@gaia-ai/addon-gaia-ui 0.7.0 → 0.8.0

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.
Files changed (142) hide show
  1. package/dist/src/Agent/launch.d.ts +4 -4
  2. package/dist/src/Agent/launch.js +14 -7
  3. package/dist/src/Component/stats-panel/index.d.ts +13 -2
  4. package/dist/src/Component/stats-panel/index.js +28 -18
  5. package/dist/src/Component/top-tabs/index.d.ts +6 -0
  6. package/dist/src/Component/top-tabs/index.js +15 -6
  7. package/dist/src/Entity/collection.d.ts +98 -0
  8. package/dist/src/Entity/collection.js +46 -0
  9. package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
  10. package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
  11. package/dist/src/Entity/conductor/Data/conductors.d.ts +57 -0
  12. package/dist/src/Entity/conductor/Data/conductors.js +67 -0
  13. package/dist/src/Entity/conductor/Data/liveness.d.ts +2 -1
  14. package/dist/src/Entity/conductor/Data/liveness.js +12 -16
  15. package/dist/src/Entity/conductor/Data/log-tail.d.ts +25 -0
  16. package/dist/src/Entity/conductor/Data/log-tail.js +133 -0
  17. package/dist/src/Entity/conductor/Data/own-conductor.d.ts +30 -0
  18. package/dist/src/Entity/conductor/Data/own-conductor.js +73 -0
  19. package/dist/src/Entity/conductor/Data/process.d.ts +78 -0
  20. package/dist/src/Entity/conductor/Data/process.js +101 -0
  21. package/dist/src/Entity/conductor/Screen/conductors.d.ts +42 -0
  22. package/dist/src/Entity/conductor/Screen/conductors.js +200 -0
  23. package/dist/src/Entity/cycle.d.ts +18 -0
  24. package/dist/src/Entity/cycle.js +30 -0
  25. package/dist/src/Entity/project/Data/project.d.ts +8 -0
  26. package/dist/src/Entity/project/Data/project.js +12 -0
  27. package/dist/src/Entity/project/Data/projects.d.ts +3 -0
  28. package/dist/src/Entity/project/Data/projects.js +3 -0
  29. package/dist/src/Entity/project/Data/with-tickets.d.ts +5 -0
  30. package/dist/src/Entity/project/Data/with-tickets.js +15 -13
  31. package/dist/src/Entity/project/Screen/project-picker.d.ts +36 -0
  32. package/dist/src/Entity/project/Screen/project-picker.js +90 -0
  33. package/dist/src/Entity/project/Screen/projects-screen.d.ts +40 -0
  34. package/dist/src/Entity/project/Screen/projects-screen.js +103 -0
  35. package/dist/src/Entity/run/Component/run-table/index.d.ts +5 -7
  36. package/dist/src/Entity/run/Component/run-table/index.js +9 -18
  37. package/dist/src/Entity/run/Data/query.d.ts +22 -0
  38. package/dist/src/Entity/run/Data/query.js +41 -0
  39. package/dist/src/Entity/run/Data/runs.d.ts +85 -4
  40. package/dist/src/Entity/run/Data/runs.js +143 -27
  41. package/dist/src/Entity/run/Screen/run-detail.d.ts +25 -0
  42. package/dist/src/Entity/run/Screen/run-detail.js +51 -0
  43. package/dist/src/Entity/run/Screen/runs.d.ts +82 -0
  44. package/dist/src/Entity/run/Screen/runs.js +169 -0
  45. package/dist/src/Entity/statistics/Screen/dashboard.d.ts +42 -0
  46. package/dist/src/Entity/statistics/Screen/dashboard.js +177 -0
  47. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +10 -0
  48. package/dist/src/Entity/ticket/Component/create-form/index.js +4 -0
  49. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +3 -2
  50. package/dist/src/Entity/ticket/Component/edit-form/index.js +30 -8
  51. package/dist/src/Entity/ticket/Component/tab-bar/index.js +6 -0
  52. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +1 -1
  53. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +33 -2
  54. package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +4 -2
  55. package/dist/src/Entity/ticket/Component/ticket-table/index.js +8 -12
  56. package/dist/src/Entity/ticket/Data/parents.d.ts +13 -1
  57. package/dist/src/Entity/ticket/Data/parents.js +64 -12
  58. package/dist/src/Entity/ticket/Data/query.d.ts +44 -7
  59. package/dist/src/Entity/ticket/Data/query.js +60 -26
  60. package/dist/src/Entity/ticket/Data/tickets.d.ts +105 -4
  61. package/dist/src/Entity/ticket/Data/tickets.js +164 -12
  62. package/dist/src/Entity/ticket/Data/workflow.d.ts +1 -5
  63. package/dist/src/Entity/ticket/Data/workflow.js +5 -13
  64. package/dist/src/Entity/ticket/Data/write.d.ts +32 -0
  65. package/dist/src/Entity/ticket/Data/write.js +80 -0
  66. package/dist/src/Entity/ticket/Form/create-data.d.ts +1 -11
  67. package/dist/src/Entity/ticket/Form/create-data.js +7 -36
  68. package/dist/src/Entity/ticket/Form/create.d.ts +45 -5
  69. package/dist/src/Entity/ticket/Form/create.js +45 -8
  70. package/dist/src/Entity/ticket/Form/edit-data.d.ts +1 -1
  71. package/dist/src/Entity/ticket/Form/edit-data.js +4 -0
  72. package/dist/src/Entity/ticket/Form/edit.d.ts +79 -12
  73. package/dist/src/Entity/ticket/Form/edit.js +165 -18
  74. package/dist/src/Entity/ticket/Form/keys.d.ts +7 -0
  75. package/dist/src/Entity/ticket/Form/keys.js +7 -0
  76. package/dist/src/Entity/ticket/Form/parent-filter.d.ts +36 -0
  77. package/dist/src/Entity/ticket/Form/parent-filter.js +37 -0
  78. package/dist/src/Entity/ticket/Form/parent-source.d.ts +27 -0
  79. package/dist/src/Entity/ticket/Form/parent-source.js +56 -0
  80. package/dist/src/Entity/ticket/Form/who-filter.d.ts +33 -0
  81. package/dist/src/Entity/ticket/Form/who-filter.js +33 -0
  82. package/dist/src/Entity/ticket/Form/who-source.d.ts +18 -0
  83. package/dist/src/Entity/ticket/Form/who-source.js +38 -0
  84. package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +68 -0
  85. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +336 -0
  86. package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +81 -0
  87. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +200 -0
  88. package/dist/src/Entity/ticket/Screen/parent-filter-form.d.ts +27 -0
  89. package/dist/src/Entity/ticket/Screen/parent-filter-form.js +177 -0
  90. package/dist/src/Entity/ticket/Screen/ticket-agents.d.ts +45 -0
  91. package/dist/src/Entity/ticket/Screen/ticket-agents.js +135 -0
  92. package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +61 -0
  93. package/dist/src/Entity/ticket/Screen/ticket-detail.js +300 -0
  94. package/dist/src/Entity/ticket/Screen/tickets.d.ts +129 -0
  95. package/dist/src/Entity/ticket/Screen/tickets.js +507 -0
  96. package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +27 -0
  97. package/dist/src/Entity/ticket/Screen/who-filter-form.js +154 -0
  98. package/dist/src/Entity/user/Data/users.d.ts +17 -0
  99. package/dist/src/Entity/user/Data/users.js +66 -6
  100. package/dist/src/Form/field.d.ts +3 -1
  101. package/dist/src/Form/field.js +1 -0
  102. package/dist/src/Form/form.d.ts +1 -1
  103. package/dist/src/Form/form.js +61 -9
  104. package/dist/src/Form/keys.d.ts +5 -1
  105. package/dist/src/Form/keys.js +38 -5
  106. package/dist/src/Kernel/tui-kernel.d.ts +0 -10
  107. package/dist/src/Kernel/tui-kernel.js +250 -2384
  108. package/dist/src/Routing/context.d.ts +31 -0
  109. package/dist/src/Routing/context.js +52 -0
  110. package/dist/src/Routing/registry.d.ts +19 -0
  111. package/dist/src/Routing/registry.js +97 -0
  112. package/dist/src/Routing/route.d.ts +39 -0
  113. package/dist/src/Routing/route.js +1 -0
  114. package/dist/src/Routing/router.d.ts +33 -0
  115. package/dist/src/Routing/router.js +102 -0
  116. package/dist/src/Routing/routes.d.ts +17 -0
  117. package/dist/src/Routing/routes.js +57 -0
  118. package/dist/src/Screen/async-screen.d.ts +50 -0
  119. package/dist/src/Screen/async-screen.js +164 -0
  120. package/dist/src/Screen/list-screen.d.ts +158 -0
  121. package/dist/src/Screen/list-screen.js +237 -0
  122. package/dist/src/Screen/screen.d.ts +106 -0
  123. package/dist/src/Screen/screen.js +18 -0
  124. package/dist/src/Tui/controller.d.ts +13 -0
  125. package/dist/src/Tui/controller.js +1794 -0
  126. package/dist/src/Widget/text.js +49 -4
  127. package/dist/src/Widget/types.d.ts +2 -1
  128. package/dist/src/launcher.d.ts +9 -2
  129. package/dist/src/launcher.js +2 -1
  130. package/dist/src/lib/ansi.js +13 -5
  131. package/dist/src/lib/cursor.d.ts +31 -0
  132. package/dist/src/lib/cursor.js +45 -0
  133. package/dist/src/lib/format.d.ts +21 -0
  134. package/dist/src/lib/format.js +59 -0
  135. package/dist/src/lib/table.d.ts +37 -0
  136. package/dist/src/lib/table.js +20 -0
  137. package/dist/src/lib/width.d.ts +33 -1
  138. package/dist/src/lib/width.js +107 -12
  139. package/dist/src/plugin.js +19 -0
  140. package/dist/src/types.d.ts +26 -0
  141. package/dist/src/types.js +0 -4
  142. package/package.json +2 -2
@@ -19,20 +19,15 @@ import { primeSources, } from '../../../Widget/index.js';
19
19
  import { listConductors } from '../../conductor/Data/conductors.js';
20
20
  import { listTerms } from '../../term/Data/terms.js';
21
21
  import { listUsers } from '../../user/Data/users.js';
22
- import { loadParentCandidates } from '../Data/parents.js';
23
22
  import { WORKFLOW_OPTIONS, } from './create.js';
23
+ import { parentOptionSource } from './parent-source.js';
24
24
  // One back-compat re-export, no implementation: `loadEditFormOptions` lives in
25
25
  // `./edit-form-data.js`, because a module named for the create form owning the EDIT
26
26
  // form's loader is the category error AC-6 is about.
27
27
  export { loadEditFormOptions, } from './edit-data.js';
28
- /**
29
- * How long the parent picker waits before re-querying (GAIA-257 D7).
30
- *
31
- * A property of THIS field's source, not of the kernel: the kernel now drives
32
- * whatever sources a form declares and has no interest in how long any of them
33
- * waits.
34
- */
35
- const PARENT_QUERY_DEBOUNCE_MS = 150;
28
+ // The parent label policy moved to `parent-source.ts` with the source (GAIA-284
29
+ // D5); re-exported so the existing import site keeps working unchanged.
30
+ export { parentLabel } from './parent-source.js';
36
31
  /**
37
32
  * Stable-partition the machine's own row to the front: index 0 is what a ring
38
33
  * defaults to. This popup's only ordering rule, and it reads the same for users and
@@ -52,22 +47,6 @@ const toOption = (row) => ({ id: row.id, label: row.label });
52
47
  * type's vocabularies a given widget wants is the widget's business.
53
48
  */
54
49
  const LABEL_VOCABULARY = 'gaia_labels';
55
- /**
56
- * `GAIA-nnn · <title>` — the picker label, composed HERE (GAIA-257 D5).
57
- *
58
- * The kernel filters candidates by a case-insensitive substring of
59
- * `Option.label` and knows nothing of identifiers or titles. AC-5's "matching
60
- * on identifier AND on title" is therefore met by what the label is MADE OF,
61
- * not by a second rule in the kernel: a substring of this string is a match on
62
- * either half. Exported so the popup's own tests can state the rule once.
63
- */
64
- export const parentLabel = (row) => `${row.identifier} · ${row.title}`;
65
- const toParentOption = (row) => ({
66
- id: row.id,
67
- label: parentLabel(row),
68
- identifier: row.identifier,
69
- title: row.title,
70
- });
71
50
  /**
72
51
  * The option sources this popup's fields load through, keyed by FIELD id.
73
52
  *
@@ -127,17 +106,9 @@ export function createFormSources(client, hints = {}) {
127
106
  // "nothing can be offered" and "searched, nothing matched" are different
128
107
  // things and the field says different things about them (D8).
129
108
  if (projectId !== undefined) {
130
- sources.parent = {
131
- bag: 'parents',
132
- debounceMs: PARENT_QUERY_DEBOUNCE_MS,
133
- async load(query) {
134
- const outcome = await loadParentCandidates(client, projectId, query);
135
- return {
136
- options: outcome.rows.map(toParentOption),
137
- error: outcome.error,
138
- };
139
- },
140
- };
109
+ // The shared parent source (GAIA-284 D5): the same read, label policy and
110
+ // error channel the parent-filter picker composes.
111
+ sources.parent = parentOptionSource(client, projectId);
141
112
  }
142
113
  return sources;
143
114
  }
@@ -57,9 +57,27 @@ export { WORKFLOW_OPTIONS } from '../Data/workflow.js';
57
57
  export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
58
58
  id: Id;
59
59
  };
60
- export type CreateFieldId = 'description' | 'parent' | 'assignee' | 'conductor' | 'labels' | 'workflow' | 'submit';
60
+ /**
61
+ * The two action rows' field ids (GAIA-309 D3/D7).
62
+ *
63
+ * Exported because the SCREEN branches on them: the kernel's one `submit`
64
+ * intent cannot say which of two actions was fired, so the screen resolves the
65
+ * active field and compares. `'submit'` keeps its name for the agent row so
66
+ * every pre-GAIA-309 assertion about it stays literally true.
67
+ */
68
+ export declare const AGENT_ACTION_ID = "submit";
69
+ export declare const DIRECT_ACTION_ID = "direct";
70
+ export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
61
71
  /** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
62
- interface CreateValues {
72
+ export interface CreateValues {
73
+ /**
74
+ * The ticket's title. `gaia_ticket.title` is `setRequired(TRUE)` server-side,
75
+ * so a direct create without one is rejected — this was never optional
76
+ * (GAIA-309 D1). Deriving it from the description's first line was considered
77
+ * and rejected: the operator could not correct a bad title without rewriting
78
+ * their own opening sentence.
79
+ */
80
+ title: string;
63
81
  description: string;
64
82
  /** The PICKED parent's uuid, or null. `parentQuery` holds what was typed —
65
83
  * an autocomplete field owns two keys (GAIA-257 D5). */
@@ -78,6 +96,8 @@ export interface ParentRef {
78
96
  }
79
97
  /** The submitted payload (resolved options), handed to the create agent. */
80
98
  export interface CreateFormPayload {
99
+ /** What the operator NAMED the ticket. Required server-side (GAIA-309 D1). */
100
+ title: string;
81
101
  description: string;
82
102
  workflow: Option | null;
83
103
  assignee: Option | null;
@@ -93,8 +113,9 @@ export interface CreateFormPayload {
93
113
  /** The option with `id`, or null — used by both forms' `toObject`. */
94
114
  export declare const optionFor: (list: Option[], id: string | null) => Option | null;
95
115
  /**
96
- * The declaration. No field carries a function and no field is `clearable`: the
97
- * create form's selects cycle over real options only, exactly as before GAIA-233.
116
+ * The declaration. No field carries a function. Exactly ONE select is
117
+ * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
118
+ * options only, exactly as before GAIA-233.
98
119
  */
99
120
  export declare const CREATE_FORM: {
100
121
  fields: FieldDecl[];
@@ -107,7 +128,26 @@ export declare const CREATE_FORM: {
107
128
  /** Field order in the popup (top to bottom). */
108
129
  export declare const FIELDS: FieldDef<CreateFieldId>[];
109
130
  export type CreateFormState = FormState<CreateValues>;
110
- export declare const initialForm: (opts: CreateFormOptions) => CreateFormState;
131
+ /**
132
+ * The values the popup carries from one open to the next (GAIA-314 D2).
133
+ *
134
+ * The popup is ONE screen instance, entered again and again (spec F1/F2), so
135
+ * something has to say which of its values are the ticket being written and
136
+ * which are the operator's standing routing choice. That answer belongs beside
137
+ * the field declaration, not in the screen: the `Pick` ties the list to the
138
+ * value shape, so renaming a field breaks the build instead of silently
139
+ * dropping it out of the memory.
140
+ *
141
+ * `workflow` is NOT here, deliberately. A ticket type is a per-ticket decision,
142
+ * and a silently retained `gaia_bug` on the next feature ticket is a wrong
143
+ * value nobody looks at (D5). The parent contributes BOTH of its keys, because
144
+ * an autocomplete owns two and `apply` never produces one without the other
145
+ * (D4).
146
+ */
147
+ export type CreateRemembered = Pick<CreateValues, 'parent' | 'parentQuery' | 'assignee' | 'conductor' | 'labels'>;
148
+ /** Snapshot the routing values of a form that is about to be replaced. */
149
+ export declare const rememberedValues: (s: CreateFormState) => CreateRemembered;
150
+ export declare const initialForm: (opts: CreateFormOptions, prefill?: Partial<CreateValues>) => CreateFormState;
111
151
  export declare const activeField: (s: CreateFormState) => FieldDef<CreateFieldId>;
112
152
  export declare const moveField: (s: CreateFormState, delta: number) => CreateFormState;
113
153
  /** Append typed text to the description (only meaningful on the text field). */
@@ -1,7 +1,8 @@
1
1
  // The "New ticket" popup form (GAIA-190) as a DECLARATION over the form kernel
2
- // (GAIA-236): five fields plus an action, and one `toObject` that yields the payload
3
- // the create agent gets. The mechanics — navigation, text editing, cycling, the
4
- // multiselect cursor — all live in `Form/`; nothing of them is repeated here.
2
+ // (GAIA-236): seven fields plus two actions, and one `toObject` that yields the
3
+ // payload BOTH submit paths consume (GAIA-309). The mechanics — navigation, text
4
+ // editing, cycling, the multiselect cursor — all live in `Form/`; nothing of them
5
+ // is repeated here.
5
6
  import { cycleValue, declareForm, } from '../../../Form/index.js';
6
7
  import { PARENT_PAGE_SIZE } from '../Data/parents.js';
7
8
  // `cycleValue` is re-exported because GAIA-233's contract cases pin it against
@@ -20,6 +21,16 @@ export { cycleValue };
20
21
  // previously kept its own second copy (GAIA-236 phase 4). Re-exported so this form's
21
22
  // existing import sites are unchanged.
22
23
  export { WORKFLOW_OPTIONS } from '../Data/workflow.js';
24
+ /**
25
+ * The two action rows' field ids (GAIA-309 D3/D7).
26
+ *
27
+ * Exported because the SCREEN branches on them: the kernel's one `submit`
28
+ * intent cannot say which of two actions was fired, so the screen resolves the
29
+ * active field and compares. `'submit'` keeps its name for the agent row so
30
+ * every pre-GAIA-309 assertion about it stays literally true.
31
+ */
32
+ export const AGENT_ACTION_ID = 'submit';
33
+ export const DIRECT_ACTION_ID = 'direct';
23
34
  /** The option with `id`, or null — used by both forms' `toObject`. */
24
35
  export const optionFor = (list, id) => list.find((o) => o.id === id) ?? null;
25
36
  /**
@@ -37,11 +48,16 @@ const parentFor = (list, id) => {
37
48
  : null;
38
49
  };
39
50
  /**
40
- * The declaration. No field carries a function and no field is `clearable`: the
41
- * create form's selects cycle over real options only, exactly as before GAIA-233.
51
+ * The declaration. No field carries a function. Exactly ONE select is
52
+ * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
53
+ * options only, exactly as before GAIA-233.
42
54
  */
43
55
  export const CREATE_FORM = declareForm({
44
56
  fields: (f) => [
57
+ // First, because it is the one field the server REQUIRES (D1). Both submit
58
+ // paths read it: the direct write sends it, the agent adopts it from the
59
+ // temp JSON spec.
60
+ f.text('title', 'Title'),
45
61
  f.text('description', 'Description'),
46
62
  // Between the description and the assignee on purpose: a parent is chosen
47
63
  // WITH what the ticket is about, before who works on it (GAIA-257 D5).
@@ -60,12 +76,22 @@ export const CREATE_FORM = declareForm({
60
76
  pageSize: PARENT_PAGE_SIZE,
61
77
  }),
62
78
  f.select('assignee', 'Assignee', { from: 'users' }),
63
- f.select('conductor', 'Conductor', { from: 'conductors' }),
79
+ // Clearable (D2): a SET conductor means the ticket is claimed at the next
80
+ // poll and a qualification run starts; a cleared one means it waits for a
81
+ // human. The field is clearable precisely so that choice is the operator's
82
+ // and not implicit. The preselection survives — `initial()` fills a select
83
+ // from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
84
+ // `create-data.ts` puts the machine's own conductor there.
85
+ f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
64
86
  f.multi('labels', 'Labels', { from: 'labels' }),
65
87
  f.select('workflow', 'Workflow', { from: 'workflows' }),
66
- f.action('Create ticket'),
88
+ // Agent row FIRST and keeping the id `'submit'` (D7): nothing that exists
89
+ // today changes its meaning or its position.
90
+ f.action('Create with agent', AGENT_ACTION_ID),
91
+ f.action('Create directly', DIRECT_ACTION_ID),
67
92
  ],
68
93
  toObject: (v, o) => ({
94
+ title: v.title,
69
95
  description: v.description,
70
96
  workflow: optionFor(o.workflows, v.workflow),
71
97
  assignee: optionFor(o.users, v.assignee),
@@ -76,13 +102,24 @@ export const CREATE_FORM = declareForm({
76
102
  });
77
103
  /** Field order in the popup (top to bottom). */
78
104
  export const FIELDS = CREATE_FORM.fields;
105
+ /** Snapshot the routing values of a form that is about to be replaced. */
106
+ export const rememberedValues = (s) => ({
107
+ parent: s.parent,
108
+ parentQuery: s.parentQuery,
109
+ assignee: s.assignee,
110
+ conductor: s.conductor,
111
+ // Copied, not referenced: the snapshot outlives the state object it came
112
+ // from, and the reducer builds a NEW Set on every toggle — an alias would
113
+ // make the memory depend on which of the two was mutated last.
114
+ labels: new Set(s.labels),
115
+ });
79
116
  // ---- thin adapters (GAIA-236 AC-3) -----------------------------------------
80
117
  //
81
118
  // The popup itself is driven by `keyToIntent` + `CREATE_FORM.apply` in the kernel.
82
119
  // These per-transition functions stay because they ARE this module's tested
83
120
  // contract: keeping them is what lets `tests/Service/create-form.test.ts` prove the
84
121
  // re-cut changed no behaviour instead of being rewritten to match it.
85
- export const initialForm = (opts) => CREATE_FORM.initial(opts);
122
+ export const initialForm = (opts, prefill = {}) => CREATE_FORM.initial(opts, prefill);
86
123
  export const activeField = (s) => CREATE_FORM.activeField(s);
87
124
  export const moveField = (s, delta) => CREATE_FORM.move(s, delta);
88
125
  /** Append typed text to the description (only meaningful on the text field). */
@@ -1,5 +1,5 @@
1
1
  import type { OptionClients } from '../../transport.js';
2
- import type { EditFormOptions } from './edit.js';
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 {
@@ -8,6 +8,7 @@
8
8
  // once, here, beside the create adapter it differs from.
9
9
  import { listConductorsPreferringWrite, } from '../../conductor/Data/conductors.js';
10
10
  import { listUsersPreferringWrite } from '../../user/Data/users.js';
11
+ import { STATE_TARGET_OPTIONS } from './edit.js';
11
12
  /**
12
13
  * uid 0 is Drupal's Anonymous and never a valid human assignee. A row whose uid never
13
14
  * loaded is `undefined` — NOT Anonymous — and stays.
@@ -34,5 +35,8 @@ export async function loadEditFormOptions(clients, hints = {}) {
34
35
  return {
35
36
  users: excludeAnonymous(users).map((u) => ({ id: u.id, label: u.label })),
36
37
  conductors: inProject(conductors, hints.projectId).map(withStatusSuffix),
38
+ // A constant, not a read — the popup's State row needs its positions
39
+ // whether or not the two collection reads succeeded (D8).
40
+ stateTargets: STATE_TARGET_OPTIONS,
37
41
  };
38
42
  }
@@ -1,11 +1,44 @@
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' | 'submit';
5
- /** The two option lists the popup offers (loaded by `loadEditFormOptions`). */
4
+ export type EditFieldId = 'assignee' | 'conductor' | 'estimate' | 'spent' | 'stateTarget' | 'submit';
5
+ /**
6
+ * GAIA-313 — the ONE lifecycle move this popup offers, as a named constant.
7
+ *
8
+ * Not derived, and deliberately so: `state_flow` is a `gaia_dashboard` computed
9
+ * field rendering the happy path (`qualification … done`) and never contains
10
+ * `cancelled`, so no served fact carries the cancel edge. The precedent is
11
+ * `STATE_FILTER_VALUES` in `Data/query.ts`, hardcoded for the same reason.
12
+ *
13
+ * One constant serves every ticket workflow because in ALL of them
14
+ * (`gaia_feature`, `gaia_bug`, `gaia_chore`, `gaia_deployment`,
15
+ * `gaia_verification`) `cancel.from` is exactly the set of non-terminal states —
16
+ * so "non-terminal" and "the workflow permits cancelling" are the same
17
+ * predicate, and no workflow id is needed to tell them apart.
18
+ */
19
+ export declare const CANCELLED_STATE = "cancelled";
20
+ /** The position that writes nothing. Not `null`: the shared `select` widget
21
+ * renders a clearable null as `— none —`, which is not what "keep" means. */
22
+ export declare const KEEP_STATE = "keep";
23
+ /** The declared field's id. Never `cancel` — `intent.kind === 'cancel'` already
24
+ * means "close the popup", and two meanings for one word is a defect waiting. */
25
+ export declare const STATE_FIELD_ID = "stateTarget";
26
+ /** The two positions of the State row, in ring order. */
27
+ export declare const STATE_TARGET_OPTIONS: Option[];
28
+ /**
29
+ * The states no transition leaves, in every ticket workflow.
30
+ *
31
+ * The server's own definition (`TypeWorkflows::isTerminal` — "a state no
32
+ * transition leaves") evaluates to exactly this pair for all five workflows.
33
+ */
34
+ export declare const TERMINAL_TICKET_STATES: ReadonlySet<string>;
35
+ /** The option lists the popup offers (loaded by `loadEditFormOptions`). */
6
36
  export interface EditFormOptions {
7
37
  users: Option[];
8
38
  conductors: Option[];
39
+ /** A CONSTANT list, not a read — it rides in the bag because `from` names a
40
+ * bag key and that is the only channel a declared select has (D8). */
41
+ stateTargets: Option[];
9
42
  }
10
43
  /** What the form HOLDS. `ticketId` is a NON-field value: the kernel carries it
11
44
  * verbatim so submit needs no ambient state, and never transitions it. */
@@ -13,9 +46,17 @@ interface EditValues {
13
46
  ticketId: string;
14
47
  assignee: string | null;
15
48
  conductor: string | null;
49
+ /** The human duration as TYPED — seconds only exist at `toObject` (D5). */
50
+ estimate: string;
51
+ spent: string;
52
+ /** `KEEP_STATE` or `CANCELLED_STATE`. Nullable because `SelectKey` requires
53
+ * it; the control cannot produce a null (non-clearable ring, inert Del). */
54
+ stateTarget: string | null;
16
55
  }
17
- /** The relationship payload of the PATCH (GAIA-233 Decision 2's `ResourceWriteBody`). */
56
+ /** The payload of the PATCH (GAIA-233 Decision 2's `ResourceWriteBody`, which
57
+ * already declares `attributes?`, so the writer needs no change). */
18
58
  export interface EditFormPayload {
59
+ attributes: Record<string, number | string | null>;
19
60
  relationships: {
20
61
  assignee_user_id: {
21
62
  data: {
@@ -31,14 +72,7 @@ export interface EditFormPayload {
31
72
  };
32
73
  };
33
74
  }
34
- /**
35
- * The declaration. BOTH selects are `clearable`: AC-4 requires REMOVING an
36
- * assignment, not only changing it, so `null` is a real position in each cycle.
37
- *
38
- * `toObject` always emits BOTH relationships, even when neither changed: "only send
39
- * what changed" is a rule the operator cannot read off the screen, and an explicit
40
- * no-op PATCH is what the popup visibly promises.
41
- */
75
+ /** The ordinary popup: a non-terminal ticket can be cancelled from it. */
42
76
  export declare const EDIT_FORM: {
43
77
  fields: import("../../../Form/field.js").FieldDecl[];
44
78
  activeField: (s: FormState<EditValues>) => import("../../../Form/field.js").FieldDecl;
@@ -47,9 +81,42 @@ export declare const EDIT_FORM: {
47
81
  apply(s: FormState<EditValues>, options: EditFormOptions, intent: import("../../../Form/form.js").Intent): FormState<EditValues>;
48
82
  toObject: (s: FormState<EditValues>, options: EditFormOptions) => EditFormPayload;
49
83
  };
50
- /** Field order in the popup. */
84
+ /** A terminal ticket's popup: assignment and time only. */
85
+ export declare const EDIT_FORM_TERMINAL: {
86
+ fields: import("../../../Form/field.js").FieldDecl[];
87
+ activeField: (s: FormState<EditValues>) => import("../../../Form/field.js").FieldDecl;
88
+ move: (s: FormState<EditValues>, d: number) => FormState<EditValues>;
89
+ initial(options: EditFormOptions, prefill?: Partial<EditValues>): FormState<EditValues>;
90
+ apply(s: FormState<EditValues>, options: EditFormOptions, intent: import("../../../Form/form.js").Intent): FormState<EditValues>;
91
+ toObject: (s: FormState<EditValues>, options: EditFormOptions) => EditFormPayload;
92
+ };
93
+ export type EditFormDeclaration = typeof EDIT_FORM;
94
+ /** The ticket's state as the popup sees it; `''` when the read carried none. */
95
+ export declare const ticketState: (document: TicketDetailDocument) => string;
96
+ /**
97
+ * Which declaration a ticket gets (AC-4).
98
+ *
99
+ * An unknown state fails OPEN (D7): `loadTicketDetail` requests no fieldset, so
100
+ * a missing `state` means genuinely unknown rather than not-requested. Hiding
101
+ * the row on a thin read would cost a legitimate affordance; offering it costs
102
+ * at worst the server's 422, which the popup already renders in full.
103
+ */
104
+ export declare const editFormFor: (document: TicketDetailDocument) => EditFormDeclaration;
105
+ /** Field order in the popup, per declaration. */
51
106
  export declare const EDIT_FIELDS: FieldDef<EditFieldId>[];
107
+ export declare const EDIT_FIELDS_TERMINAL: FieldDef<EditFieldId>[];
52
108
  export type EditFormState = FormState<EditValues>;
109
+ /**
110
+ * No lists to OFFER — the two server-read bags are empty; the constant one is
111
+ * not, because it is not read from anywhere.
112
+ *
113
+ * The prefill below names every value, and `clear`/`toObject` read no option
114
+ * list, so the adapters that have no options to pass hand over this bag.
115
+ * Exported so the screen's options-failure path can reuse it: a failed
116
+ * user/conductor read must not silently take the State row's positions away
117
+ * (D8).
118
+ */
119
+ export declare const EMPTY_EDIT_FORM_OPTIONS: EditFormOptions;
53
120
  /** Prefill from the ticket's CURRENT values (AC-1) — never from `opts[0]`. The
54
121
  * kernel applies a prefill with `Object.hasOwn`, so an unassigned ticket's
55
122
  * explicit `null` STICKS instead of falling back to the first option. */
@@ -1,53 +1,200 @@
1
- // The "Edit assignment" popup (GAIA-233) as a DECLARATION over the form kernel
2
- // (GAIA-236): two `clearable` selects plus an action, and one `toObject` that yields
3
- // the JSON:API relationship payload of the PATCH. Its own field list, its own prefill
4
- // source (the ticket's relationships, never `opts[0]`) and its own output — the
5
- // mechanics are the kernel's. No I/O here.
1
+ // The "Edit ticket" popup (GAIA-233, widened by GAIA-308 and GAIA-313) as a
2
+ // DECLARATION over the form kernel (GAIA-236): two `clearable` selects, two text
3
+ // fields carrying the accounted durations, one non-clearable State select, plus
4
+ // an action; and one `toObject` that yields the JSON:API payload of the PATCH.
5
+ // Its own field list, its own prefill source (the ticket's own relationships and
6
+ // attributes, never `opts[0]`) and its own output — the mechanics are the
7
+ // kernel's. No I/O here.
8
+ //
9
+ // GAIA-313 makes it TWO declarations from one body: a terminal ticket's popup
10
+ // simply does not carry the State row (D3), and `editFormFor` picks between them.
6
11
  import { declareForm } from '../../../Form/index.js';
12
+ import { formatDuration, parseDuration } from '../../../lib/format.js';
13
+ /**
14
+ * GAIA-313 — the ONE lifecycle move this popup offers, as a named constant.
15
+ *
16
+ * Not derived, and deliberately so: `state_flow` is a `gaia_dashboard` computed
17
+ * field rendering the happy path (`qualification … done`) and never contains
18
+ * `cancelled`, so no served fact carries the cancel edge. The precedent is
19
+ * `STATE_FILTER_VALUES` in `Data/query.ts`, hardcoded for the same reason.
20
+ *
21
+ * One constant serves every ticket workflow because in ALL of them
22
+ * (`gaia_feature`, `gaia_bug`, `gaia_chore`, `gaia_deployment`,
23
+ * `gaia_verification`) `cancel.from` is exactly the set of non-terminal states —
24
+ * so "non-terminal" and "the workflow permits cancelling" are the same
25
+ * predicate, and no workflow id is needed to tell them apart.
26
+ */
27
+ export const CANCELLED_STATE = 'cancelled';
28
+ /** The position that writes nothing. Not `null`: the shared `select` widget
29
+ * renders a clearable null as `— none —`, which is not what "keep" means. */
30
+ export const KEEP_STATE = 'keep';
31
+ /** The declared field's id. Never `cancel` — `intent.kind === 'cancel'` already
32
+ * means "close the popup", and two meanings for one word is a defect waiting. */
33
+ export const STATE_FIELD_ID = 'stateTarget';
34
+ /** The two positions of the State row, in ring order. */
35
+ export const STATE_TARGET_OPTIONS = [
36
+ { id: KEEP_STATE, label: 'keep current state' },
37
+ { id: CANCELLED_STATE, label: 'cancelled — abandon this ticket' },
38
+ ];
39
+ /**
40
+ * The states no transition leaves, in every ticket workflow.
41
+ *
42
+ * The server's own definition (`TypeWorkflows::isTerminal` — "a state no
43
+ * transition leaves") evaluates to exactly this pair for all five workflows.
44
+ */
45
+ export const TERMINAL_TICKET_STATES = new Set([
46
+ 'done',
47
+ CANCELLED_STATE,
48
+ ]);
7
49
  /** A JSON:API to-one linkage, or an explicit `null` for "remove it". */
8
50
  const linkage = (type, id) => ({
9
51
  data: id === null ? null : { type, id },
10
52
  });
11
53
  /**
12
- * The declaration. BOTH selects are `clearable`: AC-4 requires REMOVING an
13
- * assignment, not only changing it, so `null` is a real position in each cycle.
54
+ * The two time attributes of the PATCH, in seconds.
55
+ *
56
+ * Three outcomes per field, and the third is the reason this is not a one-liner:
57
+ * parsed value → the number;
58
+ * empty input → `null`, an explicit REMOVE (the selects are clearable for the
59
+ * same reason — AC-4 requires removing a value, not only
60
+ * changing it);
61
+ * unparseable → the key is OMITTED. Writing 0 for "soon" would silently
62
+ * destroy a real estimate, and the popup has no validation
63
+ * channel of its own; leaving the key out means the server
64
+ * keeps what it had and the operator sees their typo survive
65
+ * the save.
66
+ *
67
+ * This is why `toObject` cannot emit both keys unconditionally the way the two
68
+ * relationships do.
69
+ */
70
+ const timeAttributes = (v) => {
71
+ const out = {};
72
+ for (const [key, field] of [
73
+ ['time_estimate', 'estimate'],
74
+ ['time_spent', 'spent'],
75
+ ]) {
76
+ const raw = v[field];
77
+ const seconds = parseDuration(raw);
78
+ if (seconds === null)
79
+ continue;
80
+ out[key] = raw.trim() === '' ? null : seconds;
81
+ }
82
+ return out;
83
+ };
84
+ /**
85
+ * The ONE lifecycle attribute this popup may write, and only on the position
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).
96
+ *
97
+ * 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.
14
99
  *
15
100
  * `toObject` always emits BOTH relationships, even when neither changed: "only send
16
101
  * what changed" is a rule the operator cannot read off the screen, and an explicit
17
- * no-op PATCH is what the popup visibly promises.
102
+ * no-op PATCH is what the popup visibly promises. The two time attributes are the
103
+ * documented exception — see `timeAttributes`.
104
+ *
105
+ * The kernel builds its field list once, at module load, and every consumer —
106
+ * the renderer's `▸` marker, `keyToIntent`, `apply` — indexes that array by
107
+ * `state.active`. So a row cannot be hidden at render time without shifting
108
+ * every index below it; absence has to be a property of WHICH DECLARATION is in
109
+ * force. The State row sits last, above the action, so indices 0–3 never move.
18
110
  */
19
- export const EDIT_FORM = declareForm({
111
+ const declareEditForm = (cancellable) => declareForm({
20
112
  fields: (f) => [
21
113
  f.select('assignee', 'Assignee', { from: 'users', clearable: true }),
22
- f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
23
- f.action('Save assignment'),
114
+ f.select('conductor', 'Conductor', {
115
+ from: 'conductors',
116
+ clearable: true,
117
+ }),
118
+ f.text('estimate', 'Estimate'),
119
+ f.text('spent', 'Spent'),
120
+ // NOT clearable: that is what makes "exactly two positions" provable
121
+ // rather than observed — `keyToIntent` returns null for Del/Backspace on
122
+ // a non-clearable select, and `cycleValue` never puts a null in the ring.
123
+ ...(cancellable
124
+ ? [f.select('stateTarget', 'State', { from: 'stateTargets' })]
125
+ : []),
126
+ f.action('Save'),
24
127
  ],
25
128
  toObject: (v) => ({
129
+ attributes: { ...timeAttributes(v), ...stateAttribute(v) },
26
130
  relationships: {
27
131
  assignee_user_id: linkage('user--user', v.assignee),
28
132
  conductor_id: linkage('gaia_conductor--gaia_conductor', v.conductor),
29
133
  },
30
134
  }),
31
135
  });
32
- /** Field order in the popup. */
136
+ /** The ordinary popup: a non-terminal ticket can be cancelled from it. */
137
+ export const EDIT_FORM = declareEditForm(true);
138
+ /** A terminal ticket's popup: assignment and time only. */
139
+ export const EDIT_FORM_TERMINAL = declareEditForm(false);
140
+ /** The ticket's state as the popup sees it; `''` when the read carried none. */
141
+ export const ticketState = (document) => {
142
+ const value = document.data?.attributes?.state;
143
+ return typeof value === 'string' ? value : '';
144
+ };
145
+ /**
146
+ * Which declaration a ticket gets (AC-4).
147
+ *
148
+ * An unknown state fails OPEN (D7): `loadTicketDetail` requests no fieldset, so
149
+ * a missing `state` means genuinely unknown rather than not-requested. Hiding
150
+ * the row on a thin read would cost a legitimate affordance; offering it costs
151
+ * at worst the server's 422, which the popup already renders in full.
152
+ */
153
+ export const editFormFor = (document) => TERMINAL_TICKET_STATES.has(ticketState(document))
154
+ ? EDIT_FORM_TERMINAL
155
+ : EDIT_FORM;
156
+ /** Field order in the popup, per declaration. */
33
157
  export const EDIT_FIELDS = EDIT_FORM.fields;
34
- // The prefill below names every value, and `clear`/`toObject` read no option list,
35
- // so the adapters that have no options to pass hand over an empty bag.
36
- const NO_OPTIONS = { users: [], conductors: [] };
158
+ export const EDIT_FIELDS_TERMINAL = EDIT_FORM_TERMINAL.fields;
159
+ /**
160
+ * No lists to OFFER — the two server-read bags are empty; the constant one is
161
+ * not, because it is not read from anywhere.
162
+ *
163
+ * The prefill below names every value, and `clear`/`toObject` read no option
164
+ * list, so the adapters that have no options to pass hand over this bag.
165
+ * Exported so the screen's options-failure path can reuse it: a failed
166
+ * user/conductor read must not silently take the State row's positions away
167
+ * (D8).
168
+ */
169
+ export const EMPTY_EDIT_FORM_OPTIONS = {
170
+ users: [],
171
+ conductors: [],
172
+ stateTargets: STATE_TARGET_OPTIONS,
173
+ };
37
174
  const relId = (document, name) => {
38
175
  const data = document.data?.relationships?.[name]?.data;
39
176
  if (!data)
40
177
  return null;
41
178
  return Array.isArray(data) ? (data[0]?.id ?? null) : data.id;
42
179
  };
180
+ const timeValue = (document, name) => {
181
+ const value = document.data?.attributes?.[name];
182
+ // An unset field prefills EMPTY, never `0m`: the popup must not turn "nobody
183
+ // estimated this" into a typed zero the operator then saves by accident.
184
+ return typeof value === 'number' ? formatDuration(value) : '';
185
+ };
43
186
  /** Prefill from the ticket's CURRENT values (AC-1) — never from `opts[0]`. The
44
187
  * kernel applies a prefill with `Object.hasOwn`, so an unassigned ticket's
45
188
  * explicit `null` STICKS instead of falling back to the first option. */
46
189
  export function initialEditForm(document) {
47
- return EDIT_FORM.initial(NO_OPTIONS, {
190
+ return EDIT_FORM.initial(EMPTY_EDIT_FORM_OPTIONS, {
48
191
  ticketId: document.data?.id ?? '',
49
192
  assignee: relId(document, 'assignee_user_id'),
50
193
  conductor: relId(document, 'conductor_id'),
194
+ estimate: timeValue(document, 'time_estimate'),
195
+ spent: timeValue(document, 'time_spent'),
196
+ // Always KEEP, on both declarations: the popup never opens armed to cancel.
197
+ stateTarget: KEEP_STATE,
51
198
  });
52
199
  }
53
200
  // ---- thin adapters over the kernel (the popup itself runs on keyToIntent) ----
@@ -57,5 +204,5 @@ export const moveEditField = (s, delta) => EDIT_FORM.move(s, delta);
57
204
  export const cycleEdit = (s, opts, dir) => EDIT_FORM.apply(s, opts, { kind: 'cycle', dir });
58
205
  /** Clear the active select outright (the Del/Backspace shortcut). Cycling to `null`
59
206
  * already works; a long conductor list just makes it tedious. */
60
- export const clearEditValue = (s) => EDIT_FORM.apply(s, NO_OPTIONS, { kind: 'clear' });
61
- export const serializeEdit = (s) => EDIT_FORM.toObject(s, NO_OPTIONS);
207
+ export const clearEditValue = (s) => EDIT_FORM.apply(s, EMPTY_EDIT_FORM_OPTIONS, { kind: 'clear' });
208
+ export const serializeEdit = (s) => EDIT_FORM.toObject(s, EMPTY_EDIT_FORM_OPTIONS);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Ticket-form adapter for translating terminal input into form intents.
3
+ *
4
+ * The generic form kernel deliberately has no knowledge of terminal screens;
5
+ * consumers outside the kernel reach it through their entity form adapter.
6
+ */
7
+ export { keyToIntent } from '../../../Form/index.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Ticket-form adapter for translating terminal input into form intents.
3
+ *
4
+ * The generic form kernel deliberately has no knowledge of terminal screens;
5
+ * consumers outside the kernel reach it through their entity form adapter.
6
+ */
7
+ export { keyToIntent } from '../../../Form/index.js';