@gaia-ai/addon-gaia-ui 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +1 -1
- package/dist/src/Entity/ticket/Component/edit-form/index.js +18 -26
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +1 -1
- package/dist/src/Entity/ticket/Form/edit-data.js +22 -11
- package/dist/src/Entity/ticket/Form/edit-parent-source.d.ts +8 -0
- package/dist/src/Entity/ticket/Form/edit-parent-source.js +23 -0
- package/dist/src/Entity/ticket/Form/edit.d.ts +59 -33
- package/dist/src/Entity/ticket/Form/edit.js +102 -63
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +7 -17
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +77 -26
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { VNode } from '@opentui/core';
|
|
2
2
|
import type { ActiveRunInfo } from '../../../run/Data/active-run.js';
|
|
3
3
|
import type { FieldDef } from '../../Form/create.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { EditFieldId, EditFormOptions, EditFormState } from '../../Form/edit.js';
|
|
5
5
|
/** The chrome around the fields: who we write as, what went wrong, who is live. */
|
|
6
6
|
export interface EditFormChrome {
|
|
7
7
|
/** The auth profile the write will actually use (AC-5 — never implied). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The "Edit ticket" popup's body (GAIA-233, widened by GAIA-308), as
|
|
2
|
-
// around its declaration — a genuine sibling of `Component/create-form`
|
|
3
|
-
// a second implementation of it.
|
|
1
|
+
// The "Edit ticket" popup's body (GAIA-233, widened by GAIA-308 / GAIA-360), as
|
|
2
|
+
// CHROME around its declaration — a genuine sibling of `Component/create-form`
|
|
3
|
+
// now, not a second implementation of it.
|
|
4
4
|
//
|
|
5
5
|
// GAIA-257 (re-entry) AC-13/AC-14. What this file used to carry, and why each
|
|
6
6
|
// piece had to go:
|
|
@@ -22,10 +22,13 @@
|
|
|
22
22
|
// things that really are this popup's own — around one call.
|
|
23
23
|
//
|
|
24
24
|
// GAIA-313 (D4) finally makes the claim above literally true: the field list is
|
|
25
|
-
// HANDED IN rather than imported. There are two declarations now —
|
|
26
|
-
//
|
|
25
|
+
// HANDED IN rather than imported. There are two declarations now — with and
|
|
26
|
+
// without Cancel ticket — so importing either here would make this file a
|
|
27
27
|
// second decider that can drift from the screen's. It names no field; it renders
|
|
28
|
-
// the list it is given
|
|
28
|
+
// the list it is given.
|
|
29
|
+
//
|
|
30
|
+
// GAIA-360 removes the State select legend. Parent and Labels are ordinary
|
|
31
|
+
// declared fields drawn by the shared widget layer.
|
|
29
32
|
//
|
|
30
33
|
// GAIA-340 Task 9 — and it returns a RENDERABLE TREE. The rows are gone with the
|
|
31
34
|
// caret they carried: this popup's two `text` fields hold their insertion point
|
|
@@ -35,7 +38,6 @@
|
|
|
35
38
|
import { columnNode, indentedColumn, textNode } from '../../../../lib/vnode.js';
|
|
36
39
|
import { wrapToWidth } from '../../../../lib/width.js';
|
|
37
40
|
import { findOption, renderDeclaredFieldsTree, } from '../../../../Widget/index.js';
|
|
38
|
-
import { STATE_FIELD_ID, } from '../../Form/edit.js';
|
|
39
41
|
const DEFAULT_WIDTH = 58;
|
|
40
42
|
/** The id the popup's body column is rooted at. */
|
|
41
43
|
export const EDIT_FORM_REGION = 'edit-form';
|
|
@@ -48,24 +50,16 @@ export const EDIT_FORM_REGION = 'edit-form';
|
|
|
48
50
|
* again. One number, spent once, in the layout.
|
|
49
51
|
*/
|
|
50
52
|
const WARNING_INDENT = 2;
|
|
51
|
-
/** Four
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* so the accepted spelling has to be on screen. */
|
|
53
|
+
/** Four assignment/time fields plus structure: `assignee_user_id` is human
|
|
54
|
+
* ownership, `conductor_id` is what dispatches, the two durations are the
|
|
55
|
+
* ACCOUNTED time (GAIA-308) — not the run footprint the Runs tab shows — and
|
|
56
|
+
* Parent/Labels are structural (GAIA-360). The duration grammar is named
|
|
57
|
+
* because the popup has no validation channel: an unparseable entry is
|
|
58
|
+
* silently left unwritten, so the accepted spelling has to be on screen. */
|
|
58
59
|
const LEGEND = 'Assignee = human ownership · Conductor = who works it next · ' +
|
|
59
|
-
'Estimate/Spent = accounted time, not the run footprint (1d 2h 30m, 90m, or a bare number as minutes)'
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
* `Esc` closes the popup, the State row abandons the ticket. The status line
|
|
63
|
-
* says `Esc cancel`, so the disambiguation has to be on screen, next to the row
|
|
64
|
-
* that caused it. Rendered only when that row is present — a terminal ticket's
|
|
65
|
-
* popup would otherwise explain an affordance it does not offer.
|
|
66
|
-
*/
|
|
67
|
-
const STATE_LEGEND = 'State = the only lifecycle move this popup offers is cancelling the ticket · ' +
|
|
68
|
-
'Esc closes the popup and writes nothing';
|
|
60
|
+
'Estimate/Spent = accounted time, not the run footprint (1d 2h 30m, 90m, or a bare number as minutes) · ' +
|
|
61
|
+
'Parent = optional parent ticket · Labels = multi-select from gaia_labels · ' +
|
|
62
|
+
'Cancel ticket abandons a non-terminal ticket (Esc closes and writes nothing)';
|
|
69
63
|
/**
|
|
70
64
|
* A block of chrome prose, wrapped so the frame never has to clip it.
|
|
71
65
|
*
|
|
@@ -109,8 +103,6 @@ export function editFormTree(fields, s, opts, chrome = {}) {
|
|
|
109
103
|
rows.push(renderDeclaredFieldsTree(fields, s, opts, { width }));
|
|
110
104
|
rows.push(textNode({ id: 'edit-form-gap', content: '' }));
|
|
111
105
|
rows.push(...proseNodes(LEGEND, width, 'edit-form-legend'));
|
|
112
|
-
if (fields.some((f) => f.id === STATE_FIELD_ID))
|
|
113
|
-
rows.push(...proseNodes(STATE_LEGEND, width, 'edit-form-state-legend'));
|
|
114
106
|
if (chrome.message !== undefined && chrome.message !== '') {
|
|
115
107
|
rows.push(...proseNodes(chrome.message, width, 'edit-form-message'));
|
|
116
108
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OptionClients } from '../../transport.js';
|
|
2
|
-
import {
|
|
2
|
+
import type { EditFormOptions } from './edit.js';
|
|
3
3
|
/** The two clients an edit-options load may use, by role. */
|
|
4
4
|
export type EditFormClients = OptionClients;
|
|
5
5
|
export interface EditFormHints {
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// GAIA-236 — the ADAPTER between the data services and the EDIT popup, and a sibling
|
|
2
2
|
// of `loadCreateFormOptions` rather than a parameterisation of it. A data service
|
|
3
|
-
// (`users.ts`, `conductors.ts`) returns data and knows nothing about forms;
|
|
4
|
-
// module knows both sides and therefore owns the mapping to `Option` and this
|
|
5
|
-
// policy: read write-client-first, drop Anonymous, filter
|
|
6
|
-
// project, put liveness in the label,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// (`users.ts`, `conductors.ts`, `terms.ts`) returns data and knows nothing about forms;
|
|
4
|
+
// this module knows both sides and therefore owns the mapping to `Option` and this
|
|
5
|
+
// popup's policy: read write-client-first for users/conductors, drop Anonymous, filter
|
|
6
|
+
// conductors to the ticket's project, put liveness in the label, load the
|
|
7
|
+
// `gaia_labels` vocabulary for the Labels multi-select, and do NOT reorder — the
|
|
8
|
+
// popup preselects the ticket's current assignee. Each rule has exactly one user,
|
|
9
|
+
// so each is written once, here, beside the create adapter it differs from.
|
|
10
|
+
//
|
|
11
|
+
// GAIA-360: labels bag replaces the constant State select targets. Parent typeahead
|
|
12
|
+
// is an OptionSource on the screen, not a batch bag here.
|
|
9
13
|
import { listConductorsPreferringWrite, } from '../../conductor/Data/conductors.js';
|
|
14
|
+
import { listTerms } from '../../term/Data/terms.js';
|
|
10
15
|
import { listUsersPreferringWrite } from '../../user/Data/users.js';
|
|
11
|
-
|
|
16
|
+
/** The vocabulary this popup offers as its labels (GAIA-360 D9 / D11). */
|
|
17
|
+
const LABEL_VOCABULARY = 'gaia_labels';
|
|
12
18
|
/**
|
|
13
19
|
* uid 0 is Drupal's Anonymous and never a valid human assignee. A row whose uid never
|
|
14
20
|
* loaded is `undefined` — NOT Anonymous — and stays.
|
|
@@ -28,15 +34,20 @@ const withStatusSuffix = (c) => ({
|
|
|
28
34
|
label: c.status ? `${c.label} (${c.status})` : c.label,
|
|
29
35
|
});
|
|
30
36
|
export async function loadEditFormOptions(clients, hints = {}) {
|
|
31
|
-
|
|
37
|
+
// Labels have no write-preferring helper: terms are not write-gated the way
|
|
38
|
+
// assignees/conductors are. Fail-open to [] when the read client is absent or
|
|
39
|
+
// the vocabulary throws (D11).
|
|
40
|
+
const labelsPromise = clients.read
|
|
41
|
+
? listTerms(clients.read, LABEL_VOCABULARY).then((outcome) => outcome.rows.map((t) => ({ id: t.id, label: t.label })), () => [])
|
|
42
|
+
: Promise.resolve([]);
|
|
43
|
+
const [users, conductors, labels] = await Promise.all([
|
|
32
44
|
listUsersPreferringWrite(clients),
|
|
33
45
|
listConductorsPreferringWrite(clients),
|
|
46
|
+
labelsPromise,
|
|
34
47
|
]);
|
|
35
48
|
return {
|
|
36
49
|
users: excludeAnonymous(users).map((u) => ({ id: u.id, label: u.label })),
|
|
37
50
|
conductors: inProject(conductors, hints.projectId).map(withStatusSuffix),
|
|
38
|
-
|
|
39
|
-
// whether or not the two collection reads succeeded (D8).
|
|
40
|
-
stateTargets: STATE_TARGET_OPTIONS,
|
|
51
|
+
labels,
|
|
41
52
|
};
|
|
42
53
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DropshClient } from '../../../types.js';
|
|
2
|
+
import type { OptionSource } from '../../../Widget/index.js';
|
|
3
|
+
import type { ParentOption } from './create.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parent candidates for the edit popup: project-scoped, identifier+title match,
|
|
6
|
+
* excluding `ticketId` so an operator cannot parent a ticket to itself.
|
|
7
|
+
*/
|
|
8
|
+
export declare function editParentOptionSource(client: DropshClient, projectId: string, ticketId: string): OptionSource<ParentOption>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GAIA-360 D5/D9 — edit's Parent autocomplete source: the shared
|
|
2
|
+
// `parentOptionSource` (identifier+title match, project scope) with the open
|
|
3
|
+
// ticket filtered out of every load. Self-parent is a client-side footgun;
|
|
4
|
+
// deeper cycle detection stays server-side.
|
|
5
|
+
import { parentOptionSource } from './parent-source.js';
|
|
6
|
+
/**
|
|
7
|
+
* Parent candidates for the edit popup: project-scoped, identifier+title match,
|
|
8
|
+
* excluding `ticketId` so an operator cannot parent a ticket to itself.
|
|
9
|
+
*/
|
|
10
|
+
export function editParentOptionSource(client, projectId, ticketId) {
|
|
11
|
+
const base = parentOptionSource(client, projectId, 'identifier+title');
|
|
12
|
+
return {
|
|
13
|
+
bag: base.bag,
|
|
14
|
+
...(base.debounceMs === undefined ? {} : { debounceMs: base.debounceMs }),
|
|
15
|
+
async load(query) {
|
|
16
|
+
const outcome = await base.load(query);
|
|
17
|
+
return {
|
|
18
|
+
options: outcome.options.filter((row) => row.id !== ticketId),
|
|
19
|
+
...(outcome.error === undefined ? {} : { error: outcome.error }),
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type FormState } from '../../../Form/index.js';
|
|
2
2
|
import type { TicketDetailDocument } from '../../../types.js';
|
|
3
3
|
import type { FieldDef, Option } from './create.js';
|
|
4
|
-
export type EditFieldId = 'assignee' | 'conductor' | 'estimate' | 'spent' | '
|
|
4
|
+
export type EditFieldId = 'assignee' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
|
|
5
5
|
/**
|
|
6
|
-
* GAIA-313 — the ONE lifecycle move this popup offers, as a named
|
|
6
|
+
* GAIA-313 / GAIA-360 — the ONE lifecycle move this popup offers, as a named
|
|
7
|
+
* constant.
|
|
7
8
|
*
|
|
8
9
|
* Not derived, and deliberately so: `state_flow` is a `gaia_dashboard` computed
|
|
9
10
|
* field rendering the happy path (`qualification … done`) and never contains
|
|
@@ -17,14 +18,13 @@ export type EditFieldId = 'assignee' | 'conductor' | 'estimate' | 'spent' | 'sta
|
|
|
17
18
|
* predicate, and no workflow id is needed to tell them apart.
|
|
18
19
|
*/
|
|
19
20
|
export declare const CANCELLED_STATE = "cancelled";
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export declare const STATE_TARGET_OPTIONS: Option[];
|
|
21
|
+
/**
|
|
22
|
+
* The Cancel ticket action's field id (GAIA-360 D4).
|
|
23
|
+
*
|
|
24
|
+
* Never `'cancel'` — `intent.kind === 'cancel'` already means "close the
|
|
25
|
+
* popup", and two meanings for one word is a defect waiting.
|
|
26
|
+
*/
|
|
27
|
+
export declare const CANCEL_TICKET_ACTION_ID = "cancelTicket";
|
|
28
28
|
/**
|
|
29
29
|
* The states no transition leaves, in every ticket workflow.
|
|
30
30
|
*
|
|
@@ -36,9 +36,13 @@ export declare const TERMINAL_TICKET_STATES: ReadonlySet<string>;
|
|
|
36
36
|
export interface EditFormOptions {
|
|
37
37
|
users: Option[];
|
|
38
38
|
conductors: Option[];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
labels: Option[];
|
|
40
|
+
/**
|
|
41
|
+
* Parent candidates from the option loader (project-scoped typeahead). Absent
|
|
42
|
+
* until the screen primes a source; an empty array means "searched, nothing
|
|
43
|
+
* matched" once a source is live.
|
|
44
|
+
*/
|
|
45
|
+
parents?: Option[] | undefined;
|
|
42
46
|
}
|
|
43
47
|
/** What the form HOLDS. `ticketId` is a NON-field value: the kernel carries it
|
|
44
48
|
* verbatim so submit needs no ambient state, and never transitions it. */
|
|
@@ -49,12 +53,12 @@ interface EditValues {
|
|
|
49
53
|
/** The human duration as TYPED — seconds only exist at `toObject` (D5). */
|
|
50
54
|
estimate: string;
|
|
51
55
|
spent: string;
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
/** The PICKED parent's uuid, or null. `parentQuery` holds what was typed. */
|
|
57
|
+
parent: string | null;
|
|
58
|
+
parentQuery: string;
|
|
59
|
+
labels: ReadonlySet<string>;
|
|
55
60
|
}
|
|
56
|
-
/** The payload of the PATCH (GAIA-233 Decision 2
|
|
57
|
-
* already declares `attributes?`, so the writer needs no change). */
|
|
61
|
+
/** The payload of the ordinary Save PATCH (GAIA-233 Decision 2). */
|
|
58
62
|
export interface EditFormPayload {
|
|
59
63
|
attributes: Record<string, number | string | null>;
|
|
60
64
|
relationships: {
|
|
@@ -70,6 +74,24 @@ export interface EditFormPayload {
|
|
|
70
74
|
id: string;
|
|
71
75
|
} | null;
|
|
72
76
|
};
|
|
77
|
+
labels: {
|
|
78
|
+
data: {
|
|
79
|
+
type: string;
|
|
80
|
+
id: string;
|
|
81
|
+
}[];
|
|
82
|
+
};
|
|
83
|
+
parent_id: {
|
|
84
|
+
data: {
|
|
85
|
+
type: string;
|
|
86
|
+
id: string;
|
|
87
|
+
} | null;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Cancel ticket writes ONLY the lifecycle attribute (GAIA-360 D1). */
|
|
92
|
+
export interface CancelEditPayload {
|
|
93
|
+
attributes: {
|
|
94
|
+
state: typeof CANCELLED_STATE;
|
|
73
95
|
};
|
|
74
96
|
}
|
|
75
97
|
/** The ordinary popup: a non-terminal ticket can be cancelled from it. */
|
|
@@ -81,7 +103,7 @@ export declare const EDIT_FORM: {
|
|
|
81
103
|
apply(s: FormState<EditValues>, options: EditFormOptions, intent: import("../../../Form/form.js").Intent): FormState<EditValues>;
|
|
82
104
|
toObject: (s: FormState<EditValues>, options: EditFormOptions) => EditFormPayload;
|
|
83
105
|
};
|
|
84
|
-
/** A terminal ticket's popup: assignment
|
|
106
|
+
/** A terminal ticket's popup: assignment, times, structure — no Cancel ticket. */
|
|
85
107
|
export declare const EDIT_FORM_TERMINAL: {
|
|
86
108
|
fields: import("../../../Form/field.js").FieldDecl[];
|
|
87
109
|
activeField: (s: FormState<EditValues>) => import("../../../Form/field.js").FieldDecl;
|
|
@@ -94,12 +116,12 @@ export type EditFormDeclaration = typeof EDIT_FORM;
|
|
|
94
116
|
/** The ticket's state as the popup sees it; `''` when the read carried none. */
|
|
95
117
|
export declare const ticketState: (document: TicketDetailDocument) => string;
|
|
96
118
|
/**
|
|
97
|
-
* Which declaration a ticket gets (AC-4).
|
|
119
|
+
* Which declaration a ticket gets (AC-4 / D13).
|
|
98
120
|
*
|
|
99
|
-
* An unknown state fails OPEN (
|
|
121
|
+
* An unknown state fails OPEN (D13): `loadTicketDetail` requests no fieldset, so
|
|
100
122
|
* a missing `state` means genuinely unknown rather than not-requested. Hiding
|
|
101
|
-
*
|
|
102
|
-
* at worst the server's 422, which the popup already renders in full.
|
|
123
|
+
* Cancel ticket on a thin read would cost a legitimate affordance; offering it
|
|
124
|
+
* costs at worst the server's 422, which the popup already renders in full.
|
|
103
125
|
*/
|
|
104
126
|
export declare const editFormFor: (document: TicketDetailDocument) => EditFormDeclaration;
|
|
105
127
|
/** Field order in the popup, per declaration. */
|
|
@@ -107,26 +129,30 @@ export declare const EDIT_FIELDS: FieldDef<EditFieldId>[];
|
|
|
107
129
|
export declare const EDIT_FIELDS_TERMINAL: FieldDef<EditFieldId>[];
|
|
108
130
|
export type EditFormState = FormState<EditValues>;
|
|
109
131
|
/**
|
|
110
|
-
* No lists to OFFER —
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* user/conductor read must not silently take the State row's positions away
|
|
117
|
-
* (D8).
|
|
132
|
+
* No lists to OFFER — bags start empty until the adapter / option loader fills
|
|
133
|
+
* them. Prefill names every value, and `clear`/`toObject` read no option list
|
|
134
|
+
* for the required fields, so adapters that have no options hand over this bag.
|
|
135
|
+
* Exported so the screen's options-failure path can reuse it (D11): a failed
|
|
136
|
+
* user/conductor/label read must not silently remove Cancel ticket (D8 keeps
|
|
137
|
+
* the action on the declaration, not in the bag).
|
|
118
138
|
*/
|
|
119
139
|
export declare const EMPTY_EDIT_FORM_OPTIONS: EditFormOptions;
|
|
120
140
|
/** Prefill from the ticket's CURRENT values (AC-1) — never from `opts[0]`. The
|
|
121
141
|
* kernel applies a prefill with `Object.hasOwn`, so an unassigned ticket's
|
|
122
142
|
* explicit `null` STICKS instead of falling back to the first option. */
|
|
123
143
|
export declare function initialEditForm(document: TicketDetailDocument): EditFormState;
|
|
144
|
+
/**
|
|
145
|
+
* The Cancel ticket PATCH body (GAIA-360 D1): lifecycle only.
|
|
146
|
+
*
|
|
147
|
+
* Local field edits are discarded; after a successful write the detail reloads
|
|
148
|
+
* from the server (same as Save's reload path). No relationships.
|
|
149
|
+
*/
|
|
150
|
+
export declare const cancelEditPayload: () => CancelEditPayload;
|
|
124
151
|
export declare const activeEditField: (s: EditFormState) => FieldDef<EditFieldId>;
|
|
125
152
|
export declare const moveEditField: (s: EditFormState, delta: number) => EditFormState;
|
|
126
153
|
/** Cycle the active select by `dir`, `null` included (AC-4). */
|
|
127
154
|
export declare const cycleEdit: (s: EditFormState, opts: EditFormOptions, dir: 1 | -1) => EditFormState;
|
|
128
|
-
/** Clear the active select outright (the Del/Backspace shortcut).
|
|
129
|
-
* already works; a long conductor list just makes it tedious. */
|
|
155
|
+
/** Clear the active select/autocomplete outright (the Del/Backspace shortcut). */
|
|
130
156
|
export declare const clearEditValue: (s: EditFormState) => EditFormState;
|
|
131
157
|
export declare const serializeEdit: (s: EditFormState) => EditFormPayload;
|
|
132
158
|
export {};
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
// The "Edit ticket" popup (GAIA-233, widened by GAIA-308
|
|
2
|
-
// DECLARATION over the form kernel (GAIA-236): two `clearable` selects, two
|
|
3
|
-
// fields
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// kernel's. No I/O here.
|
|
1
|
+
// The "Edit ticket" popup (GAIA-233, widened by GAIA-308 / GAIA-313 / GAIA-360)
|
|
2
|
+
// as a DECLARATION over the form kernel (GAIA-236): two `clearable` selects, two
|
|
3
|
+
// text fields for accounted durations, a clearable Parent autocomplete, a Labels
|
|
4
|
+
// multi-select, and dual actions (Save + Cancel ticket on non-terminal). One
|
|
5
|
+
// `toObject` yields the ordinary Save PATCH; Cancel ticket uses a separate pure
|
|
6
|
+
// payload helper. No I/O here.
|
|
8
7
|
//
|
|
9
|
-
// GAIA-
|
|
10
|
-
//
|
|
8
|
+
// GAIA-360 removes the State select entirely (D8). Cancelling is a second
|
|
9
|
+
// `f.action` whose id is never `cancel` — that intent closes the popup (Esc).
|
|
10
|
+
// Two declarations from one body: a terminal ticket's popup simply omits the
|
|
11
|
+
// Cancel ticket action (D3), and `editFormFor` picks between them.
|
|
11
12
|
import { declareForm } from '../../../Form/index.js';
|
|
12
13
|
import { formatDuration, parseDuration } from '../../../lib/format.js';
|
|
14
|
+
import { PARENT_PAGE_SIZE } from '../Data/parents.js';
|
|
13
15
|
/**
|
|
14
|
-
* GAIA-313 — the ONE lifecycle move this popup offers, as a named
|
|
16
|
+
* GAIA-313 / GAIA-360 — the ONE lifecycle move this popup offers, as a named
|
|
17
|
+
* constant.
|
|
15
18
|
*
|
|
16
19
|
* Not derived, and deliberately so: `state_flow` is a `gaia_dashboard` computed
|
|
17
20
|
* field rendering the happy path (`qualification … done`) and never contains
|
|
@@ -25,17 +28,13 @@ import { formatDuration, parseDuration } from '../../../lib/format.js';
|
|
|
25
28
|
* predicate, and no workflow id is needed to tell them apart.
|
|
26
29
|
*/
|
|
27
30
|
export const CANCELLED_STATE = 'cancelled';
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
export const STATE_TARGET_OPTIONS = [
|
|
36
|
-
{ id: KEEP_STATE, label: 'keep current state' },
|
|
37
|
-
{ id: CANCELLED_STATE, label: 'cancelled — abandon this ticket' },
|
|
38
|
-
];
|
|
31
|
+
/**
|
|
32
|
+
* The Cancel ticket action's field id (GAIA-360 D4).
|
|
33
|
+
*
|
|
34
|
+
* Never `'cancel'` — `intent.kind === 'cancel'` already means "close the
|
|
35
|
+
* popup", and two meanings for one word is a defect waiting.
|
|
36
|
+
*/
|
|
37
|
+
export const CANCEL_TICKET_ACTION_ID = 'cancelTicket';
|
|
39
38
|
/**
|
|
40
39
|
* The states no transition leaves, in every ticket workflow.
|
|
41
40
|
*
|
|
@@ -50,6 +49,10 @@ export const TERMINAL_TICKET_STATES = new Set([
|
|
|
50
49
|
const linkage = (type, id) => ({
|
|
51
50
|
data: id === null ? null : { type, id },
|
|
52
51
|
});
|
|
52
|
+
/** Multi-value term linkage for labels (always present on ordinary Save). */
|
|
53
|
+
const labelsLinkage = (ids) => ({
|
|
54
|
+
data: [...ids].map((id) => ({ type: 'gaia_term--gaia_labels', id })),
|
|
55
|
+
});
|
|
53
56
|
/**
|
|
54
57
|
* The two time attributes of the PATCH, in seconds.
|
|
55
58
|
*
|
|
@@ -64,7 +67,7 @@ const linkage = (type, id) => ({
|
|
|
64
67
|
* keeps what it had and the operator sees their typo survive
|
|
65
68
|
* the save.
|
|
66
69
|
*
|
|
67
|
-
* This is why `toObject` cannot emit both keys unconditionally the way the
|
|
70
|
+
* This is why `toObject` cannot emit both keys unconditionally the way the
|
|
68
71
|
* relationships do.
|
|
69
72
|
*/
|
|
70
73
|
const timeAttributes = (v) => {
|
|
@@ -82,31 +85,22 @@ const timeAttributes = (v) => {
|
|
|
82
85
|
return out;
|
|
83
86
|
};
|
|
84
87
|
/**
|
|
85
|
-
*
|
|
86
|
-
* that means it (AC-2/AC-3).
|
|
87
|
-
*
|
|
88
|
-
* On every other position the key is ABSENT rather than sent as the ticket's
|
|
89
|
-
* current value: a self-transition (`coding → coding`) is not a declared
|
|
90
|
-
* transition in any workflow, so echoing the current state would turn every
|
|
91
|
-
* ordinary assignee edit into a 422.
|
|
92
|
-
*/
|
|
93
|
-
const stateAttribute = (v) => v.stateTarget === CANCELLED_STATE ? { state: CANCELLED_STATE } : {};
|
|
94
|
-
/**
|
|
95
|
-
* One body, two declarations (D3).
|
|
88
|
+
* One body, two declarations (D3 / D8).
|
|
96
89
|
*
|
|
97
90
|
* BOTH assignment selects are `clearable`: AC-4 requires REMOVING an assignment,
|
|
98
|
-
* not only changing it, so `null` is a real position in each cycle.
|
|
91
|
+
* not only changing it, so `null` is a real position in each cycle. Parent is
|
|
92
|
+
* clearable for the same reason (D7). Labels always PATCH the full selected set,
|
|
93
|
+
* including empty.
|
|
99
94
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* documented exception — see `timeAttributes`.
|
|
95
|
+
* Ordinary Save NEVER includes `attributes.state` — a self-transition is not a
|
|
96
|
+
* declared edge, so echoing the current state would turn every ordinary edit
|
|
97
|
+
* into a 422. Cancel ticket is a separate action that uses `cancelEditPayload`.
|
|
104
98
|
*
|
|
105
99
|
* The kernel builds its field list once, at module load, and every consumer —
|
|
106
100
|
* the renderer's `▸` marker, `keyToIntent`, `apply` — indexes that array by
|
|
107
101
|
* `state.active`. So a row cannot be hidden at render time without shifting
|
|
108
102
|
* every index below it; absence has to be a property of WHICH DECLARATION is in
|
|
109
|
-
* force.
|
|
103
|
+
* force.
|
|
110
104
|
*/
|
|
111
105
|
const declareEditForm = (cancellable) => declareForm({
|
|
112
106
|
fields: (f) => [
|
|
@@ -117,25 +111,33 @@ const declareEditForm = (cancellable) => declareForm({
|
|
|
117
111
|
}),
|
|
118
112
|
f.text('estimate', 'Estimate'),
|
|
119
113
|
f.text('spent', 'Spent'),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
f.autocomplete('parent', 'Parent', {
|
|
115
|
+
from: 'parents',
|
|
116
|
+
query: 'parentQuery',
|
|
117
|
+
clearable: true,
|
|
118
|
+
absentNote: '(no project context — open a project to pick a parent)',
|
|
119
|
+
pageSize: PARENT_PAGE_SIZE,
|
|
120
|
+
}),
|
|
121
|
+
f.multi('labels', 'Labels', { from: 'labels' }),
|
|
122
|
+
f.action('Save', 'submit'),
|
|
123
123
|
...(cancellable
|
|
124
|
-
? [f.
|
|
124
|
+
? [f.action('Cancel ticket', CANCEL_TICKET_ACTION_ID)]
|
|
125
125
|
: []),
|
|
126
|
-
f.action('Save'),
|
|
127
126
|
],
|
|
128
127
|
toObject: (v) => ({
|
|
129
|
-
|
|
128
|
+
// Ordinary Save never writes lifecycle state (D6/D7).
|
|
129
|
+
attributes: { ...timeAttributes(v) },
|
|
130
130
|
relationships: {
|
|
131
131
|
assignee_user_id: linkage('user--user', v.assignee),
|
|
132
132
|
conductor_id: linkage('gaia_conductor--gaia_conductor', v.conductor),
|
|
133
|
+
labels: labelsLinkage(v.labels),
|
|
134
|
+
parent_id: linkage('gaia_ticket--gaia_ticket', v.parent),
|
|
133
135
|
},
|
|
134
136
|
}),
|
|
135
137
|
});
|
|
136
138
|
/** The ordinary popup: a non-terminal ticket can be cancelled from it. */
|
|
137
139
|
export const EDIT_FORM = declareEditForm(true);
|
|
138
|
-
/** A terminal ticket's popup: assignment
|
|
140
|
+
/** A terminal ticket's popup: assignment, times, structure — no Cancel ticket. */
|
|
139
141
|
export const EDIT_FORM_TERMINAL = declareEditForm(false);
|
|
140
142
|
/** The ticket's state as the popup sees it; `''` when the read carried none. */
|
|
141
143
|
export const ticketState = (document) => {
|
|
@@ -143,12 +145,12 @@ export const ticketState = (document) => {
|
|
|
143
145
|
return typeof value === 'string' ? value : '';
|
|
144
146
|
};
|
|
145
147
|
/**
|
|
146
|
-
* Which declaration a ticket gets (AC-4).
|
|
148
|
+
* Which declaration a ticket gets (AC-4 / D13).
|
|
147
149
|
*
|
|
148
|
-
* An unknown state fails OPEN (
|
|
150
|
+
* An unknown state fails OPEN (D13): `loadTicketDetail` requests no fieldset, so
|
|
149
151
|
* a missing `state` means genuinely unknown rather than not-requested. Hiding
|
|
150
|
-
*
|
|
151
|
-
* at worst the server's 422, which the popup already renders in full.
|
|
152
|
+
* Cancel ticket on a thin read would cost a legitimate affordance; offering it
|
|
153
|
+
* costs at worst the server's 422, which the popup already renders in full.
|
|
152
154
|
*/
|
|
153
155
|
export const editFormFor = (document) => TERMINAL_TICKET_STATES.has(ticketState(document))
|
|
154
156
|
? EDIT_FORM_TERMINAL
|
|
@@ -157,19 +159,17 @@ export const editFormFor = (document) => TERMINAL_TICKET_STATES.has(ticketState(
|
|
|
157
159
|
export const EDIT_FIELDS = EDIT_FORM.fields;
|
|
158
160
|
export const EDIT_FIELDS_TERMINAL = EDIT_FORM_TERMINAL.fields;
|
|
159
161
|
/**
|
|
160
|
-
* No lists to OFFER —
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* user/conductor read must not silently take the State row's positions away
|
|
167
|
-
* (D8).
|
|
162
|
+
* No lists to OFFER — bags start empty until the adapter / option loader fills
|
|
163
|
+
* them. Prefill names every value, and `clear`/`toObject` read no option list
|
|
164
|
+
* for the required fields, so adapters that have no options hand over this bag.
|
|
165
|
+
* Exported so the screen's options-failure path can reuse it (D11): a failed
|
|
166
|
+
* user/conductor/label read must not silently remove Cancel ticket (D8 keeps
|
|
167
|
+
* the action on the declaration, not in the bag).
|
|
168
168
|
*/
|
|
169
169
|
export const EMPTY_EDIT_FORM_OPTIONS = {
|
|
170
170
|
users: [],
|
|
171
171
|
conductors: [],
|
|
172
|
-
|
|
172
|
+
labels: [],
|
|
173
173
|
};
|
|
174
174
|
const relId = (document, name) => {
|
|
175
175
|
const data = document.data?.relationships?.[name]?.data;
|
|
@@ -177,32 +177,71 @@ const relId = (document, name) => {
|
|
|
177
177
|
return null;
|
|
178
178
|
return Array.isArray(data) ? (data[0]?.id ?? null) : data.id;
|
|
179
179
|
};
|
|
180
|
+
const labelIds = (document) => {
|
|
181
|
+
const data = document.data?.relationships?.labels?.data;
|
|
182
|
+
if (!data)
|
|
183
|
+
return new Set();
|
|
184
|
+
if (Array.isArray(data)) {
|
|
185
|
+
return new Set(data.map((row) => row.id).filter(Boolean));
|
|
186
|
+
}
|
|
187
|
+
return data.id ? new Set([data.id]) : new Set();
|
|
188
|
+
};
|
|
180
189
|
const timeValue = (document, name) => {
|
|
181
190
|
const value = document.data?.attributes?.[name];
|
|
182
191
|
// An unset field prefills EMPTY, never `0m`: the popup must not turn "nobody
|
|
183
192
|
// estimated this" into a typed zero the operator then saves by accident.
|
|
184
193
|
return typeof value === 'number' ? formatDuration(value) : '';
|
|
185
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* Parent query text from the detail include when present (D9).
|
|
197
|
+
*
|
|
198
|
+
* A known parent must not blank when absent from the first candidate page — the
|
|
199
|
+
* id is always set from the relationship; the query is best-effort display
|
|
200
|
+
* material from the sideload. Missing include ⇒ empty query, id still set.
|
|
201
|
+
*/
|
|
202
|
+
const parentQueryFromDocument = (document, parentId) => {
|
|
203
|
+
if (!parentId)
|
|
204
|
+
return '';
|
|
205
|
+
const hit = document.included?.find((row) => row.id === parentId && row.type === 'gaia_ticket--gaia_ticket');
|
|
206
|
+
if (!hit)
|
|
207
|
+
return '';
|
|
208
|
+
const attrs = hit.attributes ?? {};
|
|
209
|
+
const identifier = typeof attrs.identifier === 'string' ? attrs.identifier : '';
|
|
210
|
+
const title = typeof attrs.title === 'string' ? attrs.title : '';
|
|
211
|
+
if (!identifier && !title)
|
|
212
|
+
return '';
|
|
213
|
+
return [identifier, title].filter(Boolean).join(' · ');
|
|
214
|
+
};
|
|
186
215
|
/** Prefill from the ticket's CURRENT values (AC-1) — never from `opts[0]`. The
|
|
187
216
|
* kernel applies a prefill with `Object.hasOwn`, so an unassigned ticket's
|
|
188
217
|
* explicit `null` STICKS instead of falling back to the first option. */
|
|
189
218
|
export function initialEditForm(document) {
|
|
219
|
+
const parent = relId(document, 'parent_id');
|
|
190
220
|
return EDIT_FORM.initial(EMPTY_EDIT_FORM_OPTIONS, {
|
|
191
221
|
ticketId: document.data?.id ?? '',
|
|
192
222
|
assignee: relId(document, 'assignee_user_id'),
|
|
193
223
|
conductor: relId(document, 'conductor_id'),
|
|
194
224
|
estimate: timeValue(document, 'time_estimate'),
|
|
195
225
|
spent: timeValue(document, 'time_spent'),
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
parent,
|
|
227
|
+
parentQuery: parentQueryFromDocument(document, parent),
|
|
228
|
+
labels: labelIds(document),
|
|
198
229
|
});
|
|
199
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The Cancel ticket PATCH body (GAIA-360 D1): lifecycle only.
|
|
233
|
+
*
|
|
234
|
+
* Local field edits are discarded; after a successful write the detail reloads
|
|
235
|
+
* from the server (same as Save's reload path). No relationships.
|
|
236
|
+
*/
|
|
237
|
+
export const cancelEditPayload = () => ({
|
|
238
|
+
attributes: { state: CANCELLED_STATE },
|
|
239
|
+
});
|
|
200
240
|
// ---- thin adapters over the kernel (the popup itself runs on keyToIntent) ----
|
|
201
241
|
export const activeEditField = (s) => EDIT_FORM.activeField(s);
|
|
202
242
|
export const moveEditField = (s, delta) => EDIT_FORM.move(s, delta);
|
|
203
243
|
/** Cycle the active select by `dir`, `null` included (AC-4). */
|
|
204
244
|
export const cycleEdit = (s, opts, dir) => EDIT_FORM.apply(s, opts, { kind: 'cycle', dir });
|
|
205
|
-
/** Clear the active select outright (the Del/Backspace shortcut).
|
|
206
|
-
* already works; a long conductor list just makes it tedious. */
|
|
245
|
+
/** Clear the active select/autocomplete outright (the Del/Backspace shortcut). */
|
|
207
246
|
export const clearEditValue = (s) => EDIT_FORM.apply(s, EMPTY_EDIT_FORM_OPTIONS, { kind: 'clear' });
|
|
208
247
|
export const serializeEdit = (s) => EDIT_FORM.toObject(s, EMPTY_EDIT_FORM_OPTIONS);
|
|
@@ -27,7 +27,7 @@ export declare class EditAssignmentFormScreen extends AsyncScreen<EditAssignment
|
|
|
27
27
|
};
|
|
28
28
|
readonly id: string;
|
|
29
29
|
/**
|
|
30
|
-
* The declaration THIS ticket's popup runs on (GAIA-313 D3).
|
|
30
|
+
* The declaration THIS ticket's popup runs on (GAIA-313 D3 / GAIA-360 D8).
|
|
31
31
|
*
|
|
32
32
|
* Chosen once, in `didLoad`, and used for BOTH the rendered rows and the key
|
|
33
33
|
* routing — the kernel indexes its field array by `state.active`, so two
|
|
@@ -41,6 +41,9 @@ export declare class EditAssignmentFormScreen extends AsyncScreen<EditAssignment
|
|
|
41
41
|
private action;
|
|
42
42
|
private message;
|
|
43
43
|
private submitting;
|
|
44
|
+
private context;
|
|
45
|
+
private sources;
|
|
46
|
+
private optionLoader;
|
|
44
47
|
get options(): EditFormOptions | undefined;
|
|
45
48
|
protected load(context: ScreenContext): Promise<EditAssignmentData>;
|
|
46
49
|
protected didLoad(data: EditAssignmentData): void;
|
|
@@ -68,22 +71,9 @@ export declare class EditAssignmentFormScreen extends AsyncScreen<EditAssignment
|
|
|
68
71
|
* own input context is declared `role: 'input'`, which `scopeKey()` filters
|
|
69
72
|
* out. So without this the second open reuses the first open's `form` and
|
|
70
73
|
* `declaration` — and GAIA-313 put the first LIFECYCLE-MUTATING value into that
|
|
71
|
-
* reused state
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* Overridden HERE and not on `AsyncScreen`, deliberately: the sibling modals
|
|
76
|
-
* (create-ticket, parent-filter, who-filter) share the caching shape but carry
|
|
77
|
-
* no value that changes the ticket's lifecycle, and dropping every screen's
|
|
78
|
-
* document on `leave` would re-read on every back-navigation in the cockpit.
|
|
79
|
-
* The cost here is one detail read and one options read per open — what a modal
|
|
80
|
-
* editing form should pay.
|
|
81
|
-
*
|
|
82
|
-
* Safe at every call site: `Router.navigate` calls `leave()` and then re-points
|
|
83
|
-
* `location` before anything draws, so the reset screen (whose `renderLoading`
|
|
84
|
-
* is `Loading assignment…`) is never rendered; and `submit()`'s trailing
|
|
85
|
-
* `.finally` only re-writes `submitting = false`, which `reset()` has already
|
|
86
|
-
* set, on a context whose `isActive()` is false by then.
|
|
74
|
+
* reused state. Overridden HERE and not on `AsyncScreen`, deliberately: the
|
|
75
|
+
* sibling modals share the caching shape but carry no lifecycle-mutating
|
|
76
|
+
* residual that must die with the popup.
|
|
87
77
|
*/
|
|
88
78
|
leave(): void;
|
|
89
79
|
reset(): void;
|
|
@@ -3,11 +3,13 @@ import { columnNode } from '../../../lib/vnode.js';
|
|
|
3
3
|
import { defineContext } from '../../../Routing/context.js';
|
|
4
4
|
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
5
5
|
import { presentation, } from '../../../Screen/screen.js';
|
|
6
|
+
import { createOptionLoader, } from '../../../Widget/option-source.js';
|
|
6
7
|
import { activeRunInfo } from '../../run/Data/active-run.js';
|
|
7
8
|
import { EDIT_FORM_REGION, editFormTree, } from '../Component/edit-form/index.js';
|
|
8
9
|
import { loadTicketDetail } from '../Data/detail.js';
|
|
9
|
-
import { EDIT_FORM_TERMINAL, EMPTY_EDIT_FORM_OPTIONS, editFormFor, initialEditForm, serializeEdit, } from '../Form/edit.js';
|
|
10
|
+
import { CANCEL_TICKET_ACTION_ID, cancelEditPayload, EDIT_FORM_TERMINAL, EMPTY_EDIT_FORM_OPTIONS, editFormFor, initialEditForm, serializeEdit, } from '../Form/edit.js';
|
|
10
11
|
import { loadEditFormOptions } from '../Form/edit-data.js';
|
|
12
|
+
import { editParentOptionSource } from '../Form/edit-parent-source.js';
|
|
11
13
|
import { keyToIntent } from '../Form/keys.js';
|
|
12
14
|
export const editAssignmentInputContext = defineContext('edit-assignment-input', 'input');
|
|
13
15
|
const relationshipId = (document, name) => {
|
|
@@ -35,7 +37,7 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
35
37
|
};
|
|
36
38
|
id = EditAssignmentFormScreen.route.id;
|
|
37
39
|
/**
|
|
38
|
-
* The declaration THIS ticket's popup runs on (GAIA-313 D3).
|
|
40
|
+
* The declaration THIS ticket's popup runs on (GAIA-313 D3 / GAIA-360 D8).
|
|
39
41
|
*
|
|
40
42
|
* Chosen once, in `didLoad`, and used for BOTH the rendered rows and the key
|
|
41
43
|
* routing — the kernel indexes its field array by `state.active`, so two
|
|
@@ -49,10 +51,14 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
49
51
|
action;
|
|
50
52
|
message = '';
|
|
51
53
|
submitting = false;
|
|
54
|
+
context;
|
|
55
|
+
sources = {};
|
|
56
|
+
optionLoader;
|
|
52
57
|
get options() {
|
|
53
58
|
return this.readyValue?.options;
|
|
54
59
|
}
|
|
55
60
|
async load(context) {
|
|
61
|
+
this.context = context;
|
|
56
62
|
const ticketId = context.params?.ticketId;
|
|
57
63
|
if (!context.services || !ticketId)
|
|
58
64
|
throw new Error('Assignment form requires ticketId and services');
|
|
@@ -68,11 +74,38 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
68
74
|
? {}
|
|
69
75
|
: { write: context.services.writeClient }),
|
|
70
76
|
}, ...(projectId === undefined ? [] : [{ projectId }])).catch(() => EMPTY_EDIT_FORM_OPTIONS);
|
|
77
|
+
// Parent typeahead: project-scoped source with self-exclusion (D5/D11).
|
|
78
|
+
// No project ⇒ no source, so the bag stays absent and the field shows
|
|
79
|
+
// its absentNote. Built as one object: OptionSources is Readonly and
|
|
80
|
+
// does not permit property assignment after construction.
|
|
81
|
+
this.sources =
|
|
82
|
+
projectId === undefined
|
|
83
|
+
? {}
|
|
84
|
+
: {
|
|
85
|
+
parent: editParentOptionSource(context.services.client, projectId, ticketId),
|
|
86
|
+
};
|
|
71
87
|
return { document, options };
|
|
72
88
|
}
|
|
73
89
|
didLoad(data) {
|
|
74
90
|
this.declaration = editFormFor(data.document);
|
|
75
91
|
this.form = initialEditForm(data.document);
|
|
92
|
+
this.optionLoader = createOptionLoader({
|
|
93
|
+
sources: this.sources,
|
|
94
|
+
...(this.context?.options?.debounce
|
|
95
|
+
? { debounce: this.context.options.debounce }
|
|
96
|
+
: {}),
|
|
97
|
+
current: () => this.readyValue?.options ?? null,
|
|
98
|
+
onLoaded: (next) => {
|
|
99
|
+
const ready = this.readyValue;
|
|
100
|
+
if (!ready)
|
|
101
|
+
return;
|
|
102
|
+
this.replaceReady({ ...ready, options: next });
|
|
103
|
+
this.context?.requestDraw();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// Prime parent candidates with the empty query so ←/→ browses before typing.
|
|
107
|
+
if (this.sources.parent)
|
|
108
|
+
this.optionLoader.reload('parent', '');
|
|
76
109
|
}
|
|
77
110
|
renderLoading() {
|
|
78
111
|
return ['Loading assignment…'];
|
|
@@ -107,14 +140,22 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
107
140
|
this.action = undefined;
|
|
108
141
|
return action;
|
|
109
142
|
}
|
|
110
|
-
submit(context, data
|
|
143
|
+
submit(context, data,
|
|
144
|
+
/** When true, Cancel ticket path (lifecycle-only). */
|
|
145
|
+
cancelTicket) {
|
|
111
146
|
if (this.submitting || !this.form || !context.services)
|
|
112
147
|
return;
|
|
113
148
|
this.submitting = true;
|
|
114
149
|
this.message = '';
|
|
115
150
|
const ticketId = data.document.data?.id ?? context.params?.ticketId ?? '';
|
|
116
|
-
const payload =
|
|
117
|
-
|
|
151
|
+
const payload = cancelTicket
|
|
152
|
+
? cancelEditPayload()
|
|
153
|
+
: serializeEdit(this.form);
|
|
154
|
+
const assigneeId = cancelTicket
|
|
155
|
+
? (relationshipId(data.document, 'assignee_user_id') ?? '')
|
|
156
|
+
: 'relationships' in payload
|
|
157
|
+
? (payload.relationships.assignee_user_id.data?.id ?? '')
|
|
158
|
+
: '';
|
|
118
159
|
void (context.services.writeClient ?? context.services.client)
|
|
119
160
|
.update('gaia_ticket--gaia_ticket', ticketId, payload)
|
|
120
161
|
.then(async () => {
|
|
@@ -147,20 +188,37 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
147
188
|
const data = this.readyValue;
|
|
148
189
|
if (!data || !this.form)
|
|
149
190
|
return false;
|
|
191
|
+
const field = this.declaration.activeField(this.form);
|
|
150
192
|
// GAIA-300 D3 — the field count is passed so the kernel can scope ↑/↓
|
|
151
193
|
// suggestion-browsing to single-field forms; this form has several, so
|
|
152
194
|
// its ↑/↓ behaviour is unchanged.
|
|
153
|
-
const intent = keyToIntent(input,
|
|
195
|
+
const intent = keyToIntent(input, field, {
|
|
154
196
|
fieldCount: this.declaration.fields.length,
|
|
197
|
+
...(field.query !== undefined
|
|
198
|
+
? {
|
|
199
|
+
query: String(this.form[field.query] ??
|
|
200
|
+
''),
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
155
203
|
});
|
|
156
204
|
if (!intent)
|
|
157
205
|
return true;
|
|
158
206
|
if (intent.kind === 'cancel')
|
|
159
207
|
this.action = { type: 'back' };
|
|
160
|
-
else if (intent.kind === 'submit')
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
else if (intent.kind === 'submit') {
|
|
209
|
+
// D10: Ctrl+S ALWAYS Save, even when the cursor is on Cancel ticket.
|
|
210
|
+
// Only Enter/submit while standing on the cancelTicket action cancels.
|
|
211
|
+
const cancelTicket = input !== 'ctrl+s' && field.id === CANCEL_TICKET_ACTION_ID;
|
|
212
|
+
this.submit(context, data, cancelTicket);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
163
215
|
this.form = this.declaration.apply(this.form, data.options, intent);
|
|
216
|
+
if (field.query !== undefined &&
|
|
217
|
+
(intent.kind === 'insert' || intent.kind === 'backspace')) {
|
|
218
|
+
const query = this.form[field.query];
|
|
219
|
+
this.optionLoader?.reload(field.id, typeof query === 'string' ? query : '');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
164
222
|
context.requestDraw();
|
|
165
223
|
return true;
|
|
166
224
|
}
|
|
@@ -173,22 +231,9 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
173
231
|
* own input context is declared `role: 'input'`, which `scopeKey()` filters
|
|
174
232
|
* out. So without this the second open reuses the first open's `form` and
|
|
175
233
|
* `declaration` — and GAIA-313 put the first LIFECYCLE-MUTATING value into that
|
|
176
|
-
* reused state
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* Overridden HERE and not on `AsyncScreen`, deliberately: the sibling modals
|
|
181
|
-
* (create-ticket, parent-filter, who-filter) share the caching shape but carry
|
|
182
|
-
* no value that changes the ticket's lifecycle, and dropping every screen's
|
|
183
|
-
* document on `leave` would re-read on every back-navigation in the cockpit.
|
|
184
|
-
* The cost here is one detail read and one options read per open — what a modal
|
|
185
|
-
* editing form should pay.
|
|
186
|
-
*
|
|
187
|
-
* Safe at every call site: `Router.navigate` calls `leave()` and then re-points
|
|
188
|
-
* `location` before anything draws, so the reset screen (whose `renderLoading`
|
|
189
|
-
* is `Loading assignment…`) is never rendered; and `submit()`'s trailing
|
|
190
|
-
* `.finally` only re-writes `submitting = false`, which `reset()` has already
|
|
191
|
-
* set, on a context whose `isActive()` is false by then.
|
|
234
|
+
* reused state. Overridden HERE and not on `AsyncScreen`, deliberately: the
|
|
235
|
+
* sibling modals share the caching shape but carry no lifecycle-mutating
|
|
236
|
+
* residual that must die with the popup.
|
|
192
237
|
*/
|
|
193
238
|
leave() {
|
|
194
239
|
this.reset();
|
|
@@ -200,6 +245,10 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
200
245
|
this.action = undefined;
|
|
201
246
|
this.message = '';
|
|
202
247
|
this.submitting = false;
|
|
248
|
+
this.optionLoader?.cancel();
|
|
249
|
+
this.optionLoader = undefined;
|
|
250
|
+
this.sources = {};
|
|
251
|
+
this.context = undefined;
|
|
203
252
|
super.reset();
|
|
204
253
|
}
|
|
205
254
|
present(context) {
|
|
@@ -210,7 +259,9 @@ export class EditAssignmentFormScreen extends AsyncScreen {
|
|
|
210
259
|
meta: String(this.readyValue?.document.data?.attributes?.identifier ??
|
|
211
260
|
context.params?.ticketId ??
|
|
212
261
|
''),
|
|
213
|
-
|
|
262
|
+
// GAIA-360 D12 — Esc closes the popup; Cancel ticket is the lifecycle
|
|
263
|
+
// action. Status must not say "Esc cancel".
|
|
264
|
+
status: ' ↑↓ field · ←→ change · Ctrl+S save · Esc close ',
|
|
214
265
|
// The popup's box supplies its own border and padding, so the body it
|
|
215
266
|
// holds must not also carry the body window's inset — see
|
|
216
267
|
// `presentation()`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaia-ai/addon-gaia-ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
"drupal-jsonapi-params": "^3.0.1"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@gaia-ai/core": "^0.9.
|
|
28
|
+
"@gaia-ai/core": "^0.9.2"
|
|
29
29
|
}
|
|
30
30
|
}
|