Skip to content

Task Work View Developer Guide

Ed Mozley edited this page Nov 4, 2026 · 2 revisions

Folding empty Work sections - Developer Guide

How a task's Work view folds an empty Checklists, Forms, Procedures or Follow-ups section to a heading such as Forms (0), how an opened section and half-typed text survive the task panel being redrawn, and why a section that fails to load now says so instead of vanishing. Built for #218 part B (changelog #2719, 4.9.0); Procedures joined in PR #247 (changelog #2734-#2735, after 4.9.0).

For what it does from a user's point of view, see The Work view. The agreed spec is Simpler ticket and task forms - design. How the Work view itself is assembled (the head, progress, Complete, the Work tab) is in Forms on tasks and the Work view - Developer Guide, and the original design is Task work view - design.


The shape of it

No new tables, no new endpoints. It is one personal preference and a block of screen code:

File Job
system/preferences/index.php The preference tasks_collapse_empty_work - its default, the Empty work sections on a task dropdown, and the save
tasks/index.php Reads the preference into window.TASK_COLLAPSE_EMPTY_WORK
assets/js/tasks.js The whole feature. workSectionHead(), toggleWorkFold(), workSectionFailed(), the drafts (workDrafts, restoreWorkDrafts(), dropWorkDrafts()), and the four render functions that use them
assets/css/tasks.css .work-fold (the heading button and its chevron) and .work-load-error
lang/*/system.php, lang/*/tasks.php system.preferences.work_fold_*, tasks.work.section_count, tasks.work.load_failed

Everything goes through workSectionHead(). The four render functions - renderTaskChecklists(), renderTaskForms(), renderTaskProcedures(), renderTaskFollowUps() - each ask it for their heading and whether to draw a body at all. If you add a fifth foldable section, call it too, and add it to workRenderPart(); do not write a second fold. (4.9.0 shipped with three; Procedures was left showing in full until PR #247 made it the fourth caller.)


The preference

A personal preference, off by default, with no install-wide default (agreed in the spec). It is stored in user_preferences like every other one. The default list in system/preferences/index.php:

    // GH #218 part B: 'on' folds an empty Checklists / Forms / Procedures / Follow-ups section to
    // a heading in a task's Work view. '' shows them in full, as before.
    'tasks_collapse_empty_work'  => '',

The dropdown saves through the page's shared savePref(), which posts to api/system/set_user_preference.php:

        // ===== Empty Work sections on a task (tasks_collapse_empty_work, GH #218) =====
        const workFoldSelect = document.getElementById('workFoldSelect');
        const workFoldHint   = document.getElementById('workFoldSavingHint');
        if (workFoldSelect) {
            workFoldSelect.addEventListener('change', async function() {
                workFoldHint.classList.add('show');
                await savePref('tasks_collapse_empty_work', workFoldSelect.value);
                setTimeout(() => workFoldHint.classList.remove('show'), 1200);
            });
        }

tasks/index.php reads it in the same single query as the other task preferences, and only the exact value 'on' turns it on:

        if ($__k === 'tasks_collapse_empty_work' && $__v === 'on') { $taskCollapseEmptyWork = true; }
    window.TASK_COLLAPSE_EMPTY_WORK = <?php echo json_encode($taskCollapseEmptyWork); ?>;

Two consequences worth knowing:

  • It is read once, at page load. Changing the preference on System -> Preferences does not reach an already open Tasks tab until it is reloaded. Unlike Open here (setWorkOpenPref()), there is no in-panel control that flips the global.
  • A task opened inside a ticket follows it too. tasks/embedded.php ends in require __DIR__ . '/index.php', so the embedded task editor is the same page with the same globals. The other task pages (table, timeline, calendar) do not set the global, and do not need to: they do not draw the Work view.

What "empty" means, and the three states

A section is drawn in one of three states, and only one of them ever shows (0):

State What the user sees Where it is decided
Loading Nothing - the section stays display:none The style="display:none" on each host in renderDetailPanel()
Could not load Its plain heading, open, Could not load Checklists. and Retry workSectionFailed()
Loaded Records: in full, as before. None: the fold button workSectionHead()

There is no "loading" spinner on purpose. A section only learns whether its feature is switched on (data.enabled) when its fetch returns, so a heading drawn before that would flash up for a section that then disappears.

"Empty" means no records at all. The count passed in is the length of the section's list - checklistState.checklists.length, formState.forms.length, procState.procedures.length, followState.items.length - so a completed checklist, a submitted form, a procedure the viewer may not read, or a closed follow-up still counts and keeps its section open.


workSectionHead() - the heading, and whether there is a body

let workFold = { taskId: null, open: {} };
let workDrafts = { taskId: null, values: {} };

/** The fold state for the open task, started afresh when another task opens. */
function workFoldState() {
    if (workFold.taskId !== selectedTaskId) workFold = { taskId: selectedTaskId, open: {} };
    return workFold;
}
function workSectionHead(part, label, count) {
    const f = workFoldState();
    if (count > 0) f.open[part] = true;
    if (!window.TASK_COLLAPSE_EMPTY_WORK || count > 0) return { head: `<label>${esc(label)}</label>`, folded: false };
    const open = f.open[part] === true;
    return {
        folded: !open,
        head: `<button type="button" class="work-fold${open ? ' is-open' : ''}" data-work-fold="${part}" aria-expanded="${open}" onclick="toggleWorkFold('${part}')">
            <svg class="work-fold-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 6 15 12 9 18"></polyline></svg>
            <span>${esc(window.t('tasks.work.section_count', { name: label, count }))}</span>
        </button>`,
    };
}

Reading it line by line:

  1. if (count > 0) f.open[part] = true; runs before anything else, and runs whether or not the preference is on. A section that has ever been drawn with records for this task counts as opened. So removing the last checklist leaves Checklists open with its picker under the pointer, instead of snapping shut and moving everything below it up. The same line is what makes adding the first item keeps it open work: once the count is 1 the section is open, and it stays open after that item is removed again.
  2. The preference off, or any records - the old plain <label>, folded: false. With the preference off the Work view is byte-for-byte what it was.
  3. Otherwise it is a real <button>, so click, Enter and Space all work with no key handler, and aria-expanded tells a screen reader which way it is. The heading text is a translation key, '{name} ({count})', rather than string concatenation, so a locale can put the count where its grammar wants it.

Each render function uses the pair the same way. Checklists and Forms draw only the head when folded:

    const { head, folded } = workSectionHead('checklists', T('checklist_heading'), checklistState.checklists.length);
    host.innerHTML = folded ? head : `
        ${head}
        ${lists ? `<div class="chk-lists" data-sort="lists" ondragover="chkDragOver(event)">${lists}</div>`
                : `<div class="chk-empty">${esc(T('checklist_none'))}</div>`}
        ${attach}`;
    restoreWorkDrafts(host);

Follow-ups returns early instead, because the rest of that function builds the create and link row:

    const { head, folded } = workSectionHead('followups', T('heading'), followState.items.length);
    if (folded) { host.innerHTML = head; return; }

Procedures (PR #247) draws only the head when folded, like Checklists, then does one thing the others do not - it re-runs the Knowledge search:

    const { head, folded } = workSectionHead('procedures', T('procedures'), procState.procedures.length);
    host.innerHTML = folded ? head : `${head}${cards || `<div class="tf-empty">${esc(T('no_procedures'))}</div>`}${add}`;
    restoreWorkDrafts(host);
    if (!folded && host.querySelector('#tpSearch')?.value) searchTaskProcedures(host.querySelector('#tpSearch').value);
  • cards is one .tp-card per procedure; add is the search box #tpSearch with its results list #tpResults when the viewer may read Knowledge (procState.mayAdd), or a need Knowledge note when not. Both are only drawn when open.
  • Why the search is re-run. restoreWorkDrafts() puts back the text of the search box, but the results under it were drawn by an earlier searchTaskProcedures() call into a #tpResults that the redraw has just replaced with an empty one. Restoring the text alone would leave a filled box with nothing under it. Calling searchTaskProcedures() again draws them; it is the same debounced GET the box sends as you type (procedures.php?task_id=N&search=...), so it reads Knowledge as the viewer and writes nothing.
  • searchTaskProcedures() itself ignores fewer than two characters, so a one-letter draft redraws an empty results list, as typing it would.

When the section is opened, the body is exactly what it always was - the attach picker, Add a form, the create and link row - with the same permission checks, because it is the same code path.

Opening and closing

/** Opens or folds an empty Work section, keeping keyboard focus on its heading. */
function toggleWorkFold(part) {
    const f = workFoldState();
    f.open[part] = !f.open[part];
    workRenderPart(part);
    document.querySelector(`#taskWorkSection [data-work-fold="${part}"]`)?.focus();
}

/** Opens a Work section without the user pressing its heading - an action that sends them there. */
function workOpenPart(part) { workFoldState().open[part] = true; }

/** Redraws one Work section from the state it already holds. */
function workRenderPart(part) {
    if (part === 'checklists') renderTaskChecklists();
    else if (part === 'forms') renderTaskForms();
    else if (part === 'procedures') renderTaskProcedures();
    else if (part === 'followups') renderTaskFollowUps();
}
  • A toggle never fetches. workRenderPart() redraws from checklistState, formState, procState or followState, which the loader already filled. Opening a folded section is instant and cannot fail. (Opening Procedures with a search draft in its box re-runs that search, as above - the section's own records still come from procState.)
  • A part missing from workRenderPart() folds but never opens. The heading would toggle f.open[part] and redraw nothing, so the button looks dead. A new foldable section needs its line here.
  • The re-focus is needed because innerHTML replaced the button the user just pressed. Without it, a keyboard user pressing Enter would find focus dropped back to the document body.
  • workOpenPart() is for actions that send you somewhere. Follow up on a failed step or a submitted form calls followUpFrom(), which now opens Follow-ups before drawing it, so the prefilled title box it focuses actually exists:
function followUpFrom(kind, id, name, note) {
    followState.source = { kind, id, name, title: window.t('tasks.followups.default_title', { name }) , note: note || '' };
    workOpenPart('followups');
    dropWorkDrafts('fuTitle');   // the prefilled title, not whatever was typed before
    renderTaskFollowUps();
    const box = document.getElementById('fuTitle');
    if (box) { box.focus(); box.scrollIntoView({ block: 'center' }); }

The dropWorkDrafts('fuTitle') matters: without it, restoreWorkDrafts() inside renderTaskFollowUps() would put back whatever was half-typed earlier over the title that Follow up just prefilled.


Why the state lives outside the markup

This is the part to understand before changing anything here. From the comment above the block:

// TRAP: the fold state and the typed drafts live OUTSIDE the panel's markup, keyed by
// task. openDetailPanel() rebuilds the whole panel after many actions (Complete, a
// refused status change...), so anything kept in the DOM would be lost on every
// redraw - an opened section would snap shut and half-typed text would vanish.

openDetailPanel(taskId) is not only "open a task". It is also the screen's general "refresh this task" call: setTaskContractor(), saving a project, a refused status change and many others end with it. It calls renderDetailPanel(), which writes the whole panel's innerHTML - including the empty #taskChecklistSection, #taskFormsSection and #taskFollowUpSection hosts - and then starts all the loaders again:

        renderDetailPanel(data.task);
        workState = { taskId, task: data.task, loaded: {}, autoOpened: false };
    loadTaskChecklists(taskId);
    loadTaskForms(taskId);
    loadTaskProcedures(taskId);
    loadTaskFollowUps(taskId);

So a data-open attribute, an .is-open class or the text in an <input> all die on every one of those calls. workFold and workDrafts are plain module-level objects that the redraw never touches.

What keys them is the task id, and nothing else. Both are checked against selectedTaskId on every use and quietly reset when it changes. That gives the behaviour the spec asked for:

  • Redraw the same task as often as you like: the state survives.
  • Open another task: it starts folded, with no drafts.
  • Go back to the first task: it starts afresh too. Only the most recent task's state is kept, which is deliberate - this is "keep my place while I work on this task", not a store.

It is memory only. A browser reload, or closing the tab, loses both. "Survives a refresh" in the user docs means a refresh of the task window, not of the page.


Drafts: what is captured, and how it comes back

// Typed but not yet sent: the follow-up boxes, procedure search, a step's value and its Fail note.
// Kept as they are typed, put back after any redraw, dropped once sent.
const WORK_DRAFT_ID = /^(fuTitle|fuRef|tpSearch|chkValue\d+|chkNote\d+)$/;
document.addEventListener('input', e => {
    const el = e.target;
    if (!el || !el.id || !WORK_DRAFT_ID.test(el.id) || !el.closest('#taskWorkSection')) return;
    if (workDrafts.taskId !== selectedTaskId) workDrafts = { taskId: selectedTaskId, values: {} };
    workDrafts.values[el.id] = el.value;
});
  • One delegated listener on document, not one per box. The boxes are recreated on every redraw, so a listener attached to a box would be thrown away with it; the document is never replaced.
  • Keyed by element id, inside a task. The ids already identified the thing being typed for: fuTitle and fuRef are the follow-up create and link boxes, tpSearch is the Procedures search box, chkValue<itemId> is a checklist step's value, and chkNote<itemId> is a step's Fail note. Because the step's id is in the element id, two steps' drafts can never collide.
  • The allow-list regex is the boundary. Only those five shapes are captured, and only inside #taskWorkSection. A new draftable box needs its id added here, or it will not survive a redraw.

Putting them back:

/** Puts typed drafts back into a freshly drawn section; a Fail note that was being typed reopens its box. */
function restoreWorkDrafts(host) {
    if (workDrafts.taskId !== selectedTaskId) return;
    Object.entries(workDrafts.values).forEach(([id, v]) => {
        const el = host.querySelector('#' + id);
        if (!el || el.disabled || v === '') return;
        el.value = v;
        if (id.startsWith('chkNote')) {
            const box = document.getElementById('chkException' + id.slice(7));
            if (box) box.style.display = '';
        }
    });
}
  • Scoped to the host just drawn. renderTaskChecklists(), renderTaskProcedures() and renderTaskFollowUps() each pass their own host, so a checklist redraw never writes into the follow-up boxes and the other way round. renderTaskForms() does not call it, because no form box is in the allow-list.
  • el.disabled is skipped. A completed step's value box is drawn disabled with the saved response_value in it; a stale draft must not overwrite the recorded answer.
  • v === '' is skipped, so a box someone emptied falls back to whatever the redraw drew (for fuTitle, the prefilled title).
  • A Fail note reopens its box. The note's <textarea> lives in #chkException<itemId>, drawn display:none and only shown after Fail is pressed. Restoring the text into a hidden box would keep the words and hide them, so the box is shown again. id.slice(7) strips the seven characters of chkNote to get the item id.

Forgetting them once they are sent:

/** Forgets drafts once what they were typed for has been sent. */
function dropWorkDrafts(...ids) { ids.forEach(id => delete workDrafts.values[id]); }

It is called in four places: after a successful checklist post for a step (dropWorkDrafts('chkValue' + payload.item_id, 'chkNote' + payload.item_id) in postTaskChecklist()), after a successful follow-up create or link (if (payload.action !== 'unlink') dropWorkDrafts('fuTitle', 'fuRef'); in postTaskFollowUp() - an unlink sends neither box), after a successful procedure add, and in followUpFrom() above. A failed post keeps the draft, which is the point.

The procedure one, in postTaskProcedure():

        procState.procedures = d.procedures || [];
        // The search was for the article just added: drop it, or the redraw puts the
        // text back and offers the same article again.
        if (payload.action === 'add') dropWorkDrafts('tpSearch');
        renderTaskProcedures();

PR #247 shipped without this line, and it is the clearest example of the trap below: the search text came back after an add, renderTaskProcedures() re-ran the search, and the article just added was offered again - pressing it gets That procedure is already on this task from TaskProcedures::add(). A remove keeps the draft, because a search you were halfway through has nothing to do with the procedure you took off.


A section that fails to load

Before #218 each loader treated "the request failed" the same as "the feature is off": it emptied the host and hid it. A broken Forms endpoint looked exactly like Forms on tasks being switched off. 4.9.0 changed the Checklists, Forms and Follow-ups loaders; loadTaskProcedures() still hid the section on a failure until PR #247. Now all four tell the two apart:

        const data = await fetch(API_BASE + 'forms.php?answers=1&task_id=' + taskId).then(r => r.json());
        if (!data.success) throw new Error(data.error || 'forms');
        if (!data.enabled) { host.innerHTML = ''; host.style.display = 'none'; workLoaded(taskId, 'forms', false, 0); return; }
    } catch (e) {
        console.error(e);
        workSectionFailed(host, taskId, 'forms', window.t('tasks.forms.heading'), 'loadTaskForms');
    }

data.enabled === false is still "switched off, hide it". success: false, a network error and a JSON parse error all land in the catch:

function workSectionFailed(host, taskId, part, label, retry) {
    if (taskId !== selectedTaskId) return;
    host.style.display = '';
    host.innerHTML = `<label>${esc(label)}</label>
        <div class="work-load-error" role="alert">${esc(window.t('tasks.work.load_failed', { name: label }))}
            <button type="button" class="btn btn-secondary btn-sm" onclick="${retry}(selectedTaskId)">${esc(window.t('common.retry'))}</button>
        </div>`;
    if (part !== 'followups') workLoaded(taskId, part, true, 0);
}
  • taskId !== selectedTaskId: a slow failure for a task the user has already left must not paint an error into the next task's panel.
  • Always the plain heading, never the fold button, so a failure can never read as (0).
  • Retry calls the loader by name (loadTaskChecklists, loadTaskForms, loadTaskProcedures, loadTaskFollowUps) with the current task. The name comes from the caller as a fixed string, never from data.
  • workLoaded(taskId, part, true, 0) reports the section as switched on with nothing in it. renderWorkHead() shows the whole Work block only when one of checklists, forms or procedures says enabled, so without this a failure in the only switched-on section would hide the block - and the error with it. Follow-ups are not one of the parts renderWorkHead() counts, so it does not report.

The Procedures loader, as PR #247 left it:

        const data = await fetch(API_BASE + 'procedures.php?task_id=' + taskId).then(r => r.json());
        if (taskId !== selectedTaskId) return;
        if (!data.success) throw new Error(data.error || 'procedures');
        if (!data.enabled) { host.innerHTML = ''; host.style.display = 'none'; workLoaded(taskId, 'procedures', false, 0); return; }
        procState = { taskId, procedures: data.procedures || [], mayAdd: !!data.may_add };
        renderTaskProcedures();
        workLoaded(taskId, 'procedures', true, procState.procedures.length);
    } catch (e) {
        console.error(e);
        workSectionFailed(host, taskId, 'procedures', window.t('tasks.work.procedures'), 'loadTaskProcedures');
    }
  • if (taskId !== selectedTaskId) return; comes first, before anything touches procState or the host. Only this loader has it. The checklist and forms loaders write their state for the old task and rely on the render function's own taskId check to draw nothing; that is safe too, but the early return also stops a slow switched off answer for the previous task from hiding the section of the one now open.
  • What success: false can mean here. api/tasks/procedures.php answers success: false for Not authenticated, for no Tasks access (requireModuleAccessJson), for a task the analyst cannot reach (Task not found., from procedureTaskGate()), and for an unexpected error. All of them are now a visible error with Retry. Procedures on tasks switched off, or its tables not yet created by Database Verification, is success: true, enabled: false - still hidden.

The CSS

#taskWorkSection .work-fold {
    display: inline-flex; align-items: center; gap: 4px;
    margin: 0 0 5px -4px; padding: 2px 6px 2px 4px;
    border: none; border-radius: 5px; background: none; cursor: pointer;
    font-family: inherit; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
    color: var(--text-dim, #999);
}
#taskWorkSection .work-fold:hover { background: var(--surface-2, #f1f1f4); color: var(--text, #333); }
#taskWorkSection .work-fold:focus-visible { outline: 2px solid var(--tsk-accent, #7c3aed); outline-offset: 1px; }
#taskWorkSection .work-fold-chevron { flex-shrink: 0; transition: transform 0.15s ease; }
#taskWorkSection .work-fold.is-open .work-fold-chevron { transform: rotate(90deg); }
  • It is dressed as the section label it replaces - the same 11px, weight 600, uppercase, 0.5px letter-spaced --text-dim heading as .detail-field label - so a folded section reads as a quiet heading, not as a button competing with Complete.
  • margin-left: -4px with padding-left: 4px keeps the text exactly where the plain label's text was, while giving the hover background some room on the left. Without it the heading would jump 4px right when the preference is turned on.
  • :focus-visible, not :focus, so a mouse click does not leave an outline, but a keyboard user gets one.
  • The chevron is the one used on the board card's subtask disclosure, rotated 90 degrees when open. The rotation only shows on an empty section that has been opened; a section with records gets the plain label and no chevron at all.
#taskWorkSection .work-load-error { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px; color: var(--danger-text, #b91c1c); margin-bottom: 8px; }

flex-wrap keeps Retry on the screen in the narrow side panel and on a phone, where the message and the button do not fit on one line.

Editing assets/css/tasks.css means bumping its ?v=NN on every page that loads it: tasks/index.php, tasks/settings/, tasks/table/, tasks/timeline/, tasks/calendar/, tasks/evidence/index.php and tasks/evidence/report.php. assets/js/tasks.js is loaded only by tasks/index.php (and so by tasks/embedded.php). Part B moved them from tasks.css?v=56 / tasks.js?v=71 to 57 / 72; PR #247 moved tasks.js to 74 (the CSS was untouched - Procedures reuses .work-fold and .work-load-error).

margin-bottom: 5px on the button matches the 5px under .detail-field label, so nothing below the heading moves either.


Traps

  • Do not keep Work view state in the DOM. Anything a user does to a section that should outlast a redraw goes in workFold or workDrafts (or a state object like them, keyed by task), never on an element. See Why the state lives outside the markup.
  • A new box with half-typed text needs its id in WORK_DRAFT_ID, a restoreWorkDrafts(host) call at the end of its render function, and a dropWorkDrafts() once its text has been sent. Miss the last one and the old text comes back into the empty box after a successful send - which is exactly what PR #247's procedure search did until dropWorkDrafts('tpSearch') was added.
  • Restoring a search box restores only its text. Anything drawn from the text - search results, a preview - is gone after a redraw and has to be drawn again, as renderTaskProcedures() does by re-running searchTaskProcedures().
  • Do not hide a section in the catch. That was the old behaviour and it made a broken endpoint indistinguishable from a switched-off feature. Use workSectionFailed().
  • Never show a count you did not load. The heading's count is the length of the list the loader returned. A section that failed or is still loading has no count, which is why neither state can draw the fold button.

Testing it

There is no automated test for this: nothing under tests/ covers workSectionHead(), the drafts or workSectionFailed(). It was checked by hand in a browser on a test install through the real Tasks page, and the Procedures part (PR #247) by a throwaway headless-browser harness doing steps 2, 12 and 13 below. To check it again:

  1. Switch on Checklists on tasks, Forms on tasks and Procedures on tasks (Tasks -> Settings -> Checklists), and make sure there is at least one Knowledge article you can read. Set System -> Preferences -> Empty work sections on a task to Fold empty ones to a heading and reload Tasks.
  2. Open a task with no checklist, no form, no procedure and no follow-up. Expect Checklists (0), Forms (0), Procedures (0) and Follow-ups (0), each a heading with a chevron.
  3. Tab to one and press Enter, then Space: it opens and folds, and focus stays on the heading.
  4. Open Follow-ups, type a title, then change the task's project (or anything else that calls openDetailPanel()). Follow-ups is still open and the title is still there.
  5. Attach a checklist: it shows in full. Remove it: the section stays open with its picker.
  6. On a pass/fail step press Fail, type a note, and trigger a redraw: the note box is open with the text in it.
  7. Fold Follow-ups, then press Follow up on a failed step: Follow-ups opens with the prefilled title, focused.
  8. Open another task: everything starts folded again, with no drafts.
  9. Make a loader fail (for example block api/tasks/forms.php in the browser's dev tools). The section shows its heading, Could not load Forms. and Retry, never (0); unblock it and Retry recovers.
  10. Do steps 2 and 4 on a task opened inside a ticket.
  11. Set the preference back to Show them in full and reload: the Work view looks exactly as it did before 4.9.0.
  12. With the preference on again, open Procedures and type two or more letters of an article's title: results appear. Trigger a redraw: Procedures is still open, the text is back and so are the results. Pick one: it shows as a card under a plain Procedures heading, and the search box is empty. Remove it: the section stays open.
  13. Block api/tasks/procedures.php: Could not load Procedures. and Retry, never Procedures (0) and never a vanished section; unblock it and Retry recovers.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally