Skip to content

Department Field Settings Developer Guide

Ed Mozley edited this page Nov 4, 2026 · 1 revision

Department field settings - Developer Guide

How the Department field on tickets can be hidden, how a new ticket gets a default department, and the guards that stop those two settings fighting with Mandatory fields or with the Departments tab. Built for GitHub #218 Part A slice 3 (changelog #2724, #2725) in 4.9.0.

For what it does from a user's point of view, see Custom ticket fields - The Department field and Mandatory fields. The agreed spec, and the reviewer's answers that shaped it, is Form fields design section 2.2.


The shape of it

File Job
includes/tenant_settings.php The two settings and the reading side. ticketDepartmentShown(), ticketDefaultDepartmentId(), ticketDepartmentForNewTicket(), ticketDepartmentNote(), and the department key in ticketClassificationSettings()
includes/ticket_department_rules.php The guards. ticketDepartmentConflicts(), ticketDepartmentDefaultUses(), ticketDepartmentPlaceList(), plus ticketDepartmentPlaces() and ticketDepartmentRequiredAtClose()
api/tickets/department_field_settings.php GET and POST for the settings panel, under Cap::TICKETS_CUSTOM_FIELDS
api/tickets/save_mandatory_fields_settings.php Refuses ticking Department where it is hidden
api/tickets/save_department.php, delete_department.php Refuse deactivating or deleting a department that is a default
api/tickets/get_ticket_classification.php Carries the department switch to the inbox, per company in the all-companies view
tickets/settings/index.php The Department field panel on the Custom fields tab (#deptFieldPanel) and its IIFE
assets/js/inbox.js deptShownFor(), deptShownAnywhere(), applyDeptFolderRule(), syncNewTicketDepartment() and the places they are used
assets/js/tickets-table.js Drops the Department column when no company shows it

The channels that create tickets (analyst form, REST API, portal, email, messaging, web chat, catalogue approvals, the workflow create action) each make one call to ticketDepartmentForNewTicket(). They are listed under The default on every channel.

Hiding is presentation only. Nothing stored on a ticket changes, and nothing on the server refuses a department change because the field is hidden. A ticket keeps its department; workflows, SLA notification rules, department notifications and confidential departments still read it. Departments decide folders, rules and routing - not who may open a ticket. ticket_list.php has no department filter, and hiding the field does not add one.

There are no tests for any of this yet - see How to check it by hand.


Storage

Two setting keys, read through the generic per-company layer. Nothing new in the schema.

const SETTING_TICKET_DEPARTMENT_SHOWN   = 'ticket_department_shown';
const SETTING_TICKET_DEPARTMENT_DEFAULT = 'ticket_department_default';
Key Install-wide (system_settings) Per company (tenant_settings)
ticket_department_shown '1' / '0'; no row = shown '1' / '0'; no row = follow the install
ticket_department_default a department id, or '0' for no default; no row = no default a department id, '0' for an explicit No default, or no row = follow the install

The per-company table is the one every tenantSetting() key uses:

CREATE TABLE IF NOT EXISTS `tenant_settings` (
    `id`                INT NOT NULL AUTO_INCREMENT,
    `tenant_id`         INT NOT NULL,
    `setting_key`       VARCHAR(100) NOT NULL,
    `setting_value`     TEXT NULL,
    `updated_datetime`  DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_tenant_setting` (`tenant_id`,`setting_key`),
    CONSTRAINT `fk_tenant_settings_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

"No row" and "0" are different answers

This is the one thing to hold on to. tenantSetting() looks for the company's row first, then the install's, then the caller's default:

    // 1. The company's own answer, if it has one and companies exist at all.
    if ($tenantId !== null && $tenantId > 0) {
        try {
            if (isMultiTenant($conn) || tenancyTablesReady($conn)) {
                $st = $conn->prepare("SELECT setting_value FROM tenant_settings WHERE tenant_id = ? AND setting_key = ?");
                $st->execute([$tenantId, $key]);
                $row = $st->fetchColumn();
                if ($row !== false) $value = (string) $row;
            }
        } catch (Throwable) {
            // Table absent on a part-migrated install: fall through to the
            // install-wide value rather than failing the page.
        }
    }

    // 2. The install-wide default.
    if ($value === null) {

So a company row of '0' for the default stops the fall-through and means "this company has no default, whatever the install says". A missing row means "whatever the install says". setTenantSetting() with null deletes the row, which is how Follow the default is stored:

function setTenantSetting(PDO $conn, int $tenantId, string $key, ?string $value): void
{
    tenantSettingForget();   // a read later in this request must see the new value
    if ($value === null) {
        $conn->prepare("DELETE FROM tenant_settings WHERE tenant_id = ? AND setting_key = ?")
             ->execute([$tenantId, $key]);
        return;
    }

If you ever store '' instead of deleting the row, a company can never be handed back to the install-wide answer.

tenantSettingsForKey() returns only the companies that have their own row. The settings endpoint and the guards use it to tell "follow" (absent) from an answer:

function tenantSettingsForKey(PDO $conn, string $key): array
{
    $out = [];
    try {
        $st = $conn->prepare("SELECT tenant_id, setting_value FROM tenant_settings WHERE setting_key = ?");
        $st->execute([$key]);
        foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
            $out[(int) $r['tenant_id']] = (string) $r['setting_value'];
        }
    } catch (Throwable) { /* table absent β€” everyone follows the default */ }
    return $out;
}

Reading the settings

/** Is the Department field shown on tickets of this company? */
function ticketDepartmentShown(PDO $conn, ?int $tenantId): bool
{
    return tenantSettingOn($conn, $tenantId, SETTING_TICKET_DEPARTMENT_SHOWN, true);
}

/** The company's default department id as stored, or null. Not checked - see ticketDepartmentForNewTicket(). */
function ticketDefaultDepartmentId(PDO $conn, ?int $tenantId): ?int
{
    $v = (int) tenantSetting($conn, $tenantId, SETTING_TICKET_DEPARTMENT_DEFAULT, '');
    return $v > 0 ? $v : null;
}
  • tenantSettingOn() treats anything but '0' as on, and the default is true - so an install that has never saved this setting shows Department, exactly as before.
  • ticketDefaultDepartmentId() turns both '0' and "nothing anywhere" into null. It does not check the department exists or is active. Only ticketDepartmentForNewTicket() does that, because that is the moment it matters.
  • $tenantId = null asks the install only. The settings panel uses that for its top row.

The switch travels with the category switches

function ticketClassificationSettings(PDO $conn, ?int $tenantId): array
{
    return [
        'category'         => ticketCategoryOn($conn, $tenantId),
        'closure_category' => ticketClosureCategoryOn($conn, $tenantId),
        'resolution_code'  => ticketResolutionCodeOn($conn, $tenantId),
        // GH #218: travels with the other field switches, so a board of several
        // companies can show Department for one ticket and hide it for the next.
        'department'       => ticketDepartmentShown($conn, $tenantId),
    ];
}

Putting it here, rather than in an endpoint of its own, means the inbox already had the plumbing: get_ticket_classification.php returns settings for the active company and, in the all-companies view, settings_by_company for every company the analyst can reach:

        foreach (getAccessibleTenantIds($conn, $analystId) as $tid) {
            $tid = (int) $tid;
            $catOpts = ['activeOnly' => true];
            if (isset($opts['portalOnly'])) $catOpts['portalOnly'] = true;
            $catsByCompany[$tid]     = array_values(ticketCategoriesResolved($conn, $tid, $catOpts));
            // Resolution codes are per-company too β€” same model, same treatment.
            $codesByCompany[$tid]    = ticketResolutionCodesResolved($conn, $tid, ['activeOnly' => true]);
            $settingsByCompany[$tid] = ticketClassificationSettings($conn, $tid);
        }

See All companies ticket view - Developer Guide section 5 for why these are resolved per company and never flattened.


The default department for new tickets

The resolver

function ticketDepartmentForNewTicket(PDO $conn, ?int $tenantId, $explicit): array
{
    if ($explicit !== null && $explicit !== '' && (int) $explicit > 0) return [(int) $explicit, null];
    $id = ticketDefaultDepartmentId($conn, $tenantId);
    if ($id === null) return [null, null];
    try {
        $st = $conn->prepare("SELECT name, is_active FROM departments WHERE id = ?");
        $st->execute([$id]);
        $row = $st->fetch(PDO::FETCH_ASSOC);
    } catch (Throwable) {
        return [null, null];
    }
    if ($row && (int) $row['is_active'] === 1) return [$id, null];
    $name = $row ? (string) $row['name'] : ('#' . $id);
    return [null, "Default department '{$name}' is no longer active, so it was not applied."];
}

It returns a pair: the department to store, and a history note or null.

  1. An explicit department wins, unchecked. Validating it is the caller's job, and a caller must refuse a bad one rather than let the default quietly replace it. That ordering is deliberate - see TicketsService::createTicket below.
  2. No default - [null, null]. The ticket has no department, as before this feature.
  3. The default is re-checked at creation. The guards below stop a default being deactivated or deleted from the screen, but a database edit can still do it. A default that is gone or inactive is not applied, and the reason is returned so it lands on the ticket's history rather than vanishing.

The history note

function ticketDepartmentNote(PDO $conn, int $ticketId, ?string $note): void
{
    if ($note === null) return;
    try {
        $conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
                        VALUES (?, NULL, 'Department', NULL, ?, UTC_TIMESTAMP())")->execute([$ticketId, $note]);
    } catch (Throwable $e) {
        error_log('[department-default] could not note ticket ' . $ticketId . ': ' . $e->getMessage());
    }
}

A ticket_audit row with no analyst and field Department. It never throws: a ticket that has already been created must not fail because its note could not be written. Call it after the insert, once there is a ticket id.

The default on every channel

Every channel that creates a ticket calls the resolver before its insert and the note after it. Merge and split carry an existing ticket forward and do not take a default. An edit never applies one either - a ticket with no department keeps having none until somebody gives it one.

Channel File $explicit passed
Analyst New ticket form, REST API POST /api/v1/tickets includes/services/tickets.php (TicketsService::createTicket) the request's department_id
Portal api/self-service/create_ticket.php null - a requester never chooses one
Email intake api/tickets/check_mailbox_email.php null - a mailbox cannot set one
Messaging (WhatsApp, Telegram, Teams, Mattermost) includes/messaging/ingest.php null
Web chat includes/webchat/webchat.php null
Catalogue approvals includes/catalogue_approvals.php the catalogue item's department
Workflow Create a ticket workflow/includes/engine.php the step's department ($departmentId ?: null)

The service is the one that shows the ordering best - validate the explicit one, then resolve, then everything that depends on the department:

        self::validateLookupId($conn, 'departments', $departmentId, 'department');
        // GH #218: none given, so the company's default department - checked here,
        // after an explicit one was validated (a wrong one is refused above, never
        // swapped for the default) and before anything that depends on it.
        [$departmentId, $departmentNote] = ticketDepartmentForNewTicket($conn, $tenantId, $departmentId);
        self::validateLookupId($conn, 'ticket_types', $typeId, 'ticket type');

The note is written after the creation audit row:

            self::auditWrite($conn, $ticketId, $ctx->actorId, 'Ticket Created', null, $creationNote);
            ticketDepartmentNote($conn, $ticketId, $departmentNote);

Email intake, for comparison, has no explicit department at all:

        require_once __DIR__ . '/../../includes/tenant_settings.php';
        [$departmentId, $departmentNote] = ticketDepartmentForNewTicket($conn, $ticketTenantId !== null ? (int) $ticketTenantId : null, null);

The default and the ticket type rules. The type rules (ticketTypeAutoPick(), see Ticket types for new tickets - Developer Guide) can match on a department. Where a channel passes department_id in the rule facts - the service, catalogue approvals and the workflow action - the department is resolved first, so a rule sees the default. The portal, email, messaging and web chat pick their type before resolving the department and do not pass department_id in their facts, so a "departments" criterion never matches on those channels, default or not.


The guards

All three refuse with a message saying where the conflict is and how to fix it. None of them quietly corrects anything.

Where a setting can be answered

function ticketDepartmentPlaces(PDO $conn): array
{
    $places = [['id' => null, 'name' => '']];
    if (isMultiTenant($conn)) {
        foreach (getAllTenants($conn) as $t) $places[] = ['id' => (int) $t['id'], 'name' => (string) $t['name']];
    }
    return $places;
}

'' stands for the install. ticketDepartmentPlaceList() turns a list of these names into the text of a message, swapping '' for "the install-wide setting":

function ticketDepartmentPlaceList(array $names): string
{
    $named = array_values(array_filter($names, fn($n) => $n !== ''));
    if (count($named) < count($names)) $named[] = 'the install-wide setting';
    return implode(', ', $named);
}

Guard 1 - hidden and required at closure, refused both ways

The design first had Mandatory fields skip a hidden Department. The reviewer rejected that: a screen setting must not switch a closure rule off. So the conflicting combination cannot be saved from either side, and MandatoryFieldsService has no Department case.

function ticketDepartmentRequiredAtClose(PDO $conn, ?int $tenantId): bool
{
    return in_array('department', MandatoryFieldsService::settings($conn, $tenantId)['fields'], true);
}

Mandatory fields is install-wide today, but it is read through tenantSetting(), so this stays right if it gains a per-company column.

function ticketDepartmentConflicts(PDO $conn, ?bool $default, array $companyShown, ?bool $requiredOverride = null): array
{
    $installShown = $default ?? ticketDepartmentShown($conn, null);
    $stored = tenantSettingsForKey($conn, SETTING_TICKET_DEPARTMENT_SHOWN);
    $out = [];
    foreach (ticketDepartmentPlaces($conn) as $p) {
        if ($p['id'] === null) {
            // The install row only matters by itself on a single-company install;
            // with companies, each company is checked instead.
            if (isMultiTenant($conn)) continue;
            $shown = $installShown;
        } else {
            $tid = $p['id'];
            if (array_key_exists($tid, $companyShown)) $v = $companyShown[$tid];
            else $v = array_key_exists($tid, $stored) ? $stored[$tid] !== '0' : null;
            $shown = $v === null ? $installShown : (bool) $v;
        }
        $required = $requiredOverride ?? ticketDepartmentRequiredAtClose($conn, $p['id']);
        if (!$shown && $required) $out[] = $p['name'];
    }
    return $out;
}

It answers "if this change were saved, where would Department be hidden and required?". For each company the "shown" answer is, in order: the proposed value in $companyShown (where null means "will follow"), else its stored row, else the install value - proposed or stored. That is the same fall-through tenantSetting() does, worked out by hand because the change has not been saved yet.

The two callers ask it from opposite ends:

Caller $default $companyShown $requiredOverride
department_field_settings.php (POST) the proposed install "shown", or null the proposed per-company "shown" null - as stored
save_mandatory_fields_settings.php null - as stored [] - as stored true - "suppose Department is ticked"

The Mandatory fields side, which only runs when Department is among the ticked fields:

    if (in_array('department', $fields, true)) {
        require_once '../../includes/ticket_department_rules.php';
        $hiddenFor = ticketDepartmentConflicts($conn, null, [], true);
        if ($hiddenFor) {
            echo json_encode(['success' => false, 'error' => 'Department is hidden for ' . ticketDepartmentPlaceList($hiddenFor)
                . ' (Tickets -> Settings -> Custom fields -> Department field), so it cannot be required at closure. Show it there first.']);
            exit;
        }
    }

Guard 2 - the default must be an active department

In the settings endpoint, before anything is saved:

    $deptOk = fn($v) => $v === null || (int) $v === 0 || isset($active[(int) $v]);

$active is the list of active departments the endpoint loaded first (SELECT id, name FROM departments WHERE is_active = 1 ...). null (follow) and 0 (no default) always pass.

Guard 3 - a default cannot be deactivated or deleted

function ticketDepartmentDefaultUses(PDO $conn, int $departmentId): array
{
    $out = [];
    $install = (int) tenantSetting($conn, null, SETTING_TICKET_DEPARTMENT_DEFAULT, '');
    if ($install === $departmentId) $out[] = '';
    $names = [];
    foreach (ticketDepartmentPlaces($conn) as $p) if ($p['id'] !== null) $names[$p['id']] = $p['name'];
    foreach (tenantSettingsForKey($conn, SETTING_TICKET_DEPARTMENT_DEFAULT) as $tid => $v) {
        if ((int) $v === $departmentId) $out[] = $names[$tid] ?? ('#' . $tid);
    }
    return $out;
}

save_department.php calls it only when an existing department is being saved as inactive:

    if ($id && !(int) $is_active) {
        // GH #218: never retire a department that is a default for new tickets - new
        // tickets would silently start with none. Replace or clear the default first.
        require_once '../../includes/ticket_department_rules.php';
        $usedBy = ticketDepartmentDefaultUses($conn, (int) $id);
        if ($usedBy) {
            throw new Exception('This department is the default for new tickets for ' . ticketDepartmentPlaceList($usedBy)
                . '. Choose another default, or No default, on Tickets -> Settings -> Custom fields -> Department field, then deactivate it.');
        }
    }

delete_department.php makes the same check before its DELETE. The design had the Departments tab offer a replacement in its own dialog; the build points at the Department field panel instead (recorded in Form fields design 2.2 Built).


The settings endpoint

api/tickets/department_field_settings.php, gated by requireModuleAccessJson('tickets') and requireCapabilityJson(Cap::TICKETS_CUSTOM_FIELDS). The manifest's custom-fields tab lists both keys in setting_keys.

GET returns:

        echo json_encode([
            'success' => true, 'multi_tenant' => $multi,
            'required_at_close' => ticketDepartmentRequiredAtClose($conn, null),
            'default' => ['shown' => ticketDepartmentShown($conn, null), 'department_id' => $installDef],
            'companies' => $companies,
            'departments' => array_map(fn($id, $n) => ['id' => $id, 'name' => $n], array_keys($active), $active),
            'stale' => $stale,
        ]);
  • companies holds only companies the analyst can reach (analystCanAccessTenant()), each with shown as true / false / null (follow) and department_id as an id, 0 or null (follow).
  • departments is the active ones only - the pickers never offer an inactive department.
  • stale lists every place whose stored default is no longer an active department, so the panel can show the red warning the spec asks for.

POST takes { default: { shown, department_id }, companies: [{ id, shown, department_id }] }. In order:

  1. csrfRequireWriteMethod().
  2. Guard 2 on the install default and on each company. A company the analyst cannot reach is skipped, not refused.
  3. Guard 1 on the proposed result:
    $proposedShown = [];
    foreach ($companies as $tid => $c) if (array_key_exists('shown', $c)) $proposedShown[$tid] = $c['shown'] === null ? null : (bool) $c['shown'];
    $conflicts = ticketDepartmentConflicts($conn, $default !== null && array_key_exists('shown', $default) ? (bool) $default['shown'] : null, $proposedShown);
  1. All writes in one transaction: the install values with an upsert into system_settings, each company's with setTenantSetting() (null deletes the row). Only keys present in the body are written, so a caller can send just one of the two.
  2. tenantSettingForget(), then { success: true }.

The settings panel

The panel is the #deptFieldPanel block on the Custom fields tab of tickets/settings/index.php, filled by its own IIFE that returns early when the panel is not in the page.

        // TRAP: the controls stay disabled until the load succeeds - a failed fetch must
        // never be read as "Show / no default" and saved back as a decision.

The dfShown, dfDefault and dfSave controls are rendered disabled and only enabled at the end of a successful GET. On a multi-company install the per-company table is drawn with a Follow the default option in both columns.

The plain warning (hidden with no default) is worked out in the browser on every change, using the same fall-through as the server:

            function warn() {
                const instHidden = $('dfShown').value === '0', instNone = $('dfDefault').value === '0';
                let any = instHidden && instNone;
                document.querySelectorAll('#dfCompanies tbody tr').forEach(tr => {
                    const s = tr.querySelector('.df-shown').value, d = tr.querySelector('.df-default').value;
                    const hidden = s === '' ? instHidden : s === '0', none = d === '' ? instNone : d === '0';
                    if (hidden && none) any = true;
                });
                $('dfWarn').hidden = !any;
            }

Hidden with no default is allowed - it is the right setting where departments are not used - so this only warns. Save sends '' as null (follow):

                const val = (v, num) => v === '' ? null : (num ? Number(v) : v === '1');
                const body = {
                    default: { shown: $('dfShown').value === '1', department_id: Number($('dfDefault').value) },
                    companies: [...document.querySelectorAll('#dfCompanies tbody tr')].map(tr => ({
                        id: Number(tr.dataset.id), shown: val(tr.querySelector('.df-shown').value, false), department_id: val(tr.querySelector('.df-default').value, true)
                    }))
                };

A refusal from the server is shown as it comes, in the toast.


The ticket screens

All in assets/js/inbox.js. The switches arrive with the category switches in loadTicketClassification(): ticketClassification for the active company, and classificationByCompany (from settings_by_company) in the all-companies view.

Two questions, two functions

function deptShownFor(email) {
    // TRAP: keyed on classificationByCompany, NOT allCompaniesView. The per-company
    // settings only arrive in the all-companies view, and they are loaded before the
    // folder rule runs - allCompaniesView is set later, by loadEmails(), and never in
    // table mode. Gating on it answered for the active company alone.
    const s = (classificationByCompany && email)
        ? (classificationByCompany[ticketCompanyId(email)] || classificationByCompany[String(ticketCompanyId(email))] || ticketClassification)
        : ticketClassification;
    return !s || s.department !== false;
}
function deptShownAnywhere() {
    // Not gated on allCompaniesView - see the TRAP in deptShownFor().
    if (classificationByCompany) return Object.values(classificationByCompany).some(s => !s || s.department !== false);
    return !ticketClassification || ticketClassification.department !== false;
}
  • deptShownFor(email) - one ticket. Used where the screen is about one ticket: the reading pane and workspace, the summary line, the right-click menu on one ticket.
  • deptShownAnywhere() - any company on the board. Used for controls that act on many tickets: the bulk button, the right-click menu on a selection, the folder grouping, the table column.

Both test department !== false, never === true. The initial ticketClassification has no department key at all, and a switch that failed to load must never hide a field - unknown counts as shown.

Where each is used

Screen Code Rule
Ticket panel and workspace propertyFields.department in displayEmail() is '' deptShownFor(email)
Summary Dept line the summaryDept span is not rendered deptShownFor(email)
Bulk Department button left out of selectionActionsHtml() deptShownAnywhere()
Right-click Set department the whole item is hidden by populateContextDepartmentSubmenu() one ticket: deptShownFor(); a selection: deptShownAnywhere()
New ticket Department box syncNewTicketDepartment() the company the ticket is for
Department folder grouping applyDeptFolderRule() deptShownAnywhere()
Department table column tickets-table.js setup() deptShownAnywhere()

The panel and the workspace are both built from one propertyFields object, so one empty string covers both:

        // GH #218: '' when this ticket's company hides Department - in the classic pane
        // and the workspace alike, since both are filled from this one list.
        department: !deptShownFor(email) ? '' : `<div class="toolbar-field">

With the field absent there is no #departmentSelect. assignDepartment() only runs from that select's onchange, and setDepartmentFromContext() and updatePropertiesSummary() already check for a missing element (if (sel), if (summaryDept && currentEmail)), so nothing throws.

The right-click menu decides per call, because the same menu serves one ticket and a selection:

    const target = !ctxActsOnSelection && Array.isArray(emails) ? emails.find(e => e.ticket_id == ctxTargetTicketId) : null;
    const shown = target ? deptShownFor(target) : deptShownAnywhere();
    const parentItem = sub.closest('.ticket-context-menu-item');
    if (parentItem) parentItem.style.display = shown ? '' : 'none';
    if (!shown) return;

The folder grouping

function applyDeptFolderRule() {
    const shown = deptShownAnywhere();
    const btn = document.querySelector('.folder-group-btn[data-group="department"]');
    if (btn) btn.hidden = !shown;
    if (!shown && folderGrouping === 'department') {
        folderGrouping = 'analyst';
        document.querySelectorAll('.folder-group-btn[data-group]').forEach(b => b.classList.toggle('active', b.dataset.group === folderGrouping));
    }
}

With nothing to group by, the Department button goes and an analyst grouped by department sees the Analyst grouping. In memory only - setFolderGrouping() is not called, so the saved tickets_folder_grouping preference is untouched and returns if Department is shown again. The same idea as loadTicketTeams() dropping back from a team grouping once the teams are gone.

It has to run before the first folders and list are drawn, so start-up waits for the classification alongside the two preferences it already waited for:

    const classificationReady = loadTicketClassification();
    const inboxPrefsReady = Promise.all([loadFolderGroupingPreference(), loadViewFilterPreference(), classificationReady]).then(applyDeptFolderRule);

They are fetched in parallel, so this adds no wait of its own. applyDeptFolderRule() is called only here. That is safe in the all-companies view because classificationReady brings classificationByCompany with it - see Traps.

When some companies show Department and others hide it, the Department folders stay, for every company's tickets, and dragging a ticket onto a department folder still sets its department.

The table column

        .filter(([key]) => key !== 'department_id' || typeof deptShownAnywhere !== 'function' || deptShownAnywhere())

The column list is built once, the first time the table is needed. The typeof check keeps tickets-table.js working on a page without inbox.js.

New ticket

function syncNewTicketDepartment() {
    const sel = document.getElementById('newTicketDepartment');
    if (!sel) return;
    const group = sel.closest('.form-group');
    const row = group && group.parentElement;
    const coRow = document.getElementById('newTicketCompanyRow');
    const asked = !!(coRow && !coRow.hidden);
    const coId = asked ? (document.getElementById('newTicketCompany').value || '') : '';
    let shown;
    if (asked && !coId) shown = deptShownAnywhere();
    else {
        const s = (coId && classificationByCompany) ? (classificationByCompany[coId] || classificationByCompany[String(coId)] || ticketClassification) : ticketClassification;
        shown = !s || s.department !== false;
    }
    group.style.display = shown ? '' : 'none';
    if (!shown) sel.value = '';
    if (row) row.style.gridTemplateColumns = 'repeat(' + Math.max(1, [...row.children].filter(c => c.style.display !== 'none').length) + ', 1fr)';
}
  • It follows the company the ticket is for: the one chosen on the form in the all-companies view, else the active company. Before a company is chosen it shows the box if any company would.
  • Hidden, the value is cleared. createNewTicket() then sends department_id: null, TicketsService::createTicket gets no explicit department, and ticketDepartmentForNewTicket() applies that company's default on the server. Nothing in the browser knows what the default is, and nothing needs to.
  • The Department / Type / Priority row is a CSS grid; its column count is re-counted so a hidden box leaves no gap.

It is called from openNewTicketModal() and from both branches of onNewTicketCompanyChange(), so it follows a company picked by hand or pre-selected from the analyst's preference (see All companies ticket view - Developer Guide section 6).


Traps

  • "No row" is not "0". For the default, '0' in tenant_settings is an explicit No default that stops the fall-through to the install. Clearing a company's answer means deleting the row (setTenantSetting(..., null)), never writing '' or '0'.
  • Unknown counts as shown. Test department !== false in JavaScript. A load failure, or a payload from before this feature, must not hide the field.
  • Validate the explicit department before resolving the default. ticketDepartmentForNewTicket() trusts what it is given. A channel that resolves first and validates after would turn a wrong department into a refused request - and one that validates nothing would store it.
  • A new channel must call both halves. ticketDepartmentForNewTicket() before the insert and ticketDepartmentNote() after it. Miss the first and the channel ignores the default; miss the second and a stale default goes silent.
  • Do not add a Department case to MandatoryFieldsService::fieldAvailable(). The conflict is prevented at save time from both screens; skipping it at closure would let a screen setting switch a closure rule off, which the reviewer ruled out.
  • The settings panel stays locked until it loads, for the reason the TRAP comment gives: a failed load would otherwise save "Show / no default" as a decision.
  • A stale default is not in the picker. The GET only offers active departments, so a select whose stored default is inactive has nothing to match and shows its first option - No default on the install row, Follow the default on a company row. Pressing Save then stores that. The red stale warning is there so this is a choice and not an accident.
  • Key the Department checks on classificationByCompany, never on allCompaniesView. allCompaniesView is set by loadEmails() from the list response, which runs after inboxPrefsReady, and table mode never sets it at all. Until 4.9.0 shipped (changelog #2731) both functions were gated on it, so applyDeptFolderRule() and the table column answered for the active company only: an all-companies board whose active company hid Department lost the Department folders and column even though another company showed it. classificationByCompany comes from get_ticket_classification.php only when the board is in the all-companies view (settings_by_company), and it has loaded before applyDeptFolderRule() runs, so it is the reliable signal. Reproduced on a test install with a second company: the old code hid the button, the fixed code shows it, and with every company hiding Department it is still hidden.
  • The generic writer would also accept these keys. The custom-fields tab lists ticket_department_shown and ticket_department_default in its manifest setting_keys, so api/settings/save_system_settings.php will write the install-wide values for anyone holding TICKETS_CUSTOM_FIELDS - without Guard 1 or Guard 2, which live only in department_field_settings.php. Nothing in the app posts these keys there; keep it that way, and save them through the dedicated endpoint.
  • ticketDepartmentConflicts() checks every company (getAllTenants()), not only the ones the analyst can reach, because an install-wide change reaches every company that follows it.

How to check it by hand

There are no automated tests for the Department settings, the guards, the default on each channel or the inbox hiding. tests/ has nothing that calls ticketDepartmentForNewTicket(), ticketDepartmentConflicts() or department_field_settings.php. Use a test install, never real data, and make it multi-company for the per-company checks.

The settings and guards

  1. Tickets -> Settings -> Custom fields -> Department field. Hide it install-wide with No default: the yellow warning appears. Save.
  2. Tick Department on Mandatory fields and save: refused, naming the place.
  3. Untick it, show Department, tick it on Mandatory fields, then try to hide Department: refused, naming the company (or the install-wide setting).
  4. Set a company's default to a department, then try to deactivate that department on the Departments tab, and to delete it: both refused, naming the company.
  5. Make the default inactive behind the screen's back (UPDATE departments SET is_active = 0 WHERE id = ... on the test install). Reload the panel: the red stale warning names it.

The default on creation

  1. With a default set and Department hidden, create a ticket from New ticket, the portal, an email, the REST API without department_id: each lands in the default department.
  2. Through the REST API, send an explicit active department: it wins. Send one that does not exist: refused, not defaulted.
  3. With the default inactive (step 5), create a ticket: no department, and its history has "Default department '...' is no longer active, so it was not applied."
  4. Edit an existing ticket with no department: the default is not applied.

The screens

  1. One company hiding Department, another showing it, all-companies view: open a ticket from each. The hiding company's ticket has no Department field (panel and workspace), no Dept summary line and no right-click Set department; the other has all three. The bulk button and the Department folder grouping stay.
  2. Every company hiding it: the bulk button, the Department grouping button and the Department column go, and an analyst grouped by department sees the Analyst grouping. Show it again and reload: the saved grouping is back.
  3. New ticket in the all-companies view: choose the hiding company and the Department box disappears with no gap in the row; choose the other and it returns.

Read the results through the API or the DOM, not from window: inbox.js state lives in top-level lets, which are not properties of window.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally