@gaia-ai/addon-gaia-ui 0.19.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/ticket-content/model.js +0 -10
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +2 -2
- package/dist/src/Entity/ticket/Data/detail.js +0 -1
- package/dist/src/Entity/ticket/Data/store.js +0 -1
- package/dist/src/Entity/ticket/Form/create-data.d.ts +10 -1
- package/dist/src/Entity/ticket/Form/create-data.js +50 -19
- package/dist/src/Entity/ticket/Form/create.d.ts +45 -3
- package/dist/src/Entity/ticket/Form/create.js +81 -47
- package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +24 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +149 -21
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +2 -3
- package/package.json +3 -3
|
@@ -26,6 +26,16 @@ export interface Conductor {
|
|
|
26
26
|
* contract, because it already answers a failure by falling back.
|
|
27
27
|
*/
|
|
28
28
|
export declare const listConductors: (client: DropshClient) => Promise<ListOutcome<Conductor>>;
|
|
29
|
+
/**
|
|
30
|
+
* The conductor collection scoped to one project (GAIA-664 review F4).
|
|
31
|
+
*
|
|
32
|
+
* The create popup's narrow option loader, not `ConductorQuery`'s row-page
|
|
33
|
+
* read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
|
|
34
|
+
* outcome/error contract rather than inventing a second one for the same
|
|
35
|
+
* entity. An unscoped caller keeps calling {@link listConductors}; this is
|
|
36
|
+
* additive.
|
|
37
|
+
*/
|
|
38
|
+
export declare const listConductorsForProject: (client: DropshClient, projectId: string) => Promise<ListOutcome<Conductor>>;
|
|
29
39
|
/**
|
|
30
40
|
* The same collection read through the WRITE client where there is one — still just
|
|
31
41
|
* reading, but the write scope is the one that decides what a write may reference,
|
|
@@ -8,7 +8,7 @@ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
|
8
8
|
import { humanizeAge } from '../../../lib/format.js';
|
|
9
9
|
import { loadPage, } from '../../collection.js';
|
|
10
10
|
import { LIST_PAGE_SIZE } from '../../cycle.js';
|
|
11
|
-
import { listOutcome, listPreferringWrite, } from '../../transport.js';
|
|
11
|
+
import { listOutcome, listPreferringWrite, messageOf, } from '../../transport.js';
|
|
12
12
|
import { deriveConductorStatus } from './liveness.js';
|
|
13
13
|
const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
14
14
|
const toConductor = (r) => ({
|
|
@@ -32,6 +32,28 @@ const toConductor = (r) => ({
|
|
|
32
32
|
* contract, because it already answers a failure by falling back.
|
|
33
33
|
*/
|
|
34
34
|
export const listConductors = (client) => listOutcome(client, 'gaia_conductor', toConductor);
|
|
35
|
+
/**
|
|
36
|
+
* The conductor collection scoped to one project (GAIA-664 review F4).
|
|
37
|
+
*
|
|
38
|
+
* The create popup's narrow option loader, not `ConductorQuery`'s row-page
|
|
39
|
+
* read above (GAIA-664 plan correction): this keeps `listOutcome`'s own
|
|
40
|
+
* outcome/error contract rather than inventing a second one for the same
|
|
41
|
+
* entity. An unscoped caller keeps calling {@link listConductors}; this is
|
|
42
|
+
* additive.
|
|
43
|
+
*/
|
|
44
|
+
export const listConductorsForProject = async (client, projectId) => {
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
rows: (await client
|
|
48
|
+
.collection('gaia_conductor')
|
|
49
|
+
.where('project_id.id', '=', projectId)
|
|
50
|
+
.list()).map(toConductor),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (caught) {
|
|
54
|
+
return { rows: [], error: messageOf(caught) };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
35
57
|
/**
|
|
36
58
|
* The same collection read through the WRITE client where there is one — still just
|
|
37
59
|
* reading, but the write scope is the one that decides what a write may reference,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@opentui/core';
|
|
2
|
-
import {
|
|
2
|
+
import type { CreateFieldId, CreateFormOptions, CreateFormState, FieldDef } from '../../Form/create.js';
|
|
3
3
|
/** The chrome the kernel wraps this tree in. */
|
|
4
4
|
export interface CreateFormChrome {
|
|
5
5
|
/**
|
|
@@ -25,4 +25,4 @@ export interface CreateFormChrome {
|
|
|
25
25
|
/** The id the popup's body column is rooted at. */
|
|
26
26
|
export declare const CREATE_FORM_REGION = "create-form";
|
|
27
27
|
/** Render the "New ticket" popup as the tree the kernel frames. */
|
|
28
|
-
export declare function createFormTree(s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
|
|
28
|
+
export declare function createFormTree(fields: FieldDef<CreateFieldId>[], s: CreateFormState, opts: CreateFormOptions, chrome?: CreateFormChrome): VNode;
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// to rebase again onto its own.
|
|
18
18
|
import { columnNode, textNode } from '../../../../lib/vnode.js';
|
|
19
19
|
import { note, renderDeclaredFieldsTree, widgetFor, } from '../../../../Widget/index.js';
|
|
20
|
-
import { FIELDS, } from '../../Form/create.js';
|
|
21
20
|
const DEFAULT_WIDTH = 58;
|
|
22
21
|
/** The id the popup's body column is rooted at. */
|
|
23
22
|
export const CREATE_FORM_REGION = 'create-form';
|
|
@@ -34,7 +33,7 @@ const WHOLE_FORM = 'The form options';
|
|
|
34
33
|
*/
|
|
35
34
|
const bagName = (field) => field.kind === 'multiselect' ? field.label : `${field.label} options`;
|
|
36
35
|
/** Render the "New ticket" popup as the tree the kernel frames. */
|
|
37
|
-
export function createFormTree(s, opts, chrome = {}) {
|
|
36
|
+
export function createFormTree(fields, s, opts, chrome = {}) {
|
|
38
37
|
const width = chrome.width ?? DEFAULT_WIDTH;
|
|
39
38
|
// Keyed by BAG, which is what `from` names; the typed shape is the adapter's
|
|
40
39
|
// business, and reading it by key is what keeps this loop declaration-driven.
|
|
@@ -49,7 +48,8 @@ export function createFormTree(s, opts, chrome = {}) {
|
|
|
49
48
|
// one, plus a whole-form failure, collapses into ONE row at the top. It is a
|
|
50
49
|
// note, not a refusal: a failed OPTION read must not cost the writer the
|
|
51
50
|
// description they opened the popup to type (D4).
|
|
52
|
-
const formLevel =
|
|
51
|
+
const formLevel = fields
|
|
52
|
+
.filter((f) => !widgetFor(f).ownsError)
|
|
53
53
|
.map(reason)
|
|
54
54
|
.filter((sentence) => sentence !== undefined);
|
|
55
55
|
if (errors.form !== undefined) {
|
|
@@ -63,7 +63,7 @@ export function createFormTree(s, opts, chrome = {}) {
|
|
|
63
63
|
// `note` supplies the `⚠` sigil itself, so the message is passed plain —
|
|
64
64
|
// and it WRAPS, which is what keeps a long server message off the frame's
|
|
65
65
|
// ellipsis (the same reason the option-load note above it wraps).
|
|
66
|
-
...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(
|
|
66
|
+
...(chrome.message ? noteNodes(chrome.message, 'create-form-message') : []), renderDeclaredFieldsTree(fields, s, opts, {
|
|
67
67
|
width,
|
|
68
68
|
errorFor: reason,
|
|
69
69
|
// A form-level failure means no read succeeded, so an empty bag may not
|
|
@@ -69,10 +69,6 @@ const colonValue = (text) => {
|
|
|
69
69
|
const index = text.indexOf(':');
|
|
70
70
|
return index < 0 ? text : text.slice(index + 1);
|
|
71
71
|
};
|
|
72
|
-
const entityLabel = (document, id, type) => {
|
|
73
|
-
const row = (document.included ?? []).find((resource) => resource.id === id && resource.type === type);
|
|
74
|
-
return (asString(row?.attributes?.label) ?? asString(row?.attributes?.name) ?? id);
|
|
75
|
-
};
|
|
76
72
|
const PRIORITY_LABEL = {
|
|
77
73
|
'1': 'high',
|
|
78
74
|
'2': 'medium',
|
|
@@ -86,12 +82,6 @@ const lifecycleProperties = (document) => {
|
|
|
86
82
|
const priorityKey = String(attributes.priority ?? '').toLowerCase();
|
|
87
83
|
const extras = [
|
|
88
84
|
property('priority', PRIORITY_LABEL[priorityKey] ?? ''),
|
|
89
|
-
property('team', (() => {
|
|
90
|
-
const teamId = relationshipIds(document, 'team')[0];
|
|
91
|
-
return teamId === undefined
|
|
92
|
-
? ''
|
|
93
|
-
: entityLabel(document, teamId, 'gaia_team--gaia_team');
|
|
94
|
-
})()),
|
|
95
85
|
property('closed', attributes.closed === true ? 'yes' : 'no'),
|
|
96
86
|
];
|
|
97
87
|
const frozenAtRaw = attributes.frozen_at;
|
|
@@ -44,8 +44,8 @@ function headerLineNodes(id, content, fg) {
|
|
|
44
44
|
}
|
|
45
45
|
export function ticketHeaderTree(document, identifier, title, lastRow, labels) {
|
|
46
46
|
// One compact meta line — state · assignee · labels — under the title. The
|
|
47
|
-
// full set (created by,
|
|
48
|
-
//
|
|
47
|
+
// full set (created by, branch, …) lives in the Overview sidebar, so the
|
|
48
|
+
// header stays two lines rather than five. Empty fields drop out.
|
|
49
49
|
const meta = [
|
|
50
50
|
headerState(document),
|
|
51
51
|
headerAssignee(document, labels),
|
|
@@ -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';
|
|
@@ -29,6 +29,15 @@ export interface CreateFormHints {
|
|
|
29
29
|
* conductors — only what counts as "own" differs, which the caller passes in.
|
|
30
30
|
*/
|
|
31
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;
|
|
32
41
|
/**
|
|
33
42
|
* The option sources this popup's fields load through, keyed by FIELD id.
|
|
34
43
|
*
|
|
@@ -16,11 +16,12 @@
|
|
|
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';
|
|
24
25
|
import { loadReviewerOptions } from './reviewer-options.js';
|
|
25
26
|
// One back-compat re-export, no implementation: `loadEditFormOptions` lives in
|
|
26
27
|
// `./edit-form-data.js`, because a module named for the create form owning the EDIT
|
|
@@ -48,6 +49,43 @@ const toOption = (row) => ({ id: row.id, label: row.label });
|
|
|
48
49
|
* type's vocabularies a given widget wants is the widget's business.
|
|
49
50
|
*/
|
|
50
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
|
+
}
|
|
51
89
|
/**
|
|
52
90
|
* The option sources this popup's fields load through, keyed by FIELD id.
|
|
53
91
|
*
|
|
@@ -91,24 +129,11 @@ export function createFormSources(client, hints = {}) {
|
|
|
91
129
|
return loadReviewerOptions(client, workspaceId);
|
|
92
130
|
},
|
|
93
131
|
},
|
|
94
|
-
conductor: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// Two stable partitions, strongest LAST (GAIA-460 D8): everything I own
|
|
100
|
-
// rises above everyone else's, then this checkout's record rises above
|
|
101
|
-
// the rest of mine. Applied in the other order the weaker rule would
|
|
102
|
-
// scatter what the stronger one just gathered.
|
|
103
|
-
const byOwner = ownerUserId
|
|
104
|
-
? ownFirst(assignable, (c) => c.ownerUserId === ownerUserId)
|
|
105
|
-
: assignable;
|
|
106
|
-
const rows = ownConductorMachineId
|
|
107
|
-
? ownFirst(byOwner, (c) => c.machineId === ownConductorMachineId)
|
|
108
|
-
: byOwner;
|
|
109
|
-
return { options: rows.map(toOption), error: outcome.error };
|
|
110
|
-
},
|
|
111
|
-
},
|
|
132
|
+
conductor: conductorOptionSource(client, {
|
|
133
|
+
projectId,
|
|
134
|
+
ownerUserId,
|
|
135
|
+
ownConductorMachineId,
|
|
136
|
+
}),
|
|
112
137
|
labels: {
|
|
113
138
|
bag: 'labels',
|
|
114
139
|
async load() {
|
|
@@ -125,6 +150,12 @@ export function createFormSources(client, hints = {}) {
|
|
|
125
150
|
// error channel the parent-filter picker composes.
|
|
126
151
|
sources.parent = parentOptionSource(client, projectId);
|
|
127
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
|
+
}
|
|
128
159
|
return sources;
|
|
129
160
|
}
|
|
130
161
|
/**
|
|
@@ -17,6 +17,9 @@ export interface CreateOptionErrors {
|
|
|
17
17
|
labels?: string | undefined;
|
|
18
18
|
parents?: string | undefined;
|
|
19
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;
|
|
20
23
|
/**
|
|
21
24
|
* The whole load threw, rather than one bag failing. Until GAIA-257 the kernel
|
|
22
25
|
* answered that by silently substituting four empty bags (F2a) — a whole-form
|
|
@@ -51,6 +54,13 @@ export interface CreateFormOptions {
|
|
|
51
54
|
* the first thing, which is why the key is optional rather than defaulted.
|
|
52
55
|
*/
|
|
53
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;
|
|
54
64
|
/** Present only when at least one read failed; see `CreateOptionErrors`. */
|
|
55
65
|
errors?: CreateOptionErrors | undefined;
|
|
56
66
|
}
|
|
@@ -69,9 +79,19 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
|
|
|
69
79
|
*/
|
|
70
80
|
export declare const AGENT_ACTION_ID = "submit";
|
|
71
81
|
export declare const DIRECT_ACTION_ID = "direct";
|
|
72
|
-
export type CreateFieldId = 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
|
|
82
|
+
export type CreateFieldId = 'project' | 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'submit' | 'direct';
|
|
73
83
|
/** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
|
|
74
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;
|
|
75
95
|
/**
|
|
76
96
|
* The ticket's title. `gaia_ticket.title` is `setRequired(TRUE)` server-side,
|
|
77
97
|
* so a direct create without one is rejected — this was never optional
|
|
@@ -117,7 +137,8 @@ export interface CreateFormPayload {
|
|
|
117
137
|
/** The option with `id`, or null — used by both forms' `toObject`. */
|
|
118
138
|
export declare const optionFor: (list: Option[], id: string | null) => Option | null;
|
|
119
139
|
/**
|
|
120
|
-
* 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
|
|
121
142
|
* `clearable` — the conductor (GAIA-309 D2); every other one cycles over real
|
|
122
143
|
* options only, exactly as before GAIA-233.
|
|
123
144
|
*/
|
|
@@ -129,8 +150,29 @@ export declare const CREATE_FORM: {
|
|
|
129
150
|
apply(s: FormState<CreateValues>, options: CreateFormOptions, intent: import("../../../Form/form.js").Intent): FormState<CreateValues>;
|
|
130
151
|
toObject: (s: FormState<CreateValues>, options: CreateFormOptions) => CreateFormPayload;
|
|
131
152
|
};
|
|
132
|
-
/**
|
|
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. */
|
|
133
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>[];
|
|
134
176
|
export type CreateFormState = FormState<CreateValues>;
|
|
135
177
|
/**
|
|
136
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,62 +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
|
-
f.multi('reviewers', 'Reviewers', { from: 'reviewers' }),
|
|
80
|
-
// Clearable (D2): a SET conductor means the ticket is claimed at the next
|
|
81
|
-
// poll and a qualification run starts; a cleared one means it waits for a
|
|
82
|
-
// human. The field is clearable precisely so that choice is the operator's
|
|
83
|
-
// and not implicit. The preselection survives — `initial()` fills a select
|
|
84
|
-
// from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
|
|
85
|
-
// `create-data.ts` puts the machine's own conductor there.
|
|
86
|
-
f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
|
|
87
|
-
f.multi('labels', 'Labels', { from: 'labels' }),
|
|
88
|
-
f.select('workflow', 'Workflow', { from: 'workflows' }),
|
|
89
|
-
// Agent row FIRST and keeping the id `'submit'` (D7): nothing that exists
|
|
90
|
-
// today changes its meaning or its position.
|
|
91
|
-
f.action('Create with agent', AGENT_ACTION_ID),
|
|
92
|
-
f.action('Create directly', DIRECT_ACTION_ID),
|
|
132
|
+
...sharedFields(f),
|
|
93
133
|
],
|
|
94
|
-
toObject:
|
|
95
|
-
title: v.title,
|
|
96
|
-
description: v.description,
|
|
97
|
-
workflow: optionFor(o.workflows, v.workflow),
|
|
98
|
-
assignee: optionFor(o.users, v.assignee),
|
|
99
|
-
reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
|
|
100
|
-
conductor: optionFor(o.conductors, v.conductor),
|
|
101
|
-
labels: o.labels.filter((x) => v.labels.has(x.id)),
|
|
102
|
-
parent: parentFor(o.parents ?? [], v.parent),
|
|
103
|
-
}),
|
|
134
|
+
toObject: toCreatePayload,
|
|
104
135
|
});
|
|
105
|
-
/** Field order in the popup (top to bottom). */
|
|
136
|
+
/** Field order in the popup (top to bottom), a project already in scope. */
|
|
106
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;
|
|
107
141
|
/** Snapshot the routing values of a form that is about to be replaced. */
|
|
108
142
|
export const rememberedValues = (s) => ({
|
|
109
143
|
parent: s.parent,
|
|
@@ -27,6 +27,17 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
|
|
|
27
27
|
private context;
|
|
28
28
|
private sources;
|
|
29
29
|
private optionLoader;
|
|
30
|
+
/**
|
|
31
|
+
* Which declared form is standing (GAIA-664 AC-8): the ordinary one when a
|
|
32
|
+
* project is already in scope (unchanged), or the one with the extra
|
|
33
|
+
* Project field when it is not. Set once per `load()` — a scope change goes
|
|
34
|
+
* through `reset()`, which drops the whole screen, not just this flag.
|
|
35
|
+
*/
|
|
36
|
+
private hasProjectContext;
|
|
37
|
+
/** The hints `load()` resolved, MINUS `projectId` — kept so a project picked
|
|
38
|
+
* in the new field can rebuild the Conductor source with the rest intact
|
|
39
|
+
* (GAIA-664 AC-8). */
|
|
40
|
+
private hints;
|
|
30
41
|
/** The last refusal, drawn inside the popup (GAIA-309 D6). */
|
|
31
42
|
private message;
|
|
32
43
|
/** In-flight guard against a second POST from a second keypress. */
|
|
@@ -39,6 +50,10 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
|
|
|
39
50
|
*/
|
|
40
51
|
private readonly ownerIdentity;
|
|
41
52
|
get options(): CreateFormOptions | undefined;
|
|
53
|
+
/** The declared form standing right now (GAIA-664 AC-8). */
|
|
54
|
+
private declaration;
|
|
55
|
+
/** `declaration()`'s own field list — what the component walks to render. */
|
|
56
|
+
private fieldList;
|
|
42
57
|
protected load(context: ScreenContext): Promise<CreateFormOptions>;
|
|
43
58
|
/**
|
|
44
59
|
* Reopening the popup starts a NEW ticket (GAIA-314 D1/D3).
|
|
@@ -57,6 +72,7 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
|
|
|
57
72
|
*/
|
|
58
73
|
enter(context: ScreenContext): void;
|
|
59
74
|
protected didLoad(options: CreateFormOptions): void;
|
|
75
|
+
private startOptionLoader;
|
|
60
76
|
protected renderLoading(): string[];
|
|
61
77
|
/**
|
|
62
78
|
* GAIA-340 D1/D3 — the popup's body as the tree the widget layer builds.
|
|
@@ -73,6 +89,14 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
|
|
|
73
89
|
protected renderReady(options: CreateFormOptions): VNode;
|
|
74
90
|
takeAction(): ScreenAction | undefined;
|
|
75
91
|
handleInput(input: string, context: ScreenContext): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Re-scope the project-dependent bags to the project just picked in the
|
|
94
|
+
* Project field (GAIA-664 AC-8): Conductor through the same source
|
|
95
|
+
* `createFormSources` declares, Parent by swapping in that project's parent
|
|
96
|
+
* source (the option loader reads `this.sources` at query time, so later
|
|
97
|
+
* typing searches the new project). No project: no conductor, no parent.
|
|
98
|
+
*/
|
|
99
|
+
private rescopeToProject;
|
|
76
100
|
reset(): void;
|
|
77
101
|
protected modalChrome(context: ScreenContext): ModalChrome;
|
|
78
102
|
protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
|
|
@@ -11,9 +11,10 @@ import { OwnerIdentity } from '../../conductor/Data/own-rows.js';
|
|
|
11
11
|
import { resolveWhoCandidate } from '../../user/Data/users.js';
|
|
12
12
|
import { CREATE_FORM_REGION, createFormTree, } from '../Component/create-form/index.js';
|
|
13
13
|
import { buildTicketCreateBody, createTicket } from '../Data/write.js';
|
|
14
|
-
import { AGENT_ACTION_ID, CREATE_FORM, DIRECT_ACTION_ID,
|
|
15
|
-
import { composeCreateFormOptions, createFormSources, } from '../Form/create-data.js';
|
|
14
|
+
import { AGENT_ACTION_ID, CREATE_FORM, CREATE_FORM_WITH_PROJECT, DIRECT_ACTION_ID, FIELDS, FIELDS_WITH_PROJECT, optionFor, rememberedValues, } from '../Form/create.js';
|
|
15
|
+
import { composeCreateFormOptions, conductorOptionSource, createFormSources, } from '../Form/create-data.js';
|
|
16
16
|
import { keyToIntent } from '../Form/keys.js';
|
|
17
|
+
import { parentOptionSource } from '../Form/parent-source.js';
|
|
17
18
|
// The key hints, on TWO rows (GAIA-309 D7 / AC-1).
|
|
18
19
|
//
|
|
19
20
|
// Not a style choice. The popup is a fixed overlay — the kernel draws it at
|
|
@@ -54,6 +55,17 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
54
55
|
context;
|
|
55
56
|
sources = {};
|
|
56
57
|
optionLoader;
|
|
58
|
+
/**
|
|
59
|
+
* Which declared form is standing (GAIA-664 AC-8): the ordinary one when a
|
|
60
|
+
* project is already in scope (unchanged), or the one with the extra
|
|
61
|
+
* Project field when it is not. Set once per `load()` — a scope change goes
|
|
62
|
+
* through `reset()`, which drops the whole screen, not just this flag.
|
|
63
|
+
*/
|
|
64
|
+
hasProjectContext = false;
|
|
65
|
+
/** The hints `load()` resolved, MINUS `projectId` — kept so a project picked
|
|
66
|
+
* in the new field can rebuild the Conductor source with the rest intact
|
|
67
|
+
* (GAIA-664 AC-8). */
|
|
68
|
+
hints = {};
|
|
57
69
|
/** The last refusal, drawn inside the popup (GAIA-309 D6). */
|
|
58
70
|
message = '';
|
|
59
71
|
/** In-flight guard against a second POST from a second keypress. */
|
|
@@ -68,6 +80,14 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
68
80
|
get options() {
|
|
69
81
|
return this.readyValue;
|
|
70
82
|
}
|
|
83
|
+
/** The declared form standing right now (GAIA-664 AC-8). */
|
|
84
|
+
declaration() {
|
|
85
|
+
return this.hasProjectContext ? CREATE_FORM : CREATE_FORM_WITH_PROJECT;
|
|
86
|
+
}
|
|
87
|
+
/** `declaration()`'s own field list — what the component walks to render. */
|
|
88
|
+
fieldList() {
|
|
89
|
+
return this.hasProjectContext ? FIELDS : FIELDS_WITH_PROJECT;
|
|
90
|
+
}
|
|
71
91
|
async load(context) {
|
|
72
92
|
this.context = context;
|
|
73
93
|
if (!context.services)
|
|
@@ -77,7 +97,8 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
77
97
|
// once per screen lifetime through `OwnerIdentity` rather than threaded
|
|
78
98
|
// straight through. Fails open: `unavailable` omits the hint entirely.
|
|
79
99
|
const ownerOutcome = await this.ownerIdentity.resolve(context.services.client, context.services.currentUser);
|
|
80
|
-
|
|
100
|
+
this.hasProjectContext = context.project !== undefined;
|
|
101
|
+
this.hints = {
|
|
81
102
|
...(context.services.currentUser === undefined
|
|
82
103
|
? {}
|
|
83
104
|
: { currentUser: context.services.currentUser }),
|
|
@@ -87,12 +108,15 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
87
108
|
...(context.services.ownConductorMachineId === undefined
|
|
88
109
|
? {}
|
|
89
110
|
: { ownConductorMachineId: context.services.ownConductorMachineId }),
|
|
90
|
-
...(context.project === undefined
|
|
91
|
-
? {}
|
|
92
|
-
: { projectId: context.project.id }),
|
|
93
111
|
...(context.services.workspaceId === undefined
|
|
94
112
|
? {}
|
|
95
113
|
: { workspaceId: context.services.workspaceId }),
|
|
114
|
+
};
|
|
115
|
+
const sources = createFormSources(context.services.client, {
|
|
116
|
+
...this.hints,
|
|
117
|
+
...(context.project === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { projectId: context.project.id }),
|
|
96
120
|
});
|
|
97
121
|
this.sources = sources;
|
|
98
122
|
return composeCreateFormOptions(sources).catch((caught) => ({
|
|
@@ -130,7 +154,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
130
154
|
// it through the very same rule.
|
|
131
155
|
const options = this.readyValue;
|
|
132
156
|
if (options) {
|
|
133
|
-
this.form =
|
|
157
|
+
this.form = this.declaration().initial(options, this.remembered ?? {});
|
|
134
158
|
// `super.enter`'s `ready` branch already painted — from the form we just
|
|
135
159
|
// replaced. The kernel draws SYNCHRONOUSLY inside `requestDraw`, so that
|
|
136
160
|
// paint is the only one this navigation issues, and without a second one
|
|
@@ -142,7 +166,11 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
142
166
|
}
|
|
143
167
|
}
|
|
144
168
|
didLoad(options) {
|
|
145
|
-
this.form =
|
|
169
|
+
this.form = this.declaration().initial(options, this.remembered ?? {});
|
|
170
|
+
this.startOptionLoader();
|
|
171
|
+
}
|
|
172
|
+
startOptionLoader() {
|
|
173
|
+
this.optionLoader?.cancel();
|
|
146
174
|
this.optionLoader = createOptionLoader({
|
|
147
175
|
sources: this.sources,
|
|
148
176
|
// Absent unless the caller injected one: `createOptionLoader` owns the
|
|
@@ -179,7 +207,9 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
179
207
|
// the standing tree rather than build a second one beside it.
|
|
180
208
|
if (!this.form)
|
|
181
209
|
return columnNode(CREATE_FORM_REGION);
|
|
182
|
-
return createFormTree(this.form, options, {
|
|
210
|
+
return createFormTree(this.fieldList(), this.form, options, {
|
|
211
|
+
message: this.message,
|
|
212
|
+
});
|
|
183
213
|
}
|
|
184
214
|
takeAction() {
|
|
185
215
|
const action = this.action;
|
|
@@ -192,14 +222,15 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
192
222
|
const options = this.readyValue;
|
|
193
223
|
if (!options || !this.form)
|
|
194
224
|
return false;
|
|
195
|
-
const
|
|
225
|
+
const declaration = this.declaration();
|
|
226
|
+
const field = declaration.activeField(this.form);
|
|
196
227
|
// GAIA-309 D7 — Ctrl+D is handled HERE rather than in `Form/keys.ts`. The
|
|
197
228
|
// kernel's one `submit` intent cannot say WHICH of two actions was meant,
|
|
198
229
|
// and a generic key module has no business knowing that a form has an
|
|
199
230
|
// "agent" action and a "direct" one. So the second binding belongs to the
|
|
200
231
|
// form that has two, and the kernel's Intent union is untouched.
|
|
201
232
|
if (input === 'ctrl+d') {
|
|
202
|
-
const payload =
|
|
233
|
+
const payload = declaration.toObject(this.form, options);
|
|
203
234
|
void this.submitDirect(payload, context);
|
|
204
235
|
context.requestDraw();
|
|
205
236
|
return true;
|
|
@@ -208,7 +239,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
208
239
|
// suggestion-browsing to single-field forms; this form has several, so
|
|
209
240
|
// ↑/↓ keep moving between fields here (AC-5).
|
|
210
241
|
const intent = keyToIntent(input, field, {
|
|
211
|
-
fieldCount:
|
|
242
|
+
fieldCount: declaration.fields.length,
|
|
212
243
|
});
|
|
213
244
|
if (!intent)
|
|
214
245
|
return true;
|
|
@@ -216,7 +247,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
216
247
|
this.action = { type: 'back' };
|
|
217
248
|
}
|
|
218
249
|
else if (intent.kind === 'submit') {
|
|
219
|
-
const payload =
|
|
250
|
+
const payload = declaration.toObject(this.form, options);
|
|
220
251
|
// Ctrl+S PRESSES THE ROW YOU ARE STANDING ON; from any other field it
|
|
221
252
|
// means the agent, exactly as it did before this ticket (AC-6). Enter on
|
|
222
253
|
// an action row arrives here as the same `submit` intent, so both keys
|
|
@@ -234,7 +265,29 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
234
265
|
// operator types the missing title and is still told the title is
|
|
235
266
|
// missing. Any field change retires it.
|
|
236
267
|
this.message = '';
|
|
237
|
-
|
|
268
|
+
// GAIA-664 AC-8: a NEW pick (or a clear) on the Project field re-scopes
|
|
269
|
+
// the Conductor bag and drops any conductor picked under the PREVIOUS
|
|
270
|
+
// project — the same staleness `parent`'s project already forbids,
|
|
271
|
+
// applied to the field that now decides it instead of receiving it.
|
|
272
|
+
const previousProject = field.id === 'project'
|
|
273
|
+
? this.form.project
|
|
274
|
+
: null;
|
|
275
|
+
this.form = declaration.apply(this.form, options, intent);
|
|
276
|
+
// Any change counts, typing included: editing the query drops the
|
|
277
|
+
// pick, and a conductor of the dropped project must go with it.
|
|
278
|
+
if (field.id === 'project') {
|
|
279
|
+
const nextProject = this.form
|
|
280
|
+
.project;
|
|
281
|
+
if (nextProject !== previousProject) {
|
|
282
|
+
this.form = {
|
|
283
|
+
...this.form,
|
|
284
|
+
conductor: null,
|
|
285
|
+
parent: null,
|
|
286
|
+
parentQuery: '',
|
|
287
|
+
};
|
|
288
|
+
void this.rescopeToProject(nextProject, context);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
238
291
|
if (field.query !== undefined &&
|
|
239
292
|
(intent.kind === 'insert' || intent.kind === 'backspace')) {
|
|
240
293
|
const query = this.form[field.query];
|
|
@@ -244,6 +297,57 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
244
297
|
context.requestDraw();
|
|
245
298
|
return true;
|
|
246
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Re-scope the project-dependent bags to the project just picked in the
|
|
302
|
+
* Project field (GAIA-664 AC-8): Conductor through the same source
|
|
303
|
+
* `createFormSources` declares, Parent by swapping in that project's parent
|
|
304
|
+
* source (the option loader reads `this.sources` at query time, so later
|
|
305
|
+
* typing searches the new project). No project: no conductor, no parent.
|
|
306
|
+
*/
|
|
307
|
+
async rescopeToProject(projectId, context) {
|
|
308
|
+
const client = context.services?.client;
|
|
309
|
+
if (!client)
|
|
310
|
+
return;
|
|
311
|
+
const { parent: _previous, ...rest } = this.sources;
|
|
312
|
+
this.sources =
|
|
313
|
+
projectId === null
|
|
314
|
+
? rest
|
|
315
|
+
: { ...rest, parent: parentOptionSource(client, projectId) };
|
|
316
|
+
this.startOptionLoader();
|
|
317
|
+
const conductorSource = conductorOptionSource(client, {
|
|
318
|
+
...this.hints,
|
|
319
|
+
...(projectId === null ? {} : { projectId }),
|
|
320
|
+
});
|
|
321
|
+
const [conductors, parents] = await Promise.all([
|
|
322
|
+
conductorSource.load(''),
|
|
323
|
+
this.sources.parent?.load(''),
|
|
324
|
+
]);
|
|
325
|
+
const current = this.readyValue;
|
|
326
|
+
if (!current || !context.isActive())
|
|
327
|
+
return;
|
|
328
|
+
// A later pick supersedes this load: a slow answer for the previous
|
|
329
|
+
// project must not replace the current project's candidates.
|
|
330
|
+
const picked = this.form
|
|
331
|
+
?.project ?? null;
|
|
332
|
+
if (picked !== projectId)
|
|
333
|
+
return;
|
|
334
|
+
// Errors merged like the option loader's own merge: a failed read shows
|
|
335
|
+
// its error instead of reading as "this project has none".
|
|
336
|
+
const errors = { ...current.errors };
|
|
337
|
+
delete errors.conductors;
|
|
338
|
+
delete errors.parents;
|
|
339
|
+
if (conductors.error !== undefined)
|
|
340
|
+
errors.conductors = conductors.error;
|
|
341
|
+
if (parents?.error !== undefined)
|
|
342
|
+
errors.parents = parents.error;
|
|
343
|
+
this.replaceReady({
|
|
344
|
+
...current,
|
|
345
|
+
conductors: conductors.options,
|
|
346
|
+
parents: parents?.options,
|
|
347
|
+
errors: Object.keys(errors).length > 0 ? errors : undefined,
|
|
348
|
+
});
|
|
349
|
+
context.requestDraw();
|
|
350
|
+
}
|
|
247
351
|
reset() {
|
|
248
352
|
this.form = undefined;
|
|
249
353
|
this.remembered = undefined;
|
|
@@ -254,6 +358,8 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
254
358
|
this.optionLoader?.cancel();
|
|
255
359
|
this.optionLoader = undefined;
|
|
256
360
|
this.sources = {};
|
|
361
|
+
this.hasProjectContext = false;
|
|
362
|
+
this.hints = {};
|
|
257
363
|
this.ownerIdentity.reset();
|
|
258
364
|
super.reset();
|
|
259
365
|
}
|
|
@@ -278,7 +384,7 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
278
384
|
}
|
|
279
385
|
fieldActions() {
|
|
280
386
|
return this.form &&
|
|
281
|
-
|
|
387
|
+
this.declaration().activeField(this.form).kind === 'autocomplete'
|
|
282
388
|
? [
|
|
283
389
|
actionItem('create-field', '↑↓', 'field'),
|
|
284
390
|
actionItem('create-suggest', '←→', 'suggestion'),
|
|
@@ -312,12 +418,17 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
312
418
|
// D8 — a project is a PRECONDITION, not a fallback. Without `project_id`
|
|
313
419
|
// the server writes a ticket that draws no number and gets no GAIA-nnn
|
|
314
420
|
// identifier (`GaiaTicket::allocateNumber()` returns early), which is worse
|
|
315
|
-
// than not writing one.
|
|
316
|
-
//
|
|
317
|
-
|
|
421
|
+
// than not writing one. GAIA-664 AC-8: with no project already in scope the
|
|
422
|
+
// popup shows a Project field instead of refusing outright, so the
|
|
423
|
+
// precondition is now "a project was picked", not "one happened to be in
|
|
424
|
+
// scope" — `context.project` when there was one, the form's own pick when
|
|
425
|
+
// there was not.
|
|
426
|
+
const projectId = context.project?.id ??
|
|
427
|
+
this.form
|
|
428
|
+
?.project ??
|
|
429
|
+
undefined;
|
|
318
430
|
if (!projectId) {
|
|
319
|
-
this.message =
|
|
320
|
-
'no project in scope — open a project to create directly, or hand it to the agent';
|
|
431
|
+
this.message = 'no project selected — pick one above to create directly';
|
|
321
432
|
context.requestDraw();
|
|
322
433
|
return;
|
|
323
434
|
}
|
|
@@ -377,7 +488,24 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
|
|
|
377
488
|
};
|
|
378
489
|
return;
|
|
379
490
|
}
|
|
380
|
-
|
|
491
|
+
// GAIA-664 review F4: a guessed project was a fake resolution, not a real
|
|
492
|
+
// one — the literal `gaia` fallback sent the agent into whatever project
|
|
493
|
+
// happened to carry that slug. GAIA-664 AC-8: with no project already in
|
|
494
|
+
// scope the popup offers a Project field instead, and a pick there is as
|
|
495
|
+
// real a resolution as `context.project` — checked before the legacy
|
|
496
|
+
// `context.options?.project` default, since an explicit pick says more
|
|
497
|
+
// than an ambient one.
|
|
498
|
+
const formProjectId = this.form?.project;
|
|
499
|
+
const pickedProject = formProjectId
|
|
500
|
+
? optionFor(this.readyValue?.projects ?? [], formProjectId)
|
|
501
|
+
: null;
|
|
502
|
+
const project = context.project?.slug ?? pickedProject?.label ?? context.options?.project;
|
|
503
|
+
if (!project) {
|
|
504
|
+
this.message =
|
|
505
|
+
'no project selected — pick one above to hand this to the agent';
|
|
506
|
+
context.requestDraw();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
381
509
|
let specPath;
|
|
382
510
|
try {
|
|
383
511
|
specPath = join(tmpdir(), `gaia-new-ticket-${Date.now()}.json`);
|
|
@@ -10,10 +10,9 @@ const INCLUDES = [
|
|
|
10
10
|
'conductor_id',
|
|
11
11
|
'assignee_user_id',
|
|
12
12
|
'comments',
|
|
13
|
-
// The Overview sidebar resolves references to names, not raw uuids: the
|
|
14
|
-
//
|
|
13
|
+
// The Overview sidebar resolves references to names, not raw uuids: the
|
|
14
|
+
// owner (Created by), and the sub-/referenced tickets in the Related tab.
|
|
15
15
|
// parent_id and blocked_by are already above.
|
|
16
|
-
'team',
|
|
17
16
|
'owner_user_id',
|
|
18
17
|
'sub_tickets',
|
|
19
18
|
'referenced_tickets',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaia-ai/addon-gaia-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"zustand": "^5.0.15"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@gaia-ai/core": "^0.
|
|
33
|
-
"@gaia-ai/message": "^0.
|
|
32
|
+
"@gaia-ai/core": "^0.20.0",
|
|
33
|
+
"@gaia-ai/message": "^0.20.0"
|
|
34
34
|
}
|
|
35
35
|
}
|