@gaia-ai/addon-gaia-ui 0.17.2 → 0.19.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/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 +33 -5
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -2
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +59 -4
- 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 -0
- package/dist/src/Entity/ticket/Data/store.js +2 -0
- package/dist/src/Entity/ticket/Data/tickets.d.ts +13 -9
- package/dist/src/Entity/ticket/Data/tickets.js +12 -4
- package/dist/src/Entity/ticket/Data/write.d.ts +7 -0
- package/dist/src/Entity/ticket/Data/write.js +17 -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 +2 -0
- package/dist/src/Entity/ticket/Form/create-data.js +9 -1
- package/dist/src/Entity/ticket/Form/create.d.ts +5 -1
- package/dist/src/Entity/ticket/Form/create.js +3 -0
- 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/conductor-tickets.d.ts +2 -2
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +4 -0
- 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 +21 -0
- package/dist/src/Entity/ticket/Screen/ticket-content-screen.js +122 -8
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +4 -2
- package/dist/src/Entity/ticket/Screen/tickets.d.ts +2 -2
- package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +2 -2
- 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
|
@@ -16,6 +16,15 @@ export function updateTicketAssignment(client, ticketId, body) {
|
|
|
16
16
|
}
|
|
17
17
|
return client.update(TICKET_TYPE, ticketId, body);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* PATCH a ticket's `frozen` flag (GAIA-302).
|
|
21
|
+
*
|
|
22
|
+
* Same write path as assignment: a refusal is not caught here so the caller
|
|
23
|
+
* can put the server's words on the status line.
|
|
24
|
+
*/
|
|
25
|
+
export function updateTicketFrozen(client, ticketId, frozen) {
|
|
26
|
+
return updateTicketAssignment(client, ticketId, { attributes: { frozen } });
|
|
27
|
+
}
|
|
19
28
|
/** The text format every ticket description is stored in (`gaia_rich`). */
|
|
20
29
|
export const TICKET_DESCRIPTION_FORMAT = 'gaia_rich';
|
|
21
30
|
/** A JSON:API to-one linkage, or nothing at all. */
|
|
@@ -60,6 +69,14 @@ export function buildTicketCreateBody(payload, projectId) {
|
|
|
60
69
|
})),
|
|
61
70
|
};
|
|
62
71
|
}
|
|
72
|
+
if (payload.reviewers.length > 0) {
|
|
73
|
+
relationships.reviewers = {
|
|
74
|
+
data: payload.reviewers.map((r) => ({
|
|
75
|
+
type: 'user--user',
|
|
76
|
+
id: r.id,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
63
80
|
const parent = one('gaia_ticket--gaia_ticket', payload.parent?.id);
|
|
64
81
|
if (parent)
|
|
65
82
|
relationships.parent_id = parent;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -21,6 +21,7 @@ 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 { loadReviewerOptions } from './reviewer-options.js';
|
|
24
25
|
// One back-compat re-export, no implementation: `loadEditFormOptions` lives in
|
|
25
26
|
// `./edit-form-data.js`, because a module named for the create form owning the EDIT
|
|
26
27
|
// form's loader is the category error AC-6 is about.
|
|
@@ -71,7 +72,7 @@ const LABEL_VOCABULARY = 'gaia_labels';
|
|
|
71
72
|
* can lag, but it can never show a row contradicting the text on screen.
|
|
72
73
|
*/
|
|
73
74
|
export function createFormSources(client, hints = {}) {
|
|
74
|
-
const { currentUser, ownConductorMachineId, ownerUserId, projectId } = hints;
|
|
75
|
+
const { currentUser, ownConductorMachineId, ownerUserId, projectId, workspaceId, } = hints;
|
|
75
76
|
const sources = {
|
|
76
77
|
assignee: {
|
|
77
78
|
bag: 'users',
|
|
@@ -84,6 +85,12 @@ export function createFormSources(client, hints = {}) {
|
|
|
84
85
|
return { options: rows.map(toOption), error: outcome.error };
|
|
85
86
|
},
|
|
86
87
|
},
|
|
88
|
+
reviewers: {
|
|
89
|
+
bag: 'reviewers',
|
|
90
|
+
async load() {
|
|
91
|
+
return loadReviewerOptions(client, workspaceId);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
87
94
|
conductor: {
|
|
88
95
|
bag: 'conductors',
|
|
89
96
|
async load() {
|
|
@@ -131,6 +138,7 @@ export async function composeCreateFormOptions(sources) {
|
|
|
131
138
|
const { bags, errors } = await primeSources(sources);
|
|
132
139
|
return {
|
|
133
140
|
users: [],
|
|
141
|
+
reviewers: [],
|
|
134
142
|
conductors: [],
|
|
135
143
|
labels: [],
|
|
136
144
|
workflows: WORKFLOW_OPTIONS,
|
|
@@ -12,6 +12,7 @@ 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;
|
|
@@ -38,6 +39,7 @@ export interface ParentOption extends Option {
|
|
|
38
39
|
}
|
|
39
40
|
export interface CreateFormOptions {
|
|
40
41
|
users: Option[];
|
|
42
|
+
reviewers: Option[];
|
|
41
43
|
conductors: Option[];
|
|
42
44
|
labels: Option[];
|
|
43
45
|
workflows: Option[];
|
|
@@ -67,7 +69,7 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
|
|
|
67
69
|
*/
|
|
68
70
|
export declare const AGENT_ACTION_ID = "submit";
|
|
69
71
|
export declare const DIRECT_ACTION_ID = "direct";
|
|
70
|
-
export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
|
|
72
|
+
export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
|
|
71
73
|
/** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
|
|
72
74
|
export interface CreateValues {
|
|
73
75
|
/**
|
|
@@ -84,6 +86,7 @@ export interface CreateValues {
|
|
|
84
86
|
parent: string | null;
|
|
85
87
|
parentQuery: string;
|
|
86
88
|
assignee: string | null;
|
|
89
|
+
reviewers: ReadonlySet<string>;
|
|
87
90
|
conductor: string | null;
|
|
88
91
|
labels: ReadonlySet<string>;
|
|
89
92
|
workflow: string | null;
|
|
@@ -101,6 +104,7 @@ export interface CreateFormPayload {
|
|
|
101
104
|
description: string;
|
|
102
105
|
workflow: Option | null;
|
|
103
106
|
assignee: Option | null;
|
|
107
|
+
reviewers: Option[];
|
|
104
108
|
conductor: Option | null;
|
|
105
109
|
labels: Option[];
|
|
106
110
|
/**
|
|
@@ -76,6 +76,7 @@ export const CREATE_FORM = declareForm({
|
|
|
76
76
|
pageSize: PARENT_PAGE_SIZE,
|
|
77
77
|
}),
|
|
78
78
|
f.select('assignee', 'Assignee', { from: 'users' }),
|
|
79
|
+
f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
|
|
79
80
|
// Clearable (D2): a SET conductor means the ticket is claimed at the next
|
|
80
81
|
// poll and a qualification run starts; a cleared one means it waits for a
|
|
81
82
|
// human. The field is clearable precisely so that choice is the operator's
|
|
@@ -95,6 +96,7 @@ export const CREATE_FORM = declareForm({
|
|
|
95
96
|
description: v.description,
|
|
96
97
|
workflow: optionFor(o.workflows, v.workflow),
|
|
97
98
|
assignee: optionFor(o.users, v.assignee),
|
|
99
|
+
reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
|
|
98
100
|
conductor: optionFor(o.conductors, v.conductor),
|
|
99
101
|
labels: o.labels.filter((x) => v.labels.has(x.id)),
|
|
100
102
|
parent: parentFor(o.parents ?? [], v.parent),
|
|
@@ -135,6 +137,7 @@ export const serialize = (s, opts) => CREATE_FORM.toObject(s, opts);
|
|
|
135
137
|
// signatures predate the kernel and take no options — pass an empty bag.
|
|
136
138
|
const NO_OPTIONS = {
|
|
137
139
|
users: [],
|
|
140
|
+
reviewers: [],
|
|
138
141
|
conductors: [],
|
|
139
142
|
labels: [],
|
|
140
143
|
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). */
|
|
@@ -105,6 +105,7 @@ const timeAttributes = (v) => {
|
|
|
105
105
|
const declareEditForm = (savable, cancellable) => declareForm({
|
|
106
106
|
fields: (f) => [
|
|
107
107
|
f.select('assignee', 'Assignee', { from: 'users', clearable: true }),
|
|
108
|
+
f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
|
|
108
109
|
f.select('conductor', 'Conductor', {
|
|
109
110
|
from: 'conductors',
|
|
110
111
|
clearable: true,
|
|
@@ -132,6 +133,16 @@ const declareEditForm = (savable, cancellable) => declareForm({
|
|
|
132
133
|
conductor_id: linkage('gaia_conductor--gaia_conductor', v.conductor),
|
|
133
134
|
labels: labelsLinkage(v.labels),
|
|
134
135
|
parent_id: linkage('gaia_ticket--gaia_ticket', v.parent),
|
|
136
|
+
...(v.reviewersLoaded || v.reviewers.size > 0
|
|
137
|
+
? {
|
|
138
|
+
reviewers: {
|
|
139
|
+
data: [...v.reviewers].map((id) => ({
|
|
140
|
+
type: 'user--user',
|
|
141
|
+
id,
|
|
142
|
+
})),
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
135
146
|
},
|
|
136
147
|
}),
|
|
137
148
|
});
|
|
@@ -178,6 +189,7 @@ export const EDIT_FIELDS_CLOSED = EDIT_FORM_CLOSED.fields;
|
|
|
178
189
|
*/
|
|
179
190
|
export const EMPTY_EDIT_FORM_OPTIONS = {
|
|
180
191
|
users: [],
|
|
192
|
+
reviewers: [],
|
|
181
193
|
conductors: [],
|
|
182
194
|
labels: [],
|
|
183
195
|
};
|
|
@@ -196,6 +208,22 @@ const labelIds = (document) => {
|
|
|
196
208
|
}
|
|
197
209
|
return data.id ? new Set([data.id]) : new Set();
|
|
198
210
|
};
|
|
211
|
+
const reviewersFromDocument = (document) => {
|
|
212
|
+
const rel = document.data?.relationships?.reviewers;
|
|
213
|
+
if (rel === undefined)
|
|
214
|
+
return { ids: new Set(), loaded: false };
|
|
215
|
+
const data = rel.data;
|
|
216
|
+
// A to-many relationship without `data` was not read; never treat it as empty.
|
|
217
|
+
if (!data)
|
|
218
|
+
return { ids: new Set(), loaded: false };
|
|
219
|
+
if (Array.isArray(data)) {
|
|
220
|
+
return {
|
|
221
|
+
ids: new Set(data.map((row) => row.id).filter(Boolean)),
|
|
222
|
+
loaded: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { ids: data.id ? new Set([data.id]) : new Set(), loaded: true };
|
|
226
|
+
};
|
|
199
227
|
const timeValue = (document, name) => {
|
|
200
228
|
const value = document.data?.attributes?.[name];
|
|
201
229
|
// An unset field prefills EMPTY, never `0m`: the popup must not turn "nobody
|
|
@@ -227,9 +255,12 @@ const parentQueryFromDocument = (document, parentId) => {
|
|
|
227
255
|
* explicit `null` STICKS instead of falling back to the first option. */
|
|
228
256
|
export function initialEditForm(document) {
|
|
229
257
|
const parent = relId(document, 'parent_id');
|
|
258
|
+
const reviewers = reviewersFromDocument(document);
|
|
230
259
|
return EDIT_FORM.initial(EMPTY_EDIT_FORM_OPTIONS, {
|
|
231
260
|
ticketId: document.data?.id ?? '',
|
|
261
|
+
reviewersLoaded: reviewers.loaded,
|
|
232
262
|
assignee: relId(document, 'assignee_user_id'),
|
|
263
|
+
reviewers: reviewers.ids,
|
|
233
264
|
conductor: relId(document, 'conductor_id'),
|
|
234
265
|
estimate: timeValue(document, 'time_estimate'),
|
|
235
266
|
spent: timeValue(document, 'time_spent'),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Option } from '../../../Form/index.js';
|
|
2
|
+
import type { DropshClient } from '../../../types.js';
|
|
3
|
+
export declare const NO_WORKSPACE_REVIEWERS_ERROR = "no workspace in scope \u2014 reviewers cannot be loaded";
|
|
4
|
+
export interface ReviewerOptionOutcome {
|
|
5
|
+
options: Option[];
|
|
6
|
+
error?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Selected ids that are not in the bag stay visible/removable. */
|
|
9
|
+
export declare function withSelectedReviewers(bag: Option[], selected: Iterable<string>, labels?: ReadonlyMap<string, string>): Option[];
|
|
10
|
+
export declare function loadReviewerOptions(client: DropshClient, workspaceId: string | undefined): Promise<ReviewerOptionOutcome>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Form-layer adapter: the Reviewers option bag is membership ∩ users.
|
|
2
|
+
// Data services stay form-ignorant; this module owns the intersection, the
|
|
3
|
+
// Anonymous drop, and the reviewer-specific error. Never falls back to the
|
|
4
|
+
// instance-wide user list.
|
|
5
|
+
import { listAllUsers } from '../../user/Data/users.js';
|
|
6
|
+
import { loadWorkspaceMemberships } from '../../workspace/Data/members.js';
|
|
7
|
+
export const NO_WORKSPACE_REVIEWERS_ERROR = 'no workspace in scope — reviewers cannot be loaded';
|
|
8
|
+
const toOption = (row) => ({
|
|
9
|
+
id: row.id,
|
|
10
|
+
label: row.label,
|
|
11
|
+
});
|
|
12
|
+
/** Selected ids that are not in the bag stay visible/removable. */
|
|
13
|
+
export function withSelectedReviewers(bag, selected, labels = new Map()) {
|
|
14
|
+
const known = new Set(bag.map((option) => option.id));
|
|
15
|
+
const extra = [];
|
|
16
|
+
for (const id of selected) {
|
|
17
|
+
if (known.has(id))
|
|
18
|
+
continue;
|
|
19
|
+
extra.push({ id, label: labels.get(id) ?? id });
|
|
20
|
+
}
|
|
21
|
+
return extra.length === 0 ? bag : [...bag, ...extra];
|
|
22
|
+
}
|
|
23
|
+
export async function loadReviewerOptions(client, workspaceId) {
|
|
24
|
+
if (!workspaceId) {
|
|
25
|
+
return { options: [], error: NO_WORKSPACE_REVIEWERS_ERROR };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const [memberships, users] = await Promise.all([
|
|
29
|
+
loadWorkspaceMemberships(client, workspaceId),
|
|
30
|
+
listAllUsers(client),
|
|
31
|
+
]);
|
|
32
|
+
if (users.error) {
|
|
33
|
+
return { options: [], error: users.error };
|
|
34
|
+
}
|
|
35
|
+
const memberIds = new Set(memberships.memberIds);
|
|
36
|
+
return {
|
|
37
|
+
options: users.rows
|
|
38
|
+
.filter((user) => memberIds.has(user.id) && user.uid !== 0)
|
|
39
|
+
.map(toOption),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (caught) {
|
|
43
|
+
return {
|
|
44
|
+
options: [],
|
|
45
|
+
error: caught instanceof Error ? caught.message : String(caught),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -57,8 +57,8 @@ export declare class ConductorTicketsScreen extends ListScreen<TicketRow, void>
|
|
|
57
57
|
}, {
|
|
58
58
|
readonly key: "run";
|
|
59
59
|
readonly label: "RUN";
|
|
60
|
-
readonly width:
|
|
61
|
-
readonly fields: readonly ["derived_status"];
|
|
60
|
+
readonly width: 12;
|
|
61
|
+
readonly fields: readonly ["derived_status", "stall_reason"];
|
|
62
62
|
readonly get: (r: TicketRow) => string;
|
|
63
63
|
}, {
|
|
64
64
|
readonly key: "conductor";
|
|
@@ -90,10 +90,14 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
90
90
|
...(context.project === undefined
|
|
91
91
|
? {}
|
|
92
92
|
: { projectId: context.project.id }),
|
|
93
|
+
...(context.services.workspaceId === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { workspaceId: context.services.workspaceId }),
|
|
93
96
|
});
|
|
94
97
|
this.sources = sources;
|
|
95
98
|
return composeCreateFormOptions(sources).catch((caught) => ({
|
|
96
99
|
users: [],
|
|
100
|
+
reviewers: [],
|
|
97
101
|
conductors: [],
|
|
98
102
|
labels: [],
|
|
99
103
|
workflows: [],
|
|
@@ -13,6 +13,7 @@ import { CANCEL_TICKET_ACTION_ID, cancelEditPayload, EDIT_FORM_CLOSED, EMPTY_EDI
|
|
|
13
13
|
import { loadEditFormOptions } from '../Form/edit-data.js';
|
|
14
14
|
import { editParentOptionSource } from '../Form/edit-parent-source.js';
|
|
15
15
|
import { keyToIntent } from '../Form/keys.js';
|
|
16
|
+
import { withSelectedReviewers } from '../Form/reviewer-options.js';
|
|
16
17
|
export const editAssignmentInputContext = defineContext('edit-assignment-input', 'input');
|
|
17
18
|
const relationshipId = (document, name) => {
|
|
18
19
|
const data = document.data?.relationships?.[name]?.data;
|
|
@@ -20,6 +21,27 @@ const relationshipId = (document, name) => {
|
|
|
20
21
|
return undefined;
|
|
21
22
|
return Array.isArray(data) ? data[0]?.id : data.id;
|
|
22
23
|
};
|
|
24
|
+
const reviewerIds = (document) => {
|
|
25
|
+
const data = document.data?.relationships?.reviewers?.data;
|
|
26
|
+
if (!data)
|
|
27
|
+
return [];
|
|
28
|
+
return (Array.isArray(data) ? data : [data])
|
|
29
|
+
.map((row) => row.id)
|
|
30
|
+
.filter((id) => Boolean(id));
|
|
31
|
+
};
|
|
32
|
+
const includedUserLabels = (document) => {
|
|
33
|
+
const labels = new Map();
|
|
34
|
+
for (const row of document.included ?? []) {
|
|
35
|
+
if (row.type !== 'user--user' || !row.id)
|
|
36
|
+
continue;
|
|
37
|
+
const attrs = row.attributes ?? {};
|
|
38
|
+
const label = (typeof attrs.display_name === 'string' && attrs.display_name) ||
|
|
39
|
+
(typeof attrs.name === 'string' && attrs.name) ||
|
|
40
|
+
row.id;
|
|
41
|
+
labels.set(row.id, label);
|
|
42
|
+
}
|
|
43
|
+
return labels;
|
|
44
|
+
};
|
|
23
45
|
export class EditAssignmentFormScreen extends AsyncModalScreen {
|
|
24
46
|
static result(ticketId, assigneeId) {
|
|
25
47
|
return { type: 'ticket-assignment-updated', ticketId, assigneeId };
|
|
@@ -82,7 +104,7 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
|
|
|
82
104
|
// once per screen lifetime through `OwnerIdentity` rather than threaded
|
|
83
105
|
// straight through. Fails open: `unavailable` omits the hint entirely.
|
|
84
106
|
const ownerOutcome = await this.ownerIdentity.resolve(context.services.client, context.services.currentUser);
|
|
85
|
-
const
|
|
107
|
+
const loaded = await loadEditFormOptions({
|
|
86
108
|
read: context.services.client,
|
|
87
109
|
...(context.services.writeClient === undefined
|
|
88
110
|
? {}
|
|
@@ -92,7 +114,14 @@ export class EditAssignmentFormScreen extends AsyncModalScreen {
|
|
|
92
114
|
...(ownerOutcome.kind === 'resolved'
|
|
93
115
|
? { ownerUserId: ownerOutcome.id }
|
|
94
116
|
: {}),
|
|
117
|
+
...(context.services.workspaceId === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { workspaceId: context.services.workspaceId }),
|
|
95
120
|
}).catch(() => EMPTY_EDIT_FORM_OPTIONS);
|
|
121
|
+
const options = {
|
|
122
|
+
...loaded,
|
|
123
|
+
reviewers: withSelectedReviewers(loaded.reviewers, reviewerIds(document), includedUserLabels(document)),
|
|
124
|
+
};
|
|
96
125
|
// Parent typeahead: project-scoped source with self-exclusion (D5/D11).
|
|
97
126
|
// No project ⇒ no source, so the bag stays absent and the field shows
|
|
98
127
|
// its absentNote. Built as one object: OptionSources is Readonly and
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { VNode } from '@opentui/core';
|
|
2
|
+
import { type ActionItem } from '../../../Component/footer-actions/index.js';
|
|
3
|
+
import { AsyncModalScreen } from '../../../Screen/async-modal-screen.js';
|
|
4
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
5
|
+
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
6
|
+
import type { ModalChrome } from '../../../Screen/modal-screen.js';
|
|
7
|
+
import type { ScreenAction, ScreenContext } from '../../../Screen/screen.js';
|
|
8
|
+
import type { TicketDetailDocument } from '../../../types.js';
|
|
9
|
+
import { type TicketAddon, type TicketAddons } from '../Data/addons.js';
|
|
10
|
+
interface AddonsData {
|
|
11
|
+
ticket: TicketDetailDocument;
|
|
12
|
+
model: TicketAddons;
|
|
13
|
+
}
|
|
14
|
+
/** What the message form needs; passed as navigation state. */
|
|
15
|
+
export interface AddonFormState {
|
|
16
|
+
ticketUuid: string;
|
|
17
|
+
addon: string;
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
input: NonNullable<TicketAddon['messages'][number]['input']>;
|
|
21
|
+
}
|
|
22
|
+
export declare class TicketAddonsScreen extends AsyncScreen<AddonsData> {
|
|
23
|
+
static readonly route: {
|
|
24
|
+
id: string;
|
|
25
|
+
path: string;
|
|
26
|
+
parent: string;
|
|
27
|
+
fallback: {
|
|
28
|
+
routeId: string;
|
|
29
|
+
};
|
|
30
|
+
context: (params: Readonly<Record<string, string>>, state: unknown, base: import("../../../Routing/context.js").ContextBag) => import("../../../Routing/context.js").ContextBag;
|
|
31
|
+
};
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly frameId = "ticket";
|
|
34
|
+
private readonly cursor;
|
|
35
|
+
private selected;
|
|
36
|
+
private sending;
|
|
37
|
+
private timer;
|
|
38
|
+
protected load(context: ScreenContext): Promise<AddonsData>;
|
|
39
|
+
protected didLoad(data: AddonsData, context: ScreenContext): void;
|
|
40
|
+
reload(context: ScreenContext): void;
|
|
41
|
+
leave(): void;
|
|
42
|
+
reset(): void;
|
|
43
|
+
/** Re-read every 3 s while the shown addon has an open message. */
|
|
44
|
+
private schedulePoll;
|
|
45
|
+
private stopPoll;
|
|
46
|
+
private addon;
|
|
47
|
+
private entries;
|
|
48
|
+
private clampCursor;
|
|
49
|
+
protected renderLoading(): string[];
|
|
50
|
+
private lines;
|
|
51
|
+
protected renderReady(data: AddonsData, context: ScreenContext): VNode;
|
|
52
|
+
renderNonInteractive(context: ScreenContext): string[];
|
|
53
|
+
protected chrome(context: ScreenContext): FullScreenChrome;
|
|
54
|
+
protected actions(): readonly ActionItem[];
|
|
55
|
+
protected notice(): string | undefined;
|
|
56
|
+
select(index: number, context: ScreenContext): boolean;
|
|
57
|
+
activate(index: number, context: ScreenContext): boolean;
|
|
58
|
+
/** Runs an entry's message: sends it, or opens its form. */
|
|
59
|
+
private run;
|
|
60
|
+
handleInput(input: string, context: ScreenContext): boolean;
|
|
61
|
+
commandForInput(input: string): string | undefined;
|
|
62
|
+
actionForInput(input: string, context: ScreenContext): ScreenAction | undefined;
|
|
63
|
+
receiveResult(result: unknown, context: ScreenContext): void;
|
|
64
|
+
}
|
|
65
|
+
type FormOptions = Record<string, {
|
|
66
|
+
id: string;
|
|
67
|
+
label: string;
|
|
68
|
+
}[]>;
|
|
69
|
+
/** The form of an action with input, built from its input subset. */
|
|
70
|
+
export declare class AddonActionFormScreen extends AsyncModalScreen<FormOptions> {
|
|
71
|
+
static readonly route: {
|
|
72
|
+
id: string;
|
|
73
|
+
path: string;
|
|
74
|
+
parent: string;
|
|
75
|
+
presentation: "modal";
|
|
76
|
+
};
|
|
77
|
+
readonly id: string;
|
|
78
|
+
private target;
|
|
79
|
+
private form;
|
|
80
|
+
private values;
|
|
81
|
+
private errors;
|
|
82
|
+
private sending;
|
|
83
|
+
protected load(context: ScreenContext): Promise<FormOptions>;
|
|
84
|
+
protected didLoad(options: FormOptions): void;
|
|
85
|
+
protected renderLoading(): string[];
|
|
86
|
+
protected renderReady(options: FormOptions): VNode;
|
|
87
|
+
protected modalChrome(): ModalChrome;
|
|
88
|
+
protected actions(): readonly ActionItem[];
|
|
89
|
+
handleInput(input: string, context: ScreenContext): boolean;
|
|
90
|
+
private submit;
|
|
91
|
+
protected notice(): string | undefined;
|
|
92
|
+
reset(): void;
|
|
93
|
+
}
|
|
94
|
+
export {};
|