Skip to content

Form Submission Actions Developer Guide

Ed Mozley edited this page Sep 8, 2026 · 1 revision

Form Submission Actions β€” Developer Guide

In one sentence: a form stores an ordered list of actions for each of three moments β€” submitted, approved, rejected β€” and the workflow engine runs them, so there is one automation engine in FreeITSM and the Forms module is a second front end onto it.

User-facing: Forms Β· Engine: Workflows Β· Approvals: Catalogue Request Approvals

Shipped under discussion #95 (dschipfel, Aug 2026) across changelog entries #1504–#1513.


1. What was already there β€” and why that was the problem

The request was "let a form create a ticket". Reading the code first turned up that FreeITSM could already do it twice, in two places that had never met.

catalogueCreateTicketFromSubmission() WorkflowEngine::action_create_ticket()
Reachable when a gated catalogue request is approved any trigger, from the Workflows module
Configurable not at all fully
Requester resolved by users.id matched by email string, upserting a user
Company (tenant_id) from the requester's own record never set
Body answers as a fully-escaped <table> whatever the author typed
Provenance none none

So the configurable path produced the worse ticket, and on a multi-company install left tickets unattributed. The wiki even documented the email-string bug in passing. That is the duplication #95 had to remove β€” not add a third implementation to.

The design conclusion: the Forms module needed a front end, not an engine.


2. Where the configuration lives, and why not in workflows

The first plan was for the form editor to write a normal row into workflows with trigger_event = 'form.submitted' and a condition pinning form.id. That plan was wrong, and the reason is worth keeping:

πŸ”‘ Forms are versioned. Editing one forks the leaf into a new row with a new id (parent_form_id chains backwards, version_number counts forwards). A workflow pinned to form.id = 42 simply stops matching the moment somebody edits form 42 into form 57 β€” silently, with the rule still sitting there looking active.

The evidence was already in the tree: FormsService::createVersion() copied title, description, is_active, is_portal_visible and not requires_approval / approver_id, so editing an approval-gated form had been quietly ungating it (see Β§8).

So the lists live on the form:

-- database/freeitsm.sql
`submission_actions` TEXT NULL,
{
  "submitted": [ { "type": "create_ticket", "args": { "subject": "…", "priority_id": 3 } } ],
  "approved":  [ … ],
  "rejected":  [ … ]
}

JSON-in-TEXT for exactly the reason workflows.conditions / workflows.actions are: the schema must not need a migration every time the engine grows a new action kind.

Each entry is {type, args} β€” the same shape a stored workflow's actions use, because they are run by the same handlers.

2.1 NULL is not [], and that is load-bearing

Stored Means Behaviour
NULL never configured pre-#95 behaviour β€” nothing on submit; an approval raises a ticket the hard-coded way
[] somebody opened the panel and chose nothing obeyed: an approval raises no ticket
a list configured the list runs instead of the hard-coded raise

⭐ This is what deleted the data migration. The original plan was to seed every gated form's approved list with a create_ticket action and remove the hard-coded call. That plan had two failure modes:

  1. a form the seed missed silently stops raising tickets;
  2. a form that is seeded while the hard-coded path still runs raises two.

Encoding "absence means the old behaviour" has neither. Every form that existed before the upgrade is NULL and keeps doing exactly what it did, so nothing is seeded and nothing can be half-migrated.

The general lesson: when a migration has failure modes, look for the encoding in which absence means the old behaviour.


3. Reading and writing the lists

includes/services/forms.php

// FormsService::actionLists() - services/forms.php:553
public static function actionLists(PDO $conn, int $formId): array
{
    $none = ['submitted' => null, 'approved' => null, 'rejected' => null];

    $stmt = $conn->prepare("SELECT submission_actions FROM forms WHERE id = ?");
    $stmt->execute([$formId]);
    $raw = $stmt->fetchColumn();
    if ($raw === false || $raw === null || trim((string)$raw) === '') return $none;

    $decoded = json_decode((string)$raw, true);
    if (!is_array($decoded)) return $none;          // malformed == unconfigured

    foreach (array_keys($none) as $key) {
        if (isset($decoded[$key]) && is_array($decoded[$key])) {
            $none[$key] = array_values($decoded[$key]);
        }
    }
    return $none;
}

Note the two deliberate choices: a malformed value degrades to null rather than throwing (a form must stay submittable even if its configuration is nonsense), and a missing key stays null rather than becoming [].

Writing goes through encodeActionLists() (services/forms.php:506), which normalises to the three known keys and β€” importantly β€” validates every action type against the engine:

if (!isset(WorkflowEngine::availableActions()[$type])) {
    throw new ServiceError('validation', 'unknown_action', "Unknown action type: {$type}");
}
$list[] = ['type' => $type, 'args' => is_array($action['args'] ?? null) ? $action['args'] : []];

⚠️ Storing an unknown type would fail at run time, inside a form submission β€” where the person who typed it will never see the error. Validate at save.

Both branches of saveForm() (create and update) accept the key, and the update branch uses the module's incremental rule:

array_key_exists('submission_actions', $in)
    ? self::encodeActionLists($in['submission_actions'])
    : ($current['submission_actions'] ?? null),

⚠️ Only touched when sent. An adapter that predates #95 β€” the REST API, an integration, a script that just renames a form β€” must not wipe a form's automation as a side effect of saving its title.

3.1 It must travel through createVersion()

// services/forms.php:574 - the fork INSERT
"INSERT INTO forms (title, description, is_active, is_portal_visible,
                    requires_approval, approver_id, submission_actions,
                    created_by, modified_by, parent_form_id, version_number,
                    created_date, modified_date)
 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())"
// NULL stays NULL - "never configured" must survive the copy, or every new
// version would look deliberately configured as empty.
$src['submission_actions'] ?? null,

πŸ”‘ A per-form setting left out of this INSERT is a setting that pressing Save deletes. That is not hypothetical β€” it is precisely what had been happening to requires_approval (Β§8.1). Here it would mean a form quietly stopped raising tickets.


4. Running them β€” one loop, two callers

The action loop used to live inline inside runInner(). It was extracted so a form's list and a stored workflow's actions cannot diverge:

// WorkflowEngine::executeActionList() - engine.php:1690
private static function executeActionList(array $actions, array &$payload, array &$stepLog, bool $dryRun = false): array
{
    foreach ($actions as $i => $action) {
        $type = $action['type'] ?? '';
        $args = $action['args'] ?? [];

        if ($dryRun) { /* record would_run / would_args, continue */ }

        try {
            $result = self::executeAction($type, $args, $payload);
            self::mergeStepResult($payload, $i, $result);       // Β§5
            $stepLog[] = ['kind' => 'action', 'index' => $i, 'type' => $type,
                          'status' => 'success', 'result' => $result];
        } catch (Exception $e) {
            $stepLog[] = ['kind' => 'action', 'index' => $i, 'type' => $type,
                          'status' => 'failed', 'error' => $e->getMessage()];
            // Stop the chain. A later action almost always assumes the earlier
            // one worked - emailing somebody about a ticket that was never
            // raised is worse than not emailing them.
            return ['failed', "Action {$i} ({$type}): " . $e->getMessage()];
        }
    }
    return ['success', null];
}

runInner() is now one line at that point:

} else {
    [$status, $errorMessage] = self::executeActionList($actions, $payload, $stepLog, $dryRun);
}

πŸ”‘ Two copies of this loop would have drifted on the first change to error handling or chaining, and the difference would only ever have shown up as "it behaves differently from the form editor".

4.1 The entry point for things that are not workflows

// WorkflowEngine::runActionList() - engine.php:1760
public static function runActionList(string $label, string $event, array $actions, array $payload): array
{
    if (!$actions) return ['status' => 'skipped', 'execution_id' => null, 'steps' => []];

    try {
        $conn = connectToDatabase();
        if (!array_key_exists('event', $payload)) $payload['event'] = $event;
        $payload = self::enrichPayloadForTemplates($conn, $payload);

        $insert = $conn->prepare(
            "INSERT INTO workflow_executions
             (workflow_id, workflow_name, trigger_event, trigger_payload, status, is_dry_run, started_datetime)
             VALUES (NULL, ?, ?, ?, 'running', 0, UTC_TIMESTAMP())"
        );
        $insert->execute([$label, $event, json_encode($payload)]);
        $execId = (int)$conn->lastInsertId();

        [$status, $errorMessage] = self::executeActionList($actions, $payload, $stepLog, false);

        // …UPDATE the execution row with status / step_log / error_message…
    } catch (Throwable $e) {
        // An engine fault must not take down a form submission.
        error_log('Form action list failed (' . $label . '): ' . $e->getMessage());
        $status = 'failed';
    }
    return ['status' => $status, 'execution_id' => $execId, 'steps' => $stepLog];
}

πŸ”‘ workflow_executions.workflow_id is nullable and carries a workflow_name snapshot β€” a design that already existed so a run could outlive its parent. A form's run reuses it with no parent, labelled Form: Guest Wi-Fi Access β€” when submitted. Without this a form's automation would be the one kind of automation nobody could audit, and "it didn't do anything and I can't see why" is the worst failure mode automation has.

⚠️ Never throws. The caller is a form submission or an approval decision, and neither may fail because a rule somebody configured is wrong.


5. Chaining β€” what an earlier action produced

Before #95 every action ran blind: runInner() put each result into step_log and nowhere else. A workflow could raise a ticket and its very next action had no way to name it β€” and not as two workflows either, because create_ticket dispatches no event of its own. So "raise a ticket, then email the requester about it" β€” the single most obvious pair of actions in the product β€” was unbuildable.

// WorkflowEngine::mergeStepResult() - engine.php:1820
private static function mergeStepResult(array &$payload, int $index, $result): void
{
    if (!is_array($result) || $result === []) return;
    $payload['steps'][$index + 1] = $result;
    $payload['last'] = $result;
}
Merge code Reaches
{{last.ticket_id}} the action immediately before this one
{{steps.1.ticket_number}} a particular action, by the number shown beside it

πŸ”‘ Merged under their OWN keys, never into the payload's natural namespace. Writing a new ticket's id over ticket.id would silently repoint every later {{ticket.*}} on a ticket-triggered workflow at a different ticket from the one that fired it β€” a workflow that had been correct for a year would quietly start emailing about the wrong record.

⚠️ steps is one-based, matching the number the editor prints beside each action. A merge code that disagreed with the number on screen would be wrong in the one place people copy it from.

⚠️ Nothing merges on a dry run (nothing ran, so a later action's preview shows the code unresolved β€” which is honest) or after a failed action (the chain stops anyway).

Both prefixes are advertised for every trigger, since what an earlier action produced depends on the actions chosen, not on what fired the run:

// engine.php:809
public static function variablePrefixes(string $trigger): array
{
    $prefixes = ['last.', 'steps.'];
    if (in_array($trigger, self::SUBMISSION_FIELD_TRIGGERS, true)) {
        $prefixes[] = 'submission.fields.';
    }
    return $prefixes;
}

6. One ticket, however a form raised it

action_create_ticket() now delegates whenever the payload carries a submission:

// engine.php:2172
$submissionId = (int)(self::dotGet($payload, 'submission.id') ?? 0);
if ($submissionId > 0) {
    require_once __DIR__ . '/../../includes/catalogue_approvals.php';
    $conn = connectToDatabase();
    return catalogueRaiseTicketFromSubmissionId($conn, $submissionId, [
        // Blank means "not configured", so it must not reach the shared path as
        // an override - that is what lets the submission supply the default.
        'subject'             => $subject !== '' ? $subject : null,
        'body_html'           => $body !== '' ? nl2br(htmlspecialchars($body)) : null,
        'priority_id'         => $priorityId,
        'department_id'       => $departmentId,
        'ticket_type_id'      => $typeId,
        'assigned_analyst_id' => $assignedAnalystId,
        'from_email'          => $fromEmail,
        'from_name'           => $fromName,
        'audit_note'          => 'Raised from a form submission by a workflow',
    ]);
}
// …otherwise the original behaviour, untouched…

πŸ”‘ THE RULE: an override always wins, and the submission fills in whatever was left blank. That one sentence is what lets a single function serve the approval path (which configures nothing) and a form's action list (which may configure everything).

⚠️ The require_once must be lazy β€” inside the handler. includes/catalogue_approvals.php and includes/services/forms.php both require the engine at file level; a top-level require back would close the ring. Same idiom the engine already uses for the notifications router and the search indexer.

⚠️ Anything with no submission in its payload β€” a fan-out from ticket.created, say β€” keeps the original behaviour bit for bit.

The shared implementation gained an $overrides parameter and an anonymous-submitter fallback:

// catalogue_approvals.php:211
function catalogueCreateTicketFromSubmission(PDO $conn, array $sub, array $overrides = []): array {
    $userId = (int)($sub['submitted_by_user_id'] ?? 0);
    $fallbackEmail = trim((string)($overrides['from_email'] ?? ''));

    // A portal catalogue request always carries the account that raised it - the
    // good case. A form filled in by somebody not signed in has only the address
    // they typed. Anonymous submissions were the ONLY case the workflow action
    // ever handled, which is why it produced the weaker ticket even for portal
    // requests.
    if (!$userId && $fallbackEmail !== '') { /* …resolve or create the user… */ }
    if (!$userId) throw new Exception('This request has no requester to raise a ticket for');

    // …company from the requester's own tenant_id, NULL (triage) on a
    //    multi-company install when they have none…

    $subject = trim((string)($overrides['subject'] ?? ''));
    if ($subject === '') $subject = trim((string)($sub['form_title'] ?? '')) ?: 'Service request';

    // A blank body means "show me what they asked for", not "send an empty
    // ticket" - so the escaped answer table is the DEFAULT, not a special case
    // somebody has to know to ask for.
    $bodyHtml = (string)($overrides['body_html'] ?? '');
    if (trim($bodyHtml) === '') {
        $bodyHtml = catalogueSubmissionBodyHtml($conn, (int)$sub['id'],
            trim((string)($sub['form_title'] ?? '')) ?: 'a form');
    }
    // …
}

⚠️ The body heading takes the form title, never $subject. Once a subject override existed, passing $subject made the opening line repeat the subject already at the top of the ticket, losing the one thing that line is for. (Found by Ed reading a real ticket, not a test one.)

Every ticket raised this way now writes provenance:

"INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
 VALUES (?, NULL, 'Ticket Created', NULL, ?, UTC_TIMESTAMP())"
// analyst_id NULL is the established marker for "the system did this, not a person".

7. Sending an email with no ticket

send_email took its mailbox from the ticket, so it could not acknowledge anything that was not yet a ticket β€” which is exactly the confirmation somebody expects the moment they submit a form.

// engine.php:1965
$ticketId  = self::argInt($args, 'ticket_id',  $payload);
$mailboxId = self::argInt($args, 'mailbox_id', $payload);
if (!$ticketId && !$mailboxId) {
    throw new Exception('Either a ticket or a mailbox to send from is required');
}
// An explicitly chosen mailbox wins over the ticket's own.
if ($mailboxId) {
    $mb = $conn->prepare('SELECT * FROM target_mailboxes WHERE id = ?');
    $mb->execute([$mailboxId]);
    $mailbox = $mb->fetch(PDO::FETCH_ASSOC) ?: null;
    if (!$mailbox) throw new Exception("Mailbox not found: {$mailboxId}");
} else {
    $mailbox = templateGetMailboxForTicket($conn, $ticketId);
    if (!$mailbox) throw new Exception('Ticket has no associated mailbox β€” cannot send');
}
$fullBody = $ticketRef !== ''
    ? buildTemplateEmailBody($body, $ticketRef)   // with the reply marker
    : self::plainEmailBody($body);                // without it

⚠️ No ticket means no reply marker. buildTemplateEmailBody() appends "Please reply above this line" over an [*** SDREF:… ***] token, which is what threads a reply back onto the ticket. With no ticket the token is empty, so the trailer would invite a reply that lands nowhere. plainEmailBody() is the same styling without it.

⚠️ templateSaveSentEmail() is skipped without a ticket β€” there is no conversation to file the copy against β€” but emailLogSent() still records the send, so a standalone message is auditable under Email send log.

⚠️ mailbox is an action lookup only, never a condition field: nothing dispatches a mailbox id in a payload, so there would be nothing to compare against. The table is target_mailboxes and it has no is_active column.


8. Three faults found on the way in

All three failed silently, which is how they survived. None had been reported.

8.1 Editing a gated form removed its gate

createVersion()'s INSERT named title, description, is_active, is_portal_visible β€” and not requires_approval / approver_id. requires_approval defaults to 0, the catalogue lists leaves, so the ungated copy became the one the portal offered. Two behaviours changed at once and neither announced itself: sign-off stopped being required, and β€” the approve path being the only thing that raises the ticket β€” requests stopped becoming tickets.

⚠️ Not retrospectively repairable. A form edited before the fix lost the value at that moment; there is nothing to recover it from. Any install that used approvals needs its forms checked by hand.

8.2 All three approval events fired into nothing

catalogue_request.submitted / .approved / .rejected were dispatched from the day approvals shipped, and none was in availableTriggers(). dispatch() matches on trigger_event, and the editor can only offer what the catalogue lists β€” so all three matched zero workflows for their entire life. The approver was never told a request was waiting.

πŸ”‘ A dispatch() call without a catalogue entry is an event that can never be heard, and nothing anywhere reports the mismatch. Worth checking whenever a new event is added.

⚠️ The event is .rejected, not .declined. The name is built as 'catalogue_request.' . $decision and $decision is validated against ['approved','rejected'] β€” the word the column, the API contract and the UI button all use.

8.3 A form rule could not be restricted to one form

form.id had an entry in FIELD_LOOKUP_TABLES β€” reading a column called name. The forms table has title. The query threw, availableValuesForField() caught it and returned null, and the editor drew an empty tick-list, which is indistinguishable from a field that was never meant to have one. So "Form" was offered as something to match on and could never be filled in, and the only rule anyone could save fired for every form on the install.

'form.id' => [
    'table'     => 'forms',
    'label_col' => 'title',
    'where'     => 'is_active = 1 AND id NOT IN (SELECT parent_form_id FROM forms WHERE parent_form_id IS NOT NULL)',
    'order'     => 'title',
],

⭐ A broken lookup is indistinguishable from a missing one, because the catch swallows the difference. Sweep the whole map against the database periodically β€” all 29 pass as of #95.

⚠️ Leaves only. Frozen versions keep their titles, so listing every row offers the same form name six times with no way to tell which is current. It follows that a condition pinned to form.id stops matching once that form is versioned β€” another reason the form's own list belongs on the form.


9. Wiring in the Forms module

9.1 Submission

// FormsService::submitForm() - after the dispatch, after the commit
if ($gateApproverId === null) {
    try {
        $lists = self::actionLists($conn, $formId);
        if (!empty($lists['submitted'])) {
            WorkflowEngine::runActionList(
                'Form: ' . $form['title'] . ' β€” when submitted',
                'form.submitted',
                $lists['submitted'],
                $payload
            );
        }
    } catch (Exception $e) {
        error_log('Form action list error on submission: ' . $e->getMessage());
    }
}

⚠️ A gated request runs nothing here, for the same reason it fires a different event: its actions belong to the approval decision, and raising a ticket at submission time would step straight over the gate.

πŸ”‘ It runs inside FormsService::submitForm() β€” the single write path the UI adapter, the portal and REST API v1 all funnel through β€” so all three behave identically without any of them knowing the feature exists. See Service Layer Architecture.

9.2 The approval decision

$formLists = catalogueFormActionLists($conn, (int)$sub['form_id']);
$decisionList = $formLists[$decision] ?? null;
$listOwnsTheTicket = ($decision === 'approved' && $decisionList !== null);

$conn->beginTransaction();
try {
    if ($decision === 'approved') {
        // When the form owns this, the ticket is raised by its own action list
        // AFTER the commit. Doing it here would put an email send inside a
        // transaction, and a rolled-back email is still an email somebody
        // received.
        if (!$listOwnsTheTicket) {
            [$ticketId, $ticketNumber] = catalogueCreateTicketFromSubmission($conn, $sub);
        }
        // …UPDATE form_submissions … ticket_id = ? …
    }
    $conn->commit();
} catch (Exception $e) { /* rollback + rethrow */ }

and afterwards:

if ($decisionList) {
    $answers = catalogueSubmissionAnswerMap($conn, $submissionId);
    $run = WorkflowEngine::runActionList(
        'Form: ' . $sub['form_title'] . ' β€” when ' . $decision,
        'catalogue_request.' . $decision,
        $decisionList,
        [ 'form' => …, 'request' => …, 'submission' => ['fields' => $answers['fields'], …] ]
    );

    // If the list raised a ticket, the submission must point at it - that column
    // is what the requester's "Your requests" panel reads, and a request showing
    // no ticket after one was raised looks like a failure.
    foreach ($run['steps'] as $step) {
        if (($step['type'] ?? '') === 'create_ticket' && !empty($step['result']['ticket_id'])) {
            $conn->prepare("UPDATE form_submissions SET ticket_id = ? WHERE id = ?")
                 ->execute([(int)$step['result']['ticket_id'], $submissionId]);
            break;
        }
    }
}

catalogueFormActionLists() (catalogue_approvals.php:389) is a lazy wrapper β€” the require_once of services/forms.php sits inside the function for the same ring-avoidance reason as Β§6 β€” and returns all-null on any failure, because unreadable configuration must never block an approval decision.


10. Wiring in the editor

forms/edit/index.php exports the catalogue straight from the engine:

require_once '../../workflow/includes/engine.php';
$formActionDefs = WorkflowEngine::availableActions();

$formActionLookups = [];
foreach ($formActionDefs as $def) {
    foreach (($def['args'] ?? []) as $argSpec) {
        if (is_array($argSpec) && ($argSpec['type'] ?? '') === 'lookup' && !empty($argSpec['lookup'])) {
            $lk = $argSpec['lookup'];
            if (!isset($formActionLookups[$lk])) {
                $formActionLookups[$lk] = WorkflowEngine::availableActionLookup($lk) ?? [];
            }
        }
    }
}
window.FA_ACTION_DEFS    = <?php echo json_encode($formActionDefs, …); ?>;
window.FA_ACTION_LOOKUPS = <?php echo json_encode($formActionLookups, …); ?>;

πŸ”‘ What actions exist, what arguments each takes and which widget each argument gets all come from the engine. A second description here would drift the first time an action gained an option, and the two editors would then disagree about what the same feature can do. This mirrors workflow/editor.php exactly.

⭐ The list renderer is deliberately not shared with assets/js/workflow-editor.js β€” that one is bound to the canvas node model. Sharing the data is the point; sharing the drawing code would mean loading a canvas to render a list.

faArgControl() picks the widget from the same arg spec the canvas uses (lookup β†’ <select>, textarea, number, checkbox, else text), and only shows the merge-code hint where supports_vars is set β€” rather than on every field as decoration.

The payload keeps the null / [] distinction all the way to the wire:

function faPayload() {
    const out = {};
    FA_WHENS.forEach(w => { if (faActions[w] !== null) out[w] = faActions[w]; });
    return Object.keys(out).length ? out : null;
}
const faOut = faPayload();
if (faOut !== null) payload.submission_actions = faOut;

10.1 The three touches that keep the gate honest

  • The submitted heading becomes "When submitted (before approval)" when the form is gated. "When submitted" and "when submitted, before anyone has approved it" are different promises, and the name does most of the work.
  • The gate warning appears only when a ticket-raising action actually sits in the submitted list of a gated form β€” the one configuration that defeats the gate. Warning on every action would train people to ignore it.
  • On a gated form with nothing configured, the approved section says a ticket gets raised anyway. Otherwise an empty section reads as "nothing happens", when in fact the hard-coded raise is about to fire.

Approved and rejected are dimmed, not hidden, without a gate: hiding leaves no clue the capability exists, and configuration you cannot see is configuration you cannot fix.


11. Testing it

πŸ”΄ The editor's state lives in top-level let bindings, which are NOT window properties. The first headless harness died on w.faActions being undefined. Drive the page through its exported functions (faAddAction, faSetType, faSetArg, faRemoveAction, faPayload) and the rendered DOM β€” the surface a person actually uses, which made the test better. Same trap as PM in the Process Mapper.

The harness recipe that worked, and is worth reusing:

<!-- a temporary file in the web root, deleted straight after -->
<script>
document.cookie = 'PHPSESSID=<forged>; path=/';   // same origin, so it reaches the iframe
const f = document.createElement('iframe');
f.src = '/freeitsm-app/forms/edit/?id=186';
f.onload = () => setTimeout(() => {
    const w = f.contentWindow, d = f.contentDocument;
    d.getElementById('tabActions').click();
    w.faAddAction('submitted');
    w.faSetType('submitted', 0, 'create_ticket');
    /* …assert against w.faPayload() and the DOM… */
    document.getElementById('out').textContent = results.join('\n');
}, 1500);
document.body.appendChild(f);
</script>
chrome --headless=old --disable-gpu --no-sandbox --virtual-time-budget=15000 \
       --dump-dom "http://localhost/freeitsm-app/_fa_harness.html"

Server side, the assertion that actually matters is an existing gated form, untouched, still raising exactly one ticket β€” tested against a form that already existed rather than one the script created.

⚠️ Assert the rule, not a value. One assertion checked that the raised ticket's company "was not null" and failed β€” correctly, because a requester with no company on a multi-company install lands on NULL for triage, which is what the portal's own new-ticket path does. It was rewritten to pick a requester who has a company, so the carry is genuinely exercised.


12. Still ahead

  • create_ticket has no status, team or tags argument. dschipfel asked for all three in #95.
  • The condition Field dropdown prints raw paths (form.id) while the merge-code picker humanises them (Form Β· Id). Same data, only one got the polish.
  • form.submitted flattens a checkbox answer raw (1/0); the approval events render Yes/No via catalogueAnswerText(). Unifying changes what already-built rules receive, so it is a deliberate decision rather than a tidy-up.
  • The action lists are not yet exposed through REST API v1 β€” the service accepts them, the API resource does not surface them.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally