Files

746 lines
27 KiB
JavaScript

async function loadWorkflowEditor() {
const result = await apiGet('/api/workflow.php', {
api: 'WorkflowData',
_: Date.now()
});
if (!result.success) {
document.getElementById('workflowGraph').innerHTML = '<div class="alert alert-danger m-3">Could not load workflow data.</div>';
return;
}
workflowData = result.workflow;
if (!selectedWorkflowTaskType && workflowData.task_types.length) {
selectedWorkflowTaskType = String(workflowData.task_types[0].id);
}
if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
}
renderWorkflowEditor();
}
function renderWorkflowEditor() {
if (!workflowData) return;
populateWorkflowSelects();
renderWorkflowLists();
renderWorkflowGraph();
}
function populateWorkflowSelects() {
const stateOptions = workflowData.states.map((state) => ({
id: state.id,
name: state.name
}));
populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
placeholder: 'From state'
});
populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
placeholder: 'To state'
});
populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
placeholder: 'State'
});
populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
placeholder: 'Task type',
selectedValue: selectedWorkflowTaskType ?? ''
});
const assignedStates = getSelectedWorkflowAssignments()
.map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
.filter(Boolean);
const selectedType = getSelectedWorkflowTaskType();
populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
placeholder: 'No default state',
selectedValue: selectedType?.default_state ?? ''
});
}
function renderWorkflowLists() {
const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
const selectedAssignments = getSelectedWorkflowAssignments();
const stateList = document.getElementById('workflowStateList');
stateList.innerHTML = workflowData.states.map((state) => `
<div class="workflow-list-item">
<div class="workflow-list-main">
<span class="workflow-color-dot" style="background: ${escapeHtml(state.color)}"></span>
<span>${escapeHtml(state.name)}</span>
</div>
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-state="${escapeHtml(state.id)}">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('') || '<div class="text-secondary">No states yet.</div>';
const transitionList = document.getElementById('workflowTransitionList');
transitionList.innerHTML = workflowData.transitions.map((transition) => `
<div class="workflow-list-item">
<div class="workflow-list-main">
<span>${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}</span>
<i class="fa-solid fa-arrow-right"></i>
<span>${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}</span>
<span class="text-secondary">${escapeHtml(transition.action_name)}</span>
</div>
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-transition="${escapeHtml(transition.id)}">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('') || '<div class="text-secondary">No transitions yet.</div>';
const assignmentList = document.getElementById('workflowAssignmentList');
assignmentList.innerHTML = selectedAssignments.map((assignment) => `
<div class="workflow-list-item">
<div class="workflow-list-main">
<span>${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}</span>
</div>
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-remove-assignment="${escapeHtml(assignment.id)}">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('') || '<div class="text-secondary">No assigned states yet.</div>';
}
function getSelectedWorkflowTaskType() {
return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
}
function getSelectedWorkflowAssignments() {
if (!selectedWorkflowTaskType) return [];
return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
}
function renderWorkflowGraph() {
const graph = document.getElementById('workflowGraph');
const empty = document.getElementById('workflowGraphEmpty');
if (!graph || !empty) return;
const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
const visibleTransitions = workflowData.transitions.filter((transition) => (
assignedStateIds.has(Number(transition.from_id))
&& assignedStateIds.has(Number(transition.to_id))
));
if (!visibleStates.length) {
if (workflowGraph) {
workflowGraph.destroy();
workflowGraph = null;
}
graph.innerHTML = '';
empty.hidden = false;
empty.textContent = selectedWorkflowTaskType
? 'Assign states to this task type to render its workflow graph.'
: 'Select a task type to render its workflow graph.';
return;
}
empty.hidden = true;
if (typeof cytoscape !== 'function') {
graph.innerHTML = '<div class="alert alert-warning m-3">Cytoscape.js could not be loaded.</div>';
return;
}
const elements = [
...visibleStates.map((state) => ({
data: {
id: `state-${state.id}`,
label: state.name,
color: state.color
}
})),
...visibleTransitions.map((transition) => ({
data: {
id: `transition-${transition.id}`,
source: `state-${transition.from_id}`,
target: `state-${transition.to_id}`,
label: transition.action_name
}
}))
];
const styles = getComputedStyle(document.documentElement);
const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
if (workflowGraph) {
workflowGraph.destroy();
}
workflowGraph = cytoscape({
container: graph,
elements,
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
'border-color': borderColor,
'border-width': 1,
'color': bodyColor,
'font-size': 13,
'font-weight': 700,
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': 90,
'width': 86,
'height': 42,
'shape': 'round-rectangle'
}
},
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'line-color': secondaryColor,
'target-arrow-color': secondaryColor,
'color': secondaryColor,
'font-size': 11,
'label': 'data(label)',
'text-rotation': 'autorotate',
'text-margin-y': -8,
'width': 2
}
}
],
layout: {
name: 'breadthfirst',
directed: true,
padding: 36,
spacingFactor: 1.25
}
});
}
async function saveWorkflowAction(api, body = {}, params = {}) {
const result = await apiPost('/api/workflow.php', {
api,
...params
}, body);
if (!result.success) {
alert(result.error || 'Could not save workflow change.');
return false;
}
await loadWorkflowEditor();
return true;
}
async function loadAdminOptions() {
let result = null;
try {
result = await apiGet('/api/admin_options.php', {
api: 'OptionsData',
_: Date.now()
});
} catch (error) {
result = {
success: false,
error: 'Could not load admin options.'
};
}
if (!result.success) {
const typeEditor = document.getElementById('adminSelectedTypeEditor');
const priorityList = document.getElementById('adminPriorityList');
const userRightsList = document.getElementById('adminUserRightsList');
if (typeEditor) {
typeEditor.innerHTML = '<div class="text-danger">Could not load task types.</div>';
}
if (priorityList) {
priorityList.innerHTML = '<div class="text-danger">Could not load priorities.</div>';
}
if (userRightsList) {
userRightsList.innerHTML = '<div class="text-danger">Could not load users.</div>';
}
return;
}
adminOptionsData = {
types: result.options?.types ?? [],
priorities: result.options?.priorities ?? [],
custom_fields: result.options?.custom_fields ?? [],
users: result.options?.users ?? [],
rights: result.options?.rights ?? [],
user_rights: result.options?.user_rights ?? [],
projects: result.options?.projects ?? [],
user_access: result.options?.user_access ?? []
};
if (!selectedAdminTaskType && adminOptionsData.types.length) {
selectedAdminTaskType = String(adminOptionsData.types[0].id);
}
if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
}
if (!selectedAdminUser && adminOptionsData.users.length) {
selectedAdminUser = String(adminOptionsData.users[0].id);
}
if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
}
renderAdminOptions();
}
function renderAdminOptions() {
if (!adminOptionsData) return;
const types = adminOptionsData.types ?? [];
const priorities = adminOptionsData.priorities ?? [];
populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
placeholder: 'Select task type',
selectedValue: selectedAdminTaskType ?? ''
});
renderSelectedAdminTaskType();
renderAdminCustomFields();
renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
renderAdminUsers();
}
function getSelectedAdminTaskType() {
return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
}
function renderSelectedAdminTaskType() {
const container = document.getElementById('adminSelectedTypeEditor');
if (!container) return;
const type = getSelectedAdminTaskType();
if (!type) {
container.innerHTML = '<div class="text-secondary">Create or select a task type first.</div>';
return;
}
container.innerHTML = `
<form class="admin-option-item admin-selected-option" data-admin-option-edit="type" data-option-id="${escapeHtml(type.id)}">
<div class="admin-option-icon">
${type.icon
? `<img src="${escapeHtml(type.icon)}" alt="">`
: '<i class="fa-solid fa-tag"></i>'}
</div>
<input class="form-control form-control-sm" name="name" value="${escapeHtml(type.name)}" required maxlength="20">
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
<label class="admin-option-remove-logo">
<input type="checkbox" name="remove_logo" value="1">
<span>Remove logo</span>
</label>
<div class="admin-option-actions">
<button class="btn btn-sm btn-primary" type="submit" title="Save">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="type" data-option-id="${escapeHtml(type.id)}" title="Delete">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</form>
`;
}
function renderAdminCustomFields() {
const container = document.getElementById('adminCustomFieldList');
if (!container) return;
if (!selectedAdminTaskType) {
container.innerHTML = '<div class="text-secondary">Select a task type first.</div>';
return;
}
const fields = (adminOptionsData.custom_fields ?? [])
.filter((field) => String(field.task_type) === String(selectedAdminTaskType));
container.innerHTML = fields.map((field) => `
<form class="admin-custom-field-item" data-admin-custom-field-edit="${escapeHtml(field.id)}">
<input class="form-control form-control-sm" name="name" value="${escapeHtml(field.name)}" required maxlength="128">
<select class="form-select form-select-sm" name="type" required>
${customFieldTypes.map((type) => `
<option value="${escapeHtml(type.id)}" ${String(type.id) === String(field.type) ? 'selected' : ''}>${escapeHtml(type.name)}</option>
`).join('')}
</select>
<input class="form-control form-control-sm" name="value" value="${escapeHtml(field.raw_value ?? '')}">
<div class="admin-option-actions">
<button class="btn btn-sm btn-primary" type="submit" title="Save">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-custom-field-delete="${escapeHtml(field.id)}" title="Delete">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</form>
`).join('') || '<div class="text-secondary">No custom fields yet.</div>';
}
function renderAdminOptionList(kind, options, container) {
if (!container) return;
container.innerHTML = options.map((option) => `
<form class="admin-option-item" data-admin-option-edit="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}">
<div class="admin-option-icon">
${option.icon
? `<img src="${escapeHtml(option.icon)}" alt="">`
: '<i class="fa-solid fa-tag"></i>'}
</div>
<input class="form-control form-control-sm" name="name" value="${escapeHtml(option.name)}" required maxlength="128">
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
<label class="admin-option-remove-logo">
<input type="checkbox" name="remove_logo" value="1">
<span>Remove logo</span>
</label>
<div class="admin-option-actions">
<button class="btn btn-sm btn-primary" type="submit" title="Save">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}" title="Delete">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</form>
`).join('') || '<div class="text-secondary">No entries yet.</div>';
}
function getSelectedAdminUser() {
return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
}
function userHasAdminRight(userId, rightId) {
return (adminOptionsData?.user_rights ?? []).some((entry) => (
String(entry.user_id) === String(userId)
&& String(entry.right_id) === String(rightId)
));
}
function getAdminUserAccess(userId) {
return (adminOptionsData?.user_access ?? []).filter((entry) => (
String(entry.user_id) === String(userId)
));
}
function getAdminProject(projectId) {
return (adminOptionsData?.projects ?? []).find((project) => (
String(project.id) === String(projectId)
)) ?? null;
}
function renderAdminUsers() {
const select = document.getElementById('adminUserSelect');
const container = document.getElementById('adminUserRightsList');
if (!select || !container) return;
const users = adminOptionsData.users ?? [];
const rights = adminOptionsData.rights ?? [];
const projects = adminOptionsData.projects ?? [];
const selectedUser = getSelectedAdminUser();
populateSelect(select, users, {
placeholder: 'Select user',
selectedValue: selectedAdminUser ?? ''
});
if (!selectedUser) {
container.innerHTML = '<div class="text-secondary">Select a user first.</div>';
return;
}
if (!rights.length) {
container.innerHTML = '<div class="text-secondary">No rights configured yet.</div>';
return;
}
const explicitAccess = getAdminUserAccess(selectedUser.id);
const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
const addableProjects = projects.filter((project) => (
!explicitProjectIds.has(String(project.id))
&& !ownerProjectIds.has(String(project.id))
));
container.innerHTML = `
<div class="admin-user-summary">
${renderUser(selectedUser)}
</div>
<div class="admin-user-section">
<h4>Rights</h4>
<div class="admin-right-list">
${rights.map((right) => {
const checked = userHasAdminRight(selectedUser.id, right.id);
const isSelfAdminRight = appFlag('isAdmin')
&& String(selectedUser.id) === String(getCurrentUserId())
&& String(right.name) === 'Admin';
return `
<label class="admin-right-item">
<input
type="checkbox"
data-admin-user-right
data-user-id="${escapeHtml(selectedUser.id)}"
data-right-id="${escapeHtml(right.id)}"
${checked ? 'checked' : ''}
${isSelfAdminRight ? 'disabled' : ''}
>
<span>${escapeHtml(right.name)}</span>
</label>
`;
}).join('')}
</div>
</div>
<div class="admin-user-section">
<h4>Project Access</h4>
<form class="admin-project-access-form" id="adminProjectAccessForm">
<select class="form-select form-select-sm" name="project_id" ${addableProjects.length ? '' : 'disabled'}>
${addableProjects.map((project) => `
<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)} (${escapeHtml(project.id)})</option>
`).join('')}
</select>
<button class="btn btn-sm btn-primary" type="submit" ${addableProjects.length ? '' : 'disabled'}>
<i class="fa-solid fa-plus me-1"></i>
Add Access
</button>
</form>
<div class="admin-project-access-list">
${explicitAccess.map((access) => {
const project = getAdminProject(access.project_id);
return `
<div class="admin-project-access-item">
<span>${escapeHtml(project?.name ?? access.project_id)} <small>${escapeHtml(access.project_id)}</small></span>
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-project-access-delete="${escapeHtml(access.id)}" title="Remove access">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`;
}).join('') || '<div class="text-secondary">No explicit project access yet.</div>'}
</div>
${ownerProjects.length ? `
<div class="admin-project-owner-list">
<strong>Owner access</strong>
${ownerProjects.map((project) => `
<span>${escapeHtml(project.name)} <small>${escapeHtml(project.id)}</small></span>
`).join('')}
</div>
` : ''}
</div>
`;
}
async function setAdminUserRight(userId, rightId, enabled) {
const result = await apiPost('/api/admin_options.php', {
api: 'SetUserRight'
}, {
user_id: userId,
right_id: rightId,
enabled: enabled ? 1 : 0
});
if (!result.success) {
alert(result.error || 'Could not update user right.');
return false;
}
await loadAdminOptions();
return true;
}
async function grantAdminProjectAccess(userId, projectId) {
const result = await apiPost('/api/admin_options.php', {
api: 'GrantProjectAccess'
}, {
user_id: userId,
project_id: projectId
});
if (!result.success) {
alert(result.error || 'Could not add project access.');
return false;
}
await loadAdminOptions();
return true;
}
async function revokeAdminProjectAccess(accessId) {
const result = await apiPost('/api/admin_options.php', {
api: 'RevokeProjectAccess',
access_id: accessId
});
if (!result.success) {
alert(result.error || 'Could not remove project access.');
return false;
}
await loadAdminOptions();
return true;
}
async function saveAdminOptionForm(kind, form, optionId = null) {
const formData = new FormData(form);
formData.set('kind', kind);
if (optionId) {
formData.set('id', optionId);
}
const result = await apiPostForm('/api/admin_options.php', {
api: optionId ? 'UpdateOption' : 'CreateOption'
}, formData);
if (!result.success) {
alert(result.error || 'Could not save option.');
return false;
}
if (kind === 'type' && !optionId && result.option_id) {
selectedAdminTaskType = String(result.option_id);
}
taskLookupCache.types = null;
taskLookupCache.priorities = null;
await populateTaskOptionSelects();
await loadAdminOptions();
return true;
}
async function deleteAdminOption(kind, optionId) {
const result = await apiPost('/api/admin_options.php', {
api: 'DeleteOption',
kind,
id: optionId
});
if (!result.success) {
alert(result.error || 'Could not delete option.');
return false;
}
if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
selectedAdminTaskType = null;
}
taskLookupCache.types = null;
taskLookupCache.priorities = null;
await populateTaskOptionSelects();
await loadAdminOptions();
return true;
}
async function saveAdminCustomFieldForm(form, fieldId = null) {
if (!selectedAdminTaskType) {
alert('Select a task type first.');
return false;
}
const data = Object.fromEntries(new FormData(form));
data.task_type = selectedAdminTaskType;
if (fieldId) {
data.id = fieldId;
}
const result = await apiPost('/api/admin_options.php', {
api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
}, data);
if (!result.success) {
alert(result.error || 'Could not save custom field.');
return false;
}
await loadAdminOptions();
return true;
}
async function deleteAdminCustomField(fieldId) {
const result = await apiPost('/api/admin_options.php', {
api: 'DeleteCustomField',
field_id: fieldId
});
if (!result.success) {
alert(result.error || 'Could not delete custom field.');
return false;
}
await loadAdminOptions();
return true;
}
function loadAdmin(section = 'workflows', pushHistory = true) {
if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
loadDashboard(false);
return;
}
const view = showView('adminView');
if (!view) return;
if (!['workflows', 'options', 'users'].includes(section)) {
section = 'workflows';
}
document.querySelectorAll('[data-admin-section]').forEach((button) => {
button.classList.toggle('is-active', button.dataset.adminSection === section);
});
document.querySelectorAll('.admin-panel').forEach((panel) => {
panel.hidden = true;
});
const panelBySection = {
workflows: 'adminWorkflowsPanel',
options: 'adminOptionsPanel',
users: 'adminUsersPanel'
};
const panel = document.getElementById(panelBySection[section]);
if (panel) panel.hidden = false;
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set('page', 'home');
url.searchParams.set('admin', section);
url.searchParams.delete('task');
url.searchParams.delete('project');
url.searchParams.delete('tasks');
url.searchParams.delete('version');
url.searchParams.delete('profile');
window.history.pushState({}, '', url);
}
if (section === 'workflows') {
loadWorkflowEditor();
} else if (section === 'options' || section === 'users') {
loadAdminOptions();
}
}