@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.
- package/dist/src/Entity/conductor/Data/conductors.d.ts +10 -0
- package/dist/src/Entity/conductor/Data/conductors.js +23 -1
- package/dist/src/Entity/ticket/Component/create-form/index.d.ts +2 -2
- package/dist/src/Entity/ticket/Component/create-form/index.js +4 -4
- package/dist/src/Entity/ticket/Component/edit-form/index.js +9 -2
- package/dist/src/Entity/ticket/Component/sections/index.js +5 -3
- package/dist/src/Entity/ticket/Component/ticket-content/index.d.ts +17 -0
- package/dist/src/Entity/ticket/Component/ticket-content/index.js +60 -2
- package/dist/src/Entity/ticket/Component/ticket-content/model.d.ts +1 -1
- package/dist/src/Entity/ticket/Component/ticket-content/model.js +16 -15
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -2
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +61 -6
- package/dist/src/Entity/ticket/Data/addons.d.ts +53 -0
- package/dist/src/Entity/ticket/Data/addons.js +35 -0
- package/dist/src/Entity/ticket/Data/detail.js +2 -1
- package/dist/src/Entity/ticket/Data/store.js +2 -1
- package/dist/src/Entity/ticket/Data/write.js +8 -0
- package/dist/src/Entity/ticket/Form/addon-action.d.ts +37 -0
- package/dist/src/Entity/ticket/Form/addon-action.js +80 -0
- package/dist/src/Entity/ticket/Form/create-data.d.ts +12 -1
- package/dist/src/Entity/ticket/Form/create-data.js +56 -17
- package/dist/src/Entity/ticket/Form/create.d.ts +49 -3
- package/dist/src/Entity/ticket/Form/create.js +82 -45
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +2 -0
- package/dist/src/Entity/ticket/Form/edit-data.js +14 -1
- package/dist/src/Entity/ticket/Form/edit.d.ts +14 -1
- package/dist/src/Entity/ticket/Form/edit.js +31 -0
- package/dist/src/Entity/ticket/Form/reviewer-options.d.ts +10 -0
- package/dist/src/Entity/ticket/Form/reviewer-options.js +48 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +24 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +150 -18
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +30 -1
- package/dist/src/Entity/ticket/Screen/ticket-addons.d.ts +94 -0
- package/dist/src/Entity/ticket/Screen/ticket-addons.js +492 -0
- package/dist/src/Entity/ticket/Screen/ticket-content-screen.d.ts +14 -0
- package/dist/src/Entity/ticket/Screen/ticket-content-screen.js +69 -7
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +6 -5
- package/dist/src/Routing/routes.js +3 -0
- package/dist/src/Widget/multiselect.js +7 -3
- package/dist/src/types.d.ts +5 -0
- 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 {
|
|
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
|
-
|
|
88
|
-
bag: '
|
|
126
|
+
reviewers: {
|
|
127
|
+
bag: 'reviewers',
|
|
89
128
|
async load() {
|
|
90
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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:
|
|
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
|
|
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). */
|