@gaia-ai/addon-gaia-ui 0.18.0 → 0.20.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 (41) hide show
  1. package/dist/src/Entity/conductor/Data/conductors.d.ts +10 -0
  2. package/dist/src/Entity/conductor/Data/conductors.js +23 -1
  3. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +2 -2
  4. package/dist/src/Entity/ticket/Component/create-form/index.js +4 -4
  5. package/dist/src/Entity/ticket/Component/edit-form/index.js +9 -2
  6. package/dist/src/Entity/ticket/Component/sections/index.js +5 -3
  7. package/dist/src/Entity/ticket/Component/ticket-content/index.d.ts +17 -0
  8. package/dist/src/Entity/ticket/Component/ticket-content/index.js +60 -2
  9. package/dist/src/Entity/ticket/Component/ticket-content/model.d.ts +1 -1
  10. package/dist/src/Entity/ticket/Component/ticket-content/model.js +16 -15
  11. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -2
  12. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +61 -6
  13. package/dist/src/Entity/ticket/Data/addons.d.ts +53 -0
  14. package/dist/src/Entity/ticket/Data/addons.js +35 -0
  15. package/dist/src/Entity/ticket/Data/detail.js +2 -1
  16. package/dist/src/Entity/ticket/Data/store.js +2 -1
  17. package/dist/src/Entity/ticket/Data/write.js +8 -0
  18. package/dist/src/Entity/ticket/Form/addon-action.d.ts +37 -0
  19. package/dist/src/Entity/ticket/Form/addon-action.js +80 -0
  20. package/dist/src/Entity/ticket/Form/create-data.d.ts +12 -1
  21. package/dist/src/Entity/ticket/Form/create-data.js +56 -17
  22. package/dist/src/Entity/ticket/Form/create.d.ts +49 -3
  23. package/dist/src/Entity/ticket/Form/create.js +82 -45
  24. package/dist/src/Entity/ticket/Form/edit-data.d.ts +2 -0
  25. package/dist/src/Entity/ticket/Form/edit-data.js +14 -1
  26. package/dist/src/Entity/ticket/Form/edit.d.ts +14 -1
  27. package/dist/src/Entity/ticket/Form/edit.js +31 -0
  28. package/dist/src/Entity/ticket/Form/reviewer-options.d.ts +10 -0
  29. package/dist/src/Entity/ticket/Form/reviewer-options.js +48 -0
  30. package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +24 -0
  31. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +150 -18
  32. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +30 -1
  33. package/dist/src/Entity/ticket/Screen/ticket-addons.d.ts +94 -0
  34. package/dist/src/Entity/ticket/Screen/ticket-addons.js +492 -0
  35. package/dist/src/Entity/ticket/Screen/ticket-content-screen.d.ts +14 -0
  36. package/dist/src/Entity/ticket/Screen/ticket-content-screen.js +69 -7
  37. package/dist/src/Entity/ticket/Screen/ticket-detail.js +6 -5
  38. package/dist/src/Routing/routes.js +3 -0
  39. package/dist/src/Widget/multiselect.js +7 -3
  40. package/dist/src/types.d.ts +5 -0
  41. package/package.json +3 -2
@@ -0,0 +1,37 @@
1
+ import type { MessageInput as AddonInput } from '@gaia-ai/message';
2
+ import type { FieldDecl, Option } from '../../../Form/field.js';
3
+ /** A form field as the TUI renders it: a text line or a select. */
4
+ export interface FieldSpec {
5
+ id: string;
6
+ label: string;
7
+ kind: 'text' | 'select';
8
+ options?: Option[];
9
+ }
10
+ /**
11
+ * The rendering table on a terminal: text lines for strings and numbers, a
12
+ * select for an enum, and yes/no for a boolean (there is no toggle widget).
13
+ * A required field without a default is marked `*`.
14
+ */
15
+ export declare function formFields(input: AddonInput): FieldSpec[];
16
+ /**
17
+ * Typed payload from the form's strings, or the per-field errors. The server
18
+ * validates again; this only spares a round trip for the obvious mistakes.
19
+ */
20
+ export declare function payloadFrom(input: AddonInput, values: Record<string, string | null | undefined>): {
21
+ payload: Record<string, unknown>;
22
+ } | {
23
+ errors: Record<string, string>;
24
+ };
25
+ export type AddonFormValues = Record<string, string | null>;
26
+ type AddonFormOptions = Record<string, Option[]>;
27
+ /** The kernel form for an input: its fields, then the Send row. */
28
+ export declare function addonForm(input: AddonInput): {
29
+ fields: FieldDecl[];
30
+ activeField: (s: import("../../../Form/form.js").FormState<AddonFormValues>) => FieldDecl;
31
+ move: (s: import("../../../Form/form.js").FormState<AddonFormValues>, d: number) => import("../../../Form/form.js").FormState<AddonFormValues>;
32
+ initial(options: AddonFormOptions, prefill?: Partial<AddonFormValues>): import("../../../Form/form.js").FormState<AddonFormValues>;
33
+ apply(s: import("../../../Form/form.js").FormState<AddonFormValues>, options: AddonFormOptions, intent: import("../../../Form/form.js").Intent): import("../../../Form/form.js").FormState<AddonFormValues>;
34
+ toObject: (s: import("../../../Form/form.js").FormState<AddonFormValues>, options: AddonFormOptions) => AddonFormValues;
35
+ };
36
+ export type AddonForm = ReturnType<typeof addonForm>;
37
+ export {};
@@ -0,0 +1,80 @@
1
+ // The form of an addon action with input, over the form kernel: the input
2
+ // subset of the addon schema mapped onto text lines and selects, and the
3
+ // typed payload read back from the strings the form holds.
4
+ import { declareForm } from '../../../Form/form.js';
5
+ /**
6
+ * The rendering table on a terminal: text lines for strings and numbers, a
7
+ * select for an enum, and yes/no for a boolean (there is no toggle widget).
8
+ * A required field without a default is marked `*`.
9
+ */
10
+ export function formFields(input) {
11
+ const required = new Set(input.required ?? []);
12
+ return Object.entries(input.properties).map(([id, field]) => {
13
+ const label = `${field.title ?? id}${required.has(id) && field.default === undefined ? ' *' : ''}`;
14
+ if (field.enum)
15
+ return {
16
+ id,
17
+ label,
18
+ kind: 'select',
19
+ options: field.enum.map((v) => ({ id: String(v), label: String(v) })),
20
+ };
21
+ if (field.type === 'boolean')
22
+ return {
23
+ id,
24
+ label,
25
+ kind: 'select',
26
+ options: [
27
+ { id: 'yes', label: 'yes' },
28
+ { id: 'no', label: 'no' },
29
+ ],
30
+ };
31
+ return { id, label, kind: 'text' };
32
+ });
33
+ }
34
+ /**
35
+ * Typed payload from the form's strings, or the per-field errors. The server
36
+ * validates again; this only spares a round trip for the obvious mistakes.
37
+ */
38
+ export function payloadFrom(input, values) {
39
+ const required = new Set(input.required ?? []);
40
+ const payload = {};
41
+ const errors = {};
42
+ for (const [id, field] of Object.entries(input.properties)) {
43
+ const raw = (values[id] ?? '').trim();
44
+ if (raw === '') {
45
+ if (required.has(id) && field.default === undefined)
46
+ errors[id] = 'is required';
47
+ continue;
48
+ }
49
+ if (field.type === 'boolean')
50
+ payload[id] = raw === 'yes';
51
+ else if (field.type === 'integer') {
52
+ if (!/^-?\d+$/.test(raw))
53
+ errors[id] = 'must be a whole number';
54
+ else
55
+ payload[id] = Number(raw);
56
+ }
57
+ else if (field.type === 'number') {
58
+ const n = Number(raw);
59
+ if (!Number.isFinite(n))
60
+ errors[id] = 'must be a number';
61
+ else
62
+ payload[id] = n;
63
+ }
64
+ else if (field.format === 'uri' && !URL.canParse(raw))
65
+ errors[id] = 'must be a URL';
66
+ else
67
+ payload[id] = raw;
68
+ }
69
+ return Object.keys(errors).length > 0 ? { errors } : { payload };
70
+ }
71
+ /** The kernel form for an input: its fields, then the Send row. */
72
+ export function addonForm(input) {
73
+ const fields = formFields(input).map((f) => f.kind === 'select'
74
+ ? { id: f.id, label: f.label, kind: 'select', from: f.id }
75
+ : { id: f.id, label: f.label, kind: 'text' });
76
+ return declareForm({
77
+ fields: (f) => [...fields, f.action('Send')],
78
+ toObject: (values) => values,
79
+ });
80
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DropshClient } from '../../../types.js';
2
- import { type OptionSources } from '../../../Widget/index.js';
2
+ import { type OptionSource, type OptionSources } from '../../../Widget/index.js';
3
3
  import { type CreateFormOptions } from './create.js';
4
4
  export { type EditFormClients, type EditFormHints, loadEditFormOptions, } from './edit-data.js';
5
5
  export { parentLabel } from './parent-source.js';
@@ -20,6 +20,8 @@ export interface CreateFormHints {
20
20
  projectId?: string | undefined;
21
21
  /** The launcher's user uuid; hoists everything the machine's user owns (GAIA-460). */
22
22
  ownerUserId?: string | undefined;
23
+ /** Workspace UUID for the Reviewers bag (membership ∩ users). */
24
+ workspaceId?: string | undefined;
23
25
  }
24
26
  /**
25
27
  * Stable-partition the machine's own row to the front: index 0 is what a ring
@@ -27,6 +29,15 @@ export interface CreateFormHints {
27
29
  * conductors — only what counts as "own" differs, which the caller passes in.
28
30
  */
29
31
  export declare const ownFirst: <T>(rows: T[], isOwn: (row: T) => boolean) => T[];
32
+ /**
33
+ * The `conductors` option source for one (possibly absent) project.
34
+ *
35
+ * Extracted from `createFormSources` (GAIA-664 AC-8) so the screen can call it
36
+ * a SECOND time, scoped to whatever project the operator just picked in the
37
+ * new Project field — the only way the Conductor bag can re-scope without a
38
+ * second, hand-written read living beside this one.
39
+ */
40
+ export declare function conductorOptionSource(client: DropshClient, hints: Pick<CreateFormHints, 'projectId' | 'ownerUserId' | 'ownConductorMachineId'>): OptionSource;
30
41
  /**
31
42
  * The option sources this popup's fields load through, keyed by FIELD id.
32
43
  *
@@ -16,11 +16,13 @@
16
16
  // primes them, re-queries them and merges them for every field alike. The popup's
17
17
  // policy is unchanged and still lives here; only the plumbing left.
18
18
  import { primeSources, } from '../../../Widget/index.js';
19
- import { listConductors } from '../../conductor/Data/conductors.js';
19
+ import { listConductorsForProject } from '../../conductor/Data/conductors.js';
20
20
  import { listTerms } from '../../term/Data/terms.js';
21
21
  import { listUsers } from '../../user/Data/users.js';
22
22
  import { WORKFLOW_OPTIONS, } from './create.js';
23
23
  import { parentOptionSource } from './parent-source.js';
24
+ import { projectOptionSource } from './project-source.js';
25
+ import { loadReviewerOptions } from './reviewer-options.js';
24
26
  // One back-compat re-export, no implementation: `loadEditFormOptions` lives in
25
27
  // `./edit-form-data.js`, because a module named for the create form owning the EDIT
26
28
  // form's loader is the category error AC-6 is about.
@@ -47,6 +49,43 @@ const toOption = (row) => ({ id: row.id, label: row.label });
47
49
  * type's vocabularies a given widget wants is the widget's business.
48
50
  */
49
51
  const LABEL_VOCABULARY = 'gaia_labels';
52
+ /**
53
+ * The `conductors` option source for one (possibly absent) project.
54
+ *
55
+ * Extracted from `createFormSources` (GAIA-664 AC-8) so the screen can call it
56
+ * a SECOND time, scoped to whatever project the operator just picked in the
57
+ * new Project field — the only way the Conductor bag can re-scope without a
58
+ * second, hand-written read living beside this one.
59
+ */
60
+ export function conductorOptionSource(client, hints) {
61
+ const { projectId, ownerUserId, ownConductorMachineId } = hints;
62
+ return {
63
+ bag: 'conductors',
64
+ async load() {
65
+ // GAIA-664 review F4: a new ticket's project scopes the conductor
66
+ // candidates the same way the Web dialog's selection handler does —
67
+ // including offering NONE without a resolved project, since the
68
+ // server now rejects a conductor assignment on a projectless ticket
69
+ // the same way it rejects a cross-project one.
70
+ if (projectId === undefined) {
71
+ return { options: [] };
72
+ }
73
+ const outcome = await listConductorsForProject(client, projectId);
74
+ const assignable = outcome.rows.filter((c) => c.active);
75
+ // Two stable partitions, strongest LAST (GAIA-460 D8): everything I own
76
+ // rises above everyone else's, then this checkout's record rises above
77
+ // the rest of mine. Applied in the other order the weaker rule would
78
+ // scatter what the stronger one just gathered.
79
+ const byOwner = ownerUserId
80
+ ? ownFirst(assignable, (c) => c.ownerUserId === ownerUserId)
81
+ : assignable;
82
+ const rows = ownConductorMachineId
83
+ ? ownFirst(byOwner, (c) => c.machineId === ownConductorMachineId)
84
+ : byOwner;
85
+ return { options: rows.map(toOption), error: outcome.error };
86
+ },
87
+ };
88
+ }
50
89
  /**
51
90
  * The option sources this popup's fields load through, keyed by FIELD id.
52
91
  *
@@ -71,7 +110,7 @@ const LABEL_VOCABULARY = 'gaia_labels';
71
110
  * can lag, but it can never show a row contradicting the text on screen.
72
111
  */
73
112
  export function createFormSources(client, hints = {}) {
74
- const { currentUser, ownConductorMachineId, ownerUserId, projectId } = hints;
113
+ const { currentUser, ownConductorMachineId, ownerUserId, projectId, workspaceId, } = hints;
75
114
  const sources = {
76
115
  assignee: {
77
116
  bag: 'users',
@@ -84,24 +123,17 @@ export function createFormSources(client, hints = {}) {
84
123
  return { options: rows.map(toOption), error: outcome.error };
85
124
  },
86
125
  },
87
- conductor: {
88
- bag: 'conductors',
126
+ reviewers: {
127
+ bag: 'reviewers',
89
128
  async load() {
90
- const outcome = await listConductors(client);
91
- const assignable = outcome.rows.filter((c) => c.active);
92
- // Two stable partitions, strongest LAST (GAIA-460 D8): everything I own
93
- // rises above everyone else's, then this checkout's record rises above
94
- // the rest of mine. Applied in the other order the weaker rule would
95
- // scatter what the stronger one just gathered.
96
- const byOwner = ownerUserId
97
- ? ownFirst(assignable, (c) => c.ownerUserId === ownerUserId)
98
- : assignable;
99
- const rows = ownConductorMachineId
100
- ? ownFirst(byOwner, (c) => c.machineId === ownConductorMachineId)
101
- : byOwner;
102
- return { options: rows.map(toOption), error: outcome.error };
129
+ return loadReviewerOptions(client, workspaceId);
103
130
  },
104
131
  },
132
+ conductor: conductorOptionSource(client, {
133
+ projectId,
134
+ ownerUserId,
135
+ ownConductorMachineId,
136
+ }),
105
137
  labels: {
106
138
  bag: 'labels',
107
139
  async load() {
@@ -118,6 +150,12 @@ export function createFormSources(client, hints = {}) {
118
150
  // error channel the parent-filter picker composes.
119
151
  sources.parent = parentOptionSource(client, projectId);
120
152
  }
153
+ else {
154
+ // GAIA-664 AC-8: no project in scope ⇒ the popup offers a Project field
155
+ // instead of refusing outright, over the SAME source the project-filter
156
+ // picker searches with (`projectOptionSource`) — never a second read.
157
+ sources.project = projectOptionSource(client);
158
+ }
121
159
  return sources;
122
160
  }
123
161
  /**
@@ -131,6 +169,7 @@ export async function composeCreateFormOptions(sources) {
131
169
  const { bags, errors } = await primeSources(sources);
132
170
  return {
133
171
  users: [],
172
+ reviewers: [],
134
173
  conductors: [],
135
174
  labels: [],
136
175
  workflows: WORKFLOW_OPTIONS,
@@ -12,10 +12,14 @@ export { cycleValue };
12
12
  */
13
13
  export interface CreateOptionErrors {
14
14
  users?: string | undefined;
15
+ reviewers?: string | undefined;
15
16
  conductors?: string | undefined;
16
17
  labels?: string | undefined;
17
18
  parents?: string | undefined;
18
19
  workflows?: string | undefined;
20
+ /** GAIA-664 AC-8: the project picker's own search, shown only while the
21
+ * popup has no project in scope — see {@link CreateFormOptions.projects}. */
22
+ projects?: string | undefined;
19
23
  /**
20
24
  * The whole load threw, rather than one bag failing. Until GAIA-257 the kernel
21
25
  * answered that by silently substituting four empty bags (F2a) — a whole-form
@@ -38,6 +42,7 @@ export interface ParentOption extends Option {
38
42
  }
39
43
  export interface CreateFormOptions {
40
44
  users: Option[];
45
+ reviewers: Option[];
41
46
  conductors: Option[];
42
47
  labels: Option[];
43
48
  workflows: Option[];
@@ -49,6 +54,13 @@ export interface CreateFormOptions {
49
54
  * the first thing, which is why the key is optional rather than defaulted.
50
55
  */
51
56
  parents?: ParentOption[] | undefined;
57
+ /**
58
+ * Candidate projects, searched by name — the SAME source the project-filter
59
+ * picker composes (`projectOptionSource`). Only declared as a source while
60
+ * the popup has no project in scope (GAIA-664 AC-8): with one, the operator
61
+ * is already working inside it and the field does not exist.
62
+ */
63
+ projects?: Option[] | undefined;
52
64
  /** Present only when at least one read failed; see `CreateOptionErrors`. */
53
65
  errors?: CreateOptionErrors | undefined;
54
66
  }
@@ -67,9 +79,19 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
67
79
  */
68
80
  export declare const AGENT_ACTION_ID = "submit";
69
81
  export declare const DIRECT_ACTION_ID = "direct";
70
- export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
82
+ export type CreateFieldId = 'project' | 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
71
83
  /** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
72
84
  export interface CreateValues {
85
+ /**
86
+ * The PICKED project's uuid, or null — GAIA-664 AC-8. Only a field on the
87
+ * form built WITHOUT a project in scope ({@link CREATE_FORM_WITH_PROJECT});
88
+ * on the ordinary form (a project already in scope) nothing ever sets it, so
89
+ * it reads as `undefined` at runtime despite the type — the same shape the
90
+ * other two-key autocomplete, `parent`, already has when its source is
91
+ * absent. `projectQuery` holds what was typed.
92
+ */
93
+ project: string | null;
94
+ projectQuery: string;
73
95
  /**
74
96
  * The ticket's title. `gaia_ticket.title` is `setRequired(TRUE)` server-side,
75
97
  * so a direct create without one is rejected — this was never optional
@@ -84,6 +106,7 @@ export interface CreateValues {
84
106
  parent: string | null;
85
107
  parentQuery: string;
86
108
  assignee: string | null;
109
+ reviewers: ReadonlySet<string>;
87
110
  conductor: string | null;
88
111
  labels: ReadonlySet<string>;
89
112
  workflow: string | null;
@@ -101,6 +124,7 @@ export interface CreateFormPayload {
101
124
  description: string;
102
125
  workflow: Option | null;
103
126
  assignee: Option | null;
127
+ reviewers: Option[];
104
128
  conductor: Option | null;
105
129
  labels: Option[];
106
130
  /**
@@ -113,7 +137,8 @@ export interface CreateFormPayload {
113
137
  /** The option with `id`, or null — used by both forms' `toObject`. */
114
138
  export declare const optionFor: (list: Option[], id: string | null) => Option | null;
115
139
  /**
116
- * The declaration. No field carries a function. Exactly ONE select is
140
+ * The declaration used whenever a project is already in scope (unchanged,
141
+ * GAIA-664 AC-8). No field carries a function. Exactly ONE select is
117
142
  * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
118
143
  * options only, exactly as before GAIA-233.
119
144
  */
@@ -125,8 +150,29 @@ export declare const CREATE_FORM: {
125
150
  apply(s: FormState<CreateValues>, options: CreateFormOptions, intent: import("../../../Form/form.js").Intent): FormState<CreateValues>;
126
151
  toObject: (s: FormState<CreateValues>, options: CreateFormOptions) => CreateFormPayload;
127
152
  };
128
- /** Field order in the popup (top to bottom). */
153
+ /**
154
+ * The declaration used when the popup opens with NO project in scope
155
+ * (GAIA-664 AC-8, the human decision replacing the old refusal-only form):
156
+ * one extra `autocomplete` field, ahead of everything else, over the SAME
157
+ * `projectOptionSource` the project-filter picker searches with — never a
158
+ * second project read. Picking a row here is what the screen reads to scope
159
+ * the Conductor bag and to carry a project into both submit paths; `toObject`
160
+ * needs none of it; the id travels through `CreateValues.project` instead,
161
+ * the same way the screen already reads `context.project` for the other form.
162
+ */
163
+ export declare const CREATE_FORM_WITH_PROJECT: {
164
+ fields: FieldDecl[];
165
+ activeField: (s: FormState<CreateValues>) => FieldDecl;
166
+ move: (s: FormState<CreateValues>, d: number) => FormState<CreateValues>;
167
+ initial(options: CreateFormOptions, prefill?: Partial<CreateValues>): FormState<CreateValues>;
168
+ apply(s: FormState<CreateValues>, options: CreateFormOptions, intent: import("../../../Form/form.js").Intent): FormState<CreateValues>;
169
+ toObject: (s: FormState<CreateValues>, options: CreateFormOptions) => CreateFormPayload;
170
+ };
171
+ /** Field order in the popup (top to bottom), a project already in scope. */
129
172
  export declare const FIELDS: FieldDef<CreateFieldId>[];
173
+ /** Field order with NO project in scope (GAIA-664 AC-8) — `FIELDS` plus the
174
+ * Project picker, ahead of everything else. */
175
+ export declare const FIELDS_WITH_PROJECT: FieldDef<CreateFieldId>[];
130
176
  export type CreateFormState = FormState<CreateValues>;
131
177
  /**
132
178
  * The values the popup carries from one open to the next (GAIA-314 D2).
@@ -5,6 +5,7 @@
5
5
  // is repeated here.
6
6
  import { cycleValue, declareForm, } from '../../../Form/index.js';
7
7
  import { PARENT_PAGE_SIZE } from '../Data/parents.js';
8
+ import { PROJECT_PAGE_SIZE } from '../Data/projects-search.js';
8
9
  // `cycleValue` is re-exported because GAIA-233's contract cases pin it against
9
10
  // an arbitrary option list, which no declared form can supply.
10
11
  //
@@ -48,60 +49,95 @@ const parentFor = (list, id) => {
48
49
  : null;
49
50
  };
50
51
  /**
51
- * The declaration. No field carries a function. Exactly ONE select is
52
+ * The shared field list (GAIA-664 AC-8): every field BUT the project picker,
53
+ * which only one of the two declarations below carries. Factored out so the
54
+ * two forms cannot drift apart on the fields neither decision touches — only
55
+ * `declareForm`'s one `fields` callback may differ between them.
56
+ */
57
+ const sharedFields = (f) => [
58
+ // First, because it is the one field the server REQUIRES (D1). Both submit
59
+ // paths read it: the direct write sends it, the agent adopts it from the
60
+ // temp JSON spec.
61
+ f.text('title', 'Title'),
62
+ f.text('description', 'Description'),
63
+ // Between the description and the assignee on purpose: a parent is chosen
64
+ // WITH what the ticket is about, before who works on it (GAIA-257 D5).
65
+ f.autocomplete('parent', 'Parent', {
66
+ from: 'parents',
67
+ query: 'parentQuery',
68
+ // The two things an ENTITY-AGNOSTIC widget cannot know, as plain data on
69
+ // the declaration (GAIA-257 re-entry AC-15 keeps a field printable, so
70
+ // neither may be a function). `absentNote` is what an ABSENT bag means
71
+ // HERE — the popup opened from the dashboard, where `n` is global and no
72
+ // project is in scope (D8) — as against an empty array, which would mean
73
+ // "this project was searched and nothing matched". `pageSize` is what the
74
+ // bag is filled from, so the suggestion list can say a full page may be a
75
+ // truncated answer even when the local filter narrows it (R3 / AC-17).
76
+ absentNote: '(no project context — open a project to pick a parent)',
77
+ pageSize: PARENT_PAGE_SIZE,
78
+ }),
79
+ f.select('assignee', 'Assignee', { from: 'users' }),
80
+ f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
81
+ // Clearable (D2): a SET conductor means the ticket is claimed at the next
82
+ // poll and a qualification run starts; a cleared one means it waits for a
83
+ // human. The field is clearable precisely so that choice is the operator's
84
+ // and not implicit. The preselection survives — `initial()` fills a select
85
+ // from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
86
+ // `create-data.ts` puts the machine's own conductor there.
87
+ f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
88
+ f.multi('labels', 'Labels', { from: 'labels' }),
89
+ f.select('workflow', 'Workflow', { from: 'workflows' }),
90
+ // Agent row FIRST and keeping the id `'submit'` (D7): nothing that exists
91
+ // today changes its meaning or its position.
92
+ f.action('Create with agent', AGENT_ACTION_ID),
93
+ f.action('Create directly', DIRECT_ACTION_ID),
94
+ ];
95
+ const toCreatePayload = (v, o) => ({
96
+ title: v.title,
97
+ description: v.description,
98
+ workflow: optionFor(o.workflows, v.workflow),
99
+ assignee: optionFor(o.users, v.assignee),
100
+ reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
101
+ conductor: optionFor(o.conductors, v.conductor),
102
+ labels: o.labels.filter((x) => v.labels.has(x.id)),
103
+ parent: parentFor(o.parents ?? [], v.parent),
104
+ });
105
+ /**
106
+ * The declaration used whenever a project is already in scope (unchanged,
107
+ * GAIA-664 AC-8). No field carries a function. Exactly ONE select is
52
108
  * `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
53
109
  * options only, exactly as before GAIA-233.
54
110
  */
55
111
  export const CREATE_FORM = declareForm({
112
+ fields: sharedFields,
113
+ toObject: toCreatePayload,
114
+ });
115
+ /**
116
+ * The declaration used when the popup opens with NO project in scope
117
+ * (GAIA-664 AC-8, the human decision replacing the old refusal-only form):
118
+ * one extra `autocomplete` field, ahead of everything else, over the SAME
119
+ * `projectOptionSource` the project-filter picker searches with — never a
120
+ * second project read. Picking a row here is what the screen reads to scope
121
+ * the Conductor bag and to carry a project into both submit paths; `toObject`
122
+ * needs none of it; the id travels through `CreateValues.project` instead,
123
+ * the same way the screen already reads `context.project` for the other form.
124
+ */
125
+ export const CREATE_FORM_WITH_PROJECT = declareForm({
56
126
  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'),
61
- f.text('description', 'Description'),
62
- // Between the description and the assignee on purpose: a parent is chosen
63
- // WITH what the ticket is about, before who works on it (GAIA-257 D5).
64
- f.autocomplete('parent', 'Parent', {
65
- from: 'parents',
66
- query: 'parentQuery',
67
- // The two things an ENTITY-AGNOSTIC widget cannot know, as plain data on
68
- // the declaration (GAIA-257 re-entry AC-15 keeps a field printable, so
69
- // neither may be a function). `absentNote` is what an ABSENT bag means
70
- // HERE — the popup opened from the dashboard, where `n` is global and no
71
- // project is in scope (D8) — as against an empty array, which would mean
72
- // "this project was searched and nothing matched". `pageSize` is what the
73
- // bag is filled from, so the suggestion list can say a full page may be a
74
- // truncated answer even when the local filter narrows it (R3 / AC-17).
75
- absentNote: '(no project context — open a project to pick a parent)',
76
- pageSize: PARENT_PAGE_SIZE,
127
+ f.autocomplete('project', 'Project', {
128
+ from: 'projects',
129
+ query: 'projectQuery',
130
+ pageSize: PROJECT_PAGE_SIZE,
77
131
  }),
78
- f.select('assignee', 'Assignee', { from: 'users' }),
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 }),
86
- f.multi('labels', 'Labels', { from: 'labels' }),
87
- f.select('workflow', 'Workflow', { from: 'workflows' }),
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),
132
+ ...sharedFields(f),
92
133
  ],
93
- toObject: (v, o) => ({
94
- title: v.title,
95
- description: v.description,
96
- workflow: optionFor(o.workflows, v.workflow),
97
- assignee: optionFor(o.users, v.assignee),
98
- conductor: optionFor(o.conductors, v.conductor),
99
- labels: o.labels.filter((x) => v.labels.has(x.id)),
100
- parent: parentFor(o.parents ?? [], v.parent),
101
- }),
134
+ toObject: toCreatePayload,
102
135
  });
103
- /** Field order in the popup (top to bottom). */
136
+ /** Field order in the popup (top to bottom), a project already in scope. */
104
137
  export const FIELDS = CREATE_FORM.fields;
138
+ /** Field order with NO project in scope (GAIA-664 AC-8) — `FIELDS` plus the
139
+ * Project picker, ahead of everything else. */
140
+ export const FIELDS_WITH_PROJECT = CREATE_FORM_WITH_PROJECT.fields;
105
141
  /** Snapshot the routing values of a form that is about to be replaced. */
106
142
  export const rememberedValues = (s) => ({
107
143
  parent: s.parent,
@@ -135,6 +171,7 @@ export const serialize = (s, opts) => CREATE_FORM.toObject(s, opts);
135
171
  // signatures predate the kernel and take no options — pass an empty bag.
136
172
  const NO_OPTIONS = {
137
173
  users: [],
174
+ reviewers: [],
138
175
  conductors: [],
139
176
  labels: [],
140
177
  workflows: [],
@@ -7,5 +7,7 @@ export interface EditFormHints {
7
7
  projectId?: string | undefined;
8
8
  /** The launcher's user uuid; hoists everything the machine's user owns (GAIA-460). */
9
9
  ownerUserId?: string | undefined;
10
+ /** Workspace UUID for the Reviewers bag (membership ∩ users). */
11
+ workspaceId?: string | undefined;
10
12
  }
11
13
  export declare function loadEditFormOptions(clients: EditFormClients, hints?: EditFormHints): Promise<EditFormOptions>;
@@ -15,6 +15,7 @@ import { listConductorsPreferringWrite, } from '../../conductor/Data/conductors.
15
15
  import { listTerms } from '../../term/Data/terms.js';
16
16
  import { listUsersPreferringWrite } from '../../user/Data/users.js';
17
17
  import { ownFirst } from './create-data.js';
18
+ import { loadReviewerOptions } from './reviewer-options.js';
18
19
  /** The vocabulary this popup offers as its labels (GAIA-360 D9 / D11). */
19
20
  const LABEL_VOCABULARY = 'gaia_labels';
20
21
  /**
@@ -42,14 +43,26 @@ export async function loadEditFormOptions(clients, hints = {}) {
42
43
  const labelsPromise = clients.read
43
44
  ? listTerms(clients.read, LABEL_VOCABULARY).then((outcome) => outcome.rows.map((t) => ({ id: t.id, label: t.label })), () => [])
44
45
  : Promise.resolve([]);
45
- const [users, conductors, labels] = await Promise.all([
46
+ const reviewerClient = clients.write ?? clients.read;
47
+ const reviewersPromise = reviewerClient
48
+ ? loadReviewerOptions(reviewerClient, hints.workspaceId)
49
+ : Promise.resolve({
50
+ options: [],
51
+ error: 'no client — reviewers cannot be loaded',
52
+ });
53
+ const [users, conductors, labels, reviewers] = await Promise.all([
46
54
  listUsersPreferringWrite(clients),
47
55
  listConductorsPreferringWrite(clients),
48
56
  labelsPromise,
57
+ reviewersPromise,
49
58
  ]);
50
59
  return {
51
60
  users: excludeAnonymous(users).map((u) => ({ id: u.id, label: u.label })),
61
+ reviewers: reviewers.options,
52
62
  conductors: ownFirst(inProject(conductors, hints.projectId).filter((c) => c.active), (c) => hints.ownerUserId !== undefined && c.ownerUserId === hints.ownerUserId).map(withStatusSuffix),
53
63
  labels,
64
+ ...(reviewers.error === undefined
65
+ ? {}
66
+ : { errors: { reviewers: reviewers.error } }),
54
67
  };
55
68
  }
@@ -1,7 +1,7 @@
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' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
4
+ export type EditFieldId = 'assignee' | 'reviewers' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
5
5
  /**
6
6
  * GAIA-313 / GAIA-360 — the ONE lifecycle move this popup offers, as a named
7
7
  * constant.
@@ -35,8 +35,12 @@ export declare const TERMINAL_TICKET_STATES: ReadonlySet<string>;
35
35
  /** The option lists the popup offers (loaded by `loadEditFormOptions`). */
36
36
  export interface EditFormOptions {
37
37
  users: Option[];
38
+ reviewers: Option[];
38
39
  conductors: Option[];
39
40
  labels: Option[];
41
+ errors?: {
42
+ reviewers?: string | undefined;
43
+ } | undefined;
40
44
  /**
41
45
  * Parent candidates from the option loader (project-scoped typeahead). Absent
42
46
  * until the screen primes a source; an empty array means "searched, nothing
@@ -48,7 +52,10 @@ export interface EditFormOptions {
48
52
  * verbatim so submit needs no ambient state, and never transitions it. */
49
53
  interface EditValues {
50
54
  ticketId: string;
55
+ /** Whether `relationships.reviewers` was present on the loaded document. */
56
+ reviewersLoaded: boolean;
51
57
  assignee: string | null;
58
+ reviewers: ReadonlySet<string>;
52
59
  conductor: string | null;
53
60
  /** The human duration as TYPED — seconds only exist at `toObject` (D5). */
54
61
  estimate: string;
@@ -86,6 +93,12 @@ export interface EditFormPayload {
86
93
  id: string;
87
94
  } | null;
88
95
  };
96
+ reviewers?: {
97
+ data: {
98
+ type: string;
99
+ id: string;
100
+ }[];
101
+ };
89
102
  };
90
103
  }
91
104
  /** Cancel ticket writes ONLY the lifecycle attribute (GAIA-360 D1). */