@bakers-build/addon-bakers-ui 0.1.2-next.93 → 0.2.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.
@@ -58,7 +58,7 @@ const WARNING_INDENT = 2;
58
58
  * silently left unwritten, so the accepted spelling has to be on screen. */
59
59
  const LEGEND = 'Assignee = human ownership · Reviewers = named reviewers · Conductor = who works it next · ' +
60
60
  'Estimate/Spent = accounted time, not the run footprint (1d 2h 30m, 90m, or a bare number as minutes) · ' +
61
- 'Parent = optional parent ticket · Labels = multi-select from bb_labels · ' +
61
+ 'Parent = optional parent ticket · Labels = multi-select from label · ' +
62
62
  'Cancel ticket abandons a non-terminal ticket (Esc closes and writes nothing)';
63
63
  /**
64
64
  * A block of chrome prose, wrapped so the frame never has to clip it.
@@ -43,12 +43,15 @@ function headerLineNodes(id, content, fg) {
43
43
  }));
44
44
  }
45
45
  export function ticketHeaderTree(document, identifier, title, lastRow, labels) {
46
- // One compact meta line — state · assignee · labels — under the title. The
46
+ // One compact meta line — state · assignee · type · recipe · labels — under
47
+ // the title. The
47
48
  // full set (created by, branch, …) lives in the Overview sidebar, so the
48
49
  // header stays two lines rather than five. Empty fields drop out.
49
50
  const meta = [
50
51
  headerState(document),
51
52
  headerAssignee(document, labels),
53
+ termName(document, 'ticket_type', 'bb_term--type'),
54
+ termName(document, 'recipe', 'bb_term--recipe'),
52
55
  headerLabels(document),
53
56
  ]
54
57
  .filter((part) => part !== '' && part !== '—')
@@ -513,7 +516,18 @@ function typedCommentLines(document, type) {
513
516
  }
514
517
  /** The `bb_term` label names of the ticket, resolved include → uuid (D4/D8). */
515
518
  function labelNames(document) {
516
- return toManyIds(document, 'labels').map((id) => asString(included(document, 'bb_term--bb_labels', id)?.attributes?.name) ?? id);
519
+ return toManyIds(document, 'labels').map((id) => asString(included(document, 'bb_term--label', id)?.attributes?.name) ??
520
+ id);
521
+ }
522
+ /**
523
+ * A single-valued `bb_term` relationship's name (BAKERS-17 `ticket_type` /
524
+ * `recipe`), resolved include → uuid like the labels; `''` when unset.
525
+ */
526
+ function termName(document, name, type) {
527
+ const id = relId(document, name);
528
+ if (id === null)
529
+ return '';
530
+ return asString(included(document, type, id)?.attributes?.name) ?? id;
517
531
  }
518
532
  /**
519
533
  * The ticket's labels as ONE row of text — the header box's labels row (AC-8) and
@@ -669,6 +683,15 @@ export function detailSections(document, labels, nowSeconds = 0, baseUrl) {
669
683
  heading: 'Labels',
670
684
  lines: labelNames(document).length ? labelNames(document) : ['No labels.'],
671
685
  });
686
+ // BAKERS-17: Type and Recipe sit beside Labels, for the same reason.
687
+ sections.push({
688
+ heading: 'Type',
689
+ lines: [termName(document, 'ticket_type', 'bb_term--type') || 'No type.'],
690
+ });
691
+ sections.push({
692
+ heading: 'Recipe',
693
+ lines: [termName(document, 'recipe', 'bb_term--recipe') || 'No recipe.'],
694
+ });
672
695
  // BB-678: the upstream ticket this one was imported from — above `Parent`,
673
696
  // matching the interactive Related pane's row order.
674
697
  sections.push({ heading: 'Source', lines: sourceLines(document) });
@@ -16,8 +16,10 @@ import type { DropshClient, TicketDetailDocument } from '../../../types.js';
16
16
  * BB-255 added `parent_id,labels` for the same reason: the Parent row (AC-3)
17
17
  * and the header box's labels row (AC-8) would otherwise be raw uuids. Both were
18
18
  * re-measured live on BB-255 — `labels` sideloads
19
- * `bb_term--bb_labels` carrying `name`, and `parent_id` is `{data: null}` on
20
- * a parentless ticket. The `runs` include already ships each run's FULL attribute
19
+ * `bb_term--label` carrying `name`, and `parent_id` is `{data: null}` on
20
+ * a parentless ticket. BAKERS-17 added `ticket_type,recipe` for the header's
21
+ * type and recipe names, sideloading `bb_term--type` / `bb_term--recipe`.
22
+ * The `runs` include already ships each run's FULL attribute
21
23
  * set, which is why the Runs tab and the run detail screen add no request at all.
22
24
  *
23
25
  * `parent_id` serves BB-252 as well: after a save from the ticket list, this
@@ -15,8 +15,10 @@
15
15
  * BB-255 added `parent_id,labels` for the same reason: the Parent row (AC-3)
16
16
  * and the header box's labels row (AC-8) would otherwise be raw uuids. Both were
17
17
  * re-measured live on BB-255 — `labels` sideloads
18
- * `bb_term--bb_labels` carrying `name`, and `parent_id` is `{data: null}` on
19
- * a parentless ticket. The `runs` include already ships each run's FULL attribute
18
+ * `bb_term--label` carrying `name`, and `parent_id` is `{data: null}` on
19
+ * a parentless ticket. BAKERS-17 added `ticket_type,recipe` for the header's
20
+ * type and recipe names, sideloading `bb_term--type` / `bb_term--recipe`.
21
+ * The `runs` include already ships each run's FULL attribute
20
22
  * set, which is why the Runs tab and the run detail screen add no request at all.
21
23
  *
22
24
  * `parent_id` serves BB-252 as well: after a save from the ticket list, this
@@ -52,6 +54,8 @@ export function loadTicketDetail(client, ticketId, includes = [
52
54
  'assignee_user_id',
53
55
  'parent_id',
54
56
  'labels',
57
+ 'ticket_type',
58
+ 'recipe',
55
59
  'blocked_by',
56
60
  'owner_user_id',
57
61
  'reviewers',
@@ -21,15 +21,14 @@ export { CLOSED_FILTER_VALUES, cycleValue } from '../../cycle.js';
21
21
  import { LIST_PAGE_SIZE as PAGE_SIZE } from '../../cycle.js';
22
22
  import { TICKET_CONDUCTOR_INCLUDE_FIELDS, TICKET_LIST_FIELDS, } from './tickets.js';
23
23
  // Ticket states from web/modules/custom/bb_core/bb_core.workflows.yml
24
- // (feature/bug/chore workflows) plus the import/backlog states seen on tickets.
24
+ // (the `bb_work` workflow) plus the import/backlog states seen on tickets.
25
25
  // Hardcoded so the cycle is deterministic and independent of the loaded page.
26
26
  export const STATE_FILTER_VALUES = [
27
27
  'triage',
28
28
  'backlog',
29
29
  'todo',
30
- 'spec',
31
- 'diagnose',
32
- 'coding',
30
+ 'plan',
31
+ 'working',
33
32
  'review',
34
33
  'done',
35
34
  'cancelled',
@@ -19,8 +19,11 @@ const ticketKey = (id) => dataKey(`ticket/${id}`);
19
19
  * `includes` (`ticket-detail.ts:23`) without joining this list. Every include
20
20
  * a screen's `includes` can name belongs here too.
21
21
  */
22
+ // What the header box reads on every tab (labels, and BAKERS-17's type and
23
+ // recipe), so every ticket view loads it whatever its own includes.
24
+ const headerIncludes = ['labels', 'ticket_type', 'recipe'];
22
25
  const allIncludes = [
23
- 'labels',
26
+ ...headerIncludes,
24
27
  'parent_id',
25
28
  'blocked_by',
26
29
  'conductor_id',
@@ -88,7 +91,9 @@ export function readTicket(context, fallback = {}) {
88
91
  }
89
92
  export function ticketLoaded(context, includes) {
90
93
  const id = context.params?.ticketId;
91
- return (!!id && (context.store?.has(ticketKey(id), [...includes, 'labels']) ?? true));
94
+ return (!!id &&
95
+ (context.store?.has(ticketKey(id), [...includes, ...headerIncludes]) ??
96
+ true));
92
97
  }
93
98
  export function loadTicketView(context, includes) {
94
99
  const supplied = context.contexts?.has(ticketDetailInputContext)
@@ -108,7 +113,7 @@ export function loadTicketView(context, includes) {
108
113
  const key = ticketKey(id);
109
114
  if (supplied && !store.get(key))
110
115
  store.set(key, supplied, allIncludes);
111
- return store.load(key, [...includes, 'labels'], async (missing) => {
116
+ return store.load(key, [...includes, ...headerIncludes], async (missing) => {
112
117
  if (!context.services) {
113
118
  if (supplied)
114
119
  return supplied;
@@ -7,9 +7,9 @@
7
7
  // The declaration stays because the `workflow` ATTRIBUTE stays (spec F4).
8
8
  /** The single source of truth, in the create form picker's order. */
9
9
  export const WORKFLOW_KINDS = [
10
- { id: 'bb_feature', label: 'feature' },
11
- { id: 'bb_bug', label: 'bug' },
12
- { id: 'bb_chore', label: 'chore' },
10
+ // BAKERS-17: one workflow for every work ticket; feature/bug/chore is the
11
+ // ticket's `ticket_type` term now, not its workflow.
12
+ { id: 'bb_work', label: 'work' },
13
13
  ];
14
14
  /** The picker's `{id, label}` list — what the create form's `workflow` field offers. */
15
15
  export const WORKFLOW_OPTIONS = WORKFLOW_KINDS.map((k) => ({
@@ -33,7 +33,7 @@ export declare const TICKET_DESCRIPTION_FORMAT = "bb_rich";
33
33
  * 1. NOTHING server-owned is written — no `state` (the server lands the
34
34
  * ticket in the workflow's first state), no `identifier`, no `number`, no
35
35
  * `branch_name`, no `base_branch`. Acceptance criterion 5 is satisfied by
36
- * OMISSION, which is also what `@bakers-build/create-ticket` does.
36
+ * OMISSION, which is also what `@bakers-build/bb-create-ticket` does.
37
37
  * 2. A relationship with nothing picked is OMITTED, not sent as an explicit
38
38
  * `null`. A create has nothing to unset, so `null` would carry no
39
39
  * information a missing key does not — the exact opposite of the PATCH in
@@ -42,7 +42,7 @@ const one = (type, id) => id ? { data: { type, id } } : undefined;
42
42
  * 1. NOTHING server-owned is written — no `state` (the server lands the
43
43
  * ticket in the workflow's first state), no `identifier`, no `number`, no
44
44
  * `branch_name`, no `base_branch`. Acceptance criterion 5 is satisfied by
45
- * OMISSION, which is also what `@bakers-build/create-ticket` does.
45
+ * OMISSION, which is also what `@bakers-build/bb-create-ticket` does.
46
46
  * 2. A relationship with nothing picked is OMITTED, not sent as an explicit
47
47
  * `null`. A create has nothing to unset, so `null` would carry no
48
48
  * information a missing key does not — the exact opposite of the PATCH in
@@ -61,10 +61,16 @@ export function buildTicketCreateBody(payload, projectId) {
61
61
  const conductor = one('bb_conductor--bb_conductor', payload.conductor?.id);
62
62
  if (conductor)
63
63
  relationships.conductor_id = conductor;
64
+ const ticketType = one('bb_term--type', payload.ticketType?.id);
65
+ if (ticketType)
66
+ relationships.ticket_type = ticketType;
67
+ const recipe = one('bb_term--recipe', payload.recipe?.id);
68
+ if (recipe)
69
+ relationships.recipe = recipe;
64
70
  if (payload.labels.length > 0) {
65
71
  relationships.labels = {
66
72
  data: payload.labels.map((l) => ({
67
- type: 'bb_term--bb_labels',
73
+ type: 'bb_term--label',
68
74
  id: l.id,
69
75
  })),
70
76
  };
@@ -49,7 +49,18 @@ const toOption = (row) => ({ id: row.id, label: row.label });
49
49
  * the data service returns terms for whatever purpose, and which of the entity
50
50
  * type's vocabularies a given widget wants is the widget's business.
51
51
  */
52
- const LABEL_VOCABULARY = 'bb_labels';
52
+ const LABEL_VOCABULARY = 'label';
53
+ /** The vocabularies of the single-valued `ticket_type` / `recipe` (BAKERS-17). */
54
+ const TYPE_VOCABULARY = 'type';
55
+ const RECIPE_VOCABULARY = 'recipe';
56
+ /** A vocabulary's terms as one option bag, under the bag key `bag`. */
57
+ const termSource = (client, bag, vocabulary) => ({
58
+ bag,
59
+ async load() {
60
+ const outcome = await listTerms(client, vocabulary);
61
+ return { options: outcome.rows.map(toOption), error: outcome.error };
62
+ },
63
+ });
53
64
  /**
54
65
  * The `conductors` option source for one (possibly absent) project.
55
66
  *
@@ -145,13 +156,9 @@ export function createFormSources(client, hints = {}) {
145
156
  ownerUserId,
146
157
  ownConductorMachineId,
147
158
  }),
148
- labels: {
149
- bag: 'labels',
150
- async load() {
151
- const outcome = await listTerms(client, LABEL_VOCABULARY);
152
- return { options: outcome.rows.map(toOption), error: outcome.error };
153
- },
154
- },
159
+ ticketType: termSource(client, 'ticketTypes', TYPE_VOCABULARY),
160
+ recipe: termSource(client, 'recipes', RECIPE_VOCABULARY),
161
+ labels: termSource(client, 'labels', LABEL_VOCABULARY),
155
162
  };
156
163
  // No project in scope ⇒ NO source, so the bag stays absent rather than empty:
157
164
  // "nothing can be offered" and "searched, nothing matched" are different
@@ -183,6 +190,8 @@ export async function composeCreateFormOptions(sources) {
183
190
  users: [],
184
191
  reviewers: [],
185
192
  conductors: [],
193
+ ticketTypes: [],
194
+ recipes: [],
186
195
  labels: [],
187
196
  workflows: WORKFLOW_OPTIONS,
188
197
  // Spread over the defaults, so a bag with no source stays ABSENT (D8) while
@@ -14,6 +14,8 @@ export interface CreateOptionErrors {
14
14
  users?: string | undefined;
15
15
  reviewers?: string | undefined;
16
16
  conductors?: string | undefined;
17
+ ticketTypes?: string | undefined;
18
+ recipes?: string | undefined;
17
19
  labels?: string | undefined;
18
20
  parents?: string | undefined;
19
21
  workflows?: string | undefined;
@@ -45,6 +47,10 @@ export interface CreateFormOptions {
45
47
  users: Option[];
46
48
  reviewers: Option[];
47
49
  conductors: Option[];
50
+ /** The `type` vocabulary, for the single-valued `ticket_type` (BAKERS-17). */
51
+ ticketTypes: Option[];
52
+ /** The `recipe` vocabulary, for the single-valued `recipe` (BAKERS-17). */
53
+ recipes: Option[];
48
54
  labels: Option[];
49
55
  workflows: Option[];
50
56
  /**
@@ -85,7 +91,7 @@ export type FieldDef<Id extends string = string> = Omit<FieldDecl, 'id'> & {
85
91
  */
86
92
  export declare const AGENT_ACTION_ID = "submit";
87
93
  export declare const DIRECT_ACTION_ID = "direct";
88
- export type CreateFieldId = 'project' | 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'labels' | 'workflow' | 'template' | 'submit' | 'direct';
94
+ export type CreateFieldId = 'project' | 'title' | 'description' | 'parent' | 'assignee' | 'reviewers' | 'conductor' | 'ticketType' | 'recipe' | 'labels' | 'workflow' | 'template' | 'submit' | 'direct';
89
95
  /** What the form HOLDS. `active`/`cursor` are the kernel's and must not appear. */
90
96
  export interface CreateValues {
91
97
  /**
@@ -114,6 +120,8 @@ export interface CreateValues {
114
120
  assignee: string | null;
115
121
  reviewers: ReadonlySet<string>;
116
122
  conductor: string | null;
123
+ ticketType: string | null;
124
+ recipe: string | null;
117
125
  labels: ReadonlySet<string>;
118
126
  workflow: string | null;
119
127
  /** The picked ticket template's uuid, or null. Never remembered. */
@@ -134,6 +142,8 @@ export interface CreateFormPayload {
134
142
  assignee: Option | null;
135
143
  reviewers: Option[];
136
144
  conductor: Option | null;
145
+ ticketType: Option | null;
146
+ recipe: Option | null;
137
147
  labels: Option[];
138
148
  /**
139
149
  * The ticket the new one goes under, or null when none was picked (AC-7).
@@ -146,9 +156,9 @@ export interface CreateFormPayload {
146
156
  export declare const optionFor: (list: Option[], id: string | null) => Option | null;
147
157
  /**
148
158
  * The declaration used whenever a project is already in scope (unchanged,
149
- * BB-664 AC-8). No field carries a function. Exactly ONE select is
150
- * `clearable` — the conductor (BB-309 D2); every other one cycles over real
151
- * options only, exactly as before BB-233.
159
+ * BB-664 AC-8). No field carries a function. The `clearable` selects are the
160
+ * conductor (BB-309 D2) and the template, type and recipe, which open empty;
161
+ * every other one cycles over real options only, exactly as before BB-233.
152
162
  */
153
163
  export declare const CREATE_FORM: {
154
164
  fields: FieldDecl[];
@@ -192,9 +202,10 @@ export type CreateFormState = FormState<CreateValues>;
192
202
  * value shape, so renaming a field breaks the build instead of silently
193
203
  * dropping it out of the memory.
194
204
  *
195
- * `workflow` is NOT here, deliberately. A ticket type is a per-ticket decision,
196
- * and a silently retained `bb_bug` on the next feature ticket is a wrong
197
- * value nobody looks at (D5). The parent contributes BOTH of its keys, because
205
+ * `workflow`, `ticketType` and `recipe` are NOT here, deliberately. They are
206
+ * per-ticket decisions, and a silently retained `bug` type on the next feature
207
+ * ticket is a wrong value nobody looks at (D5). The parent contributes BOTH
208
+ * of its keys, because
198
209
  * an autocomplete owns two and `apply` never produces one without the other
199
210
  * (D4).
200
211
  */
@@ -85,6 +85,10 @@ const sharedFields = (f) => [
85
85
  // from `listOf(options)[0]` regardless of `clearable`, and `ownFirst` in
86
86
  // `create-data.ts` puts the machine's own conductor there.
87
87
  f.select('conductor', 'Conductor', { from: 'conductors', clearable: true }),
88
+ // Clearable and opened EMPTY by the screen, like the template: a type or
89
+ // recipe is chosen, never preselected.
90
+ f.select('ticketType', 'Type', { from: 'ticketTypes', clearable: true }),
91
+ f.select('recipe', 'Recipe', { from: 'recipes', clearable: true }),
88
92
  f.multi('labels', 'Labels', { from: 'labels' }),
89
93
  f.select('workflow', 'Workflow', { from: 'workflows' }),
90
94
  // A template replaces the ordinary create, so it sits right above
@@ -103,14 +107,16 @@ const toCreatePayload = (v, o) => ({
103
107
  assignee: optionFor(o.users, v.assignee),
104
108
  reviewers: (o.reviewers ?? []).filter((x) => v.reviewers.has(x.id)),
105
109
  conductor: optionFor(o.conductors, v.conductor),
110
+ ticketType: optionFor(o.ticketTypes, v.ticketType),
111
+ recipe: optionFor(o.recipes, v.recipe),
106
112
  labels: o.labels.filter((x) => v.labels.has(x.id)),
107
113
  parent: parentFor(o.parents ?? [], v.parent),
108
114
  });
109
115
  /**
110
116
  * The declaration used whenever a project is already in scope (unchanged,
111
- * BB-664 AC-8). No field carries a function. Exactly ONE select is
112
- * `clearable` — the conductor (BB-309 D2); every other one cycles over real
113
- * options only, exactly as before BB-233.
117
+ * BB-664 AC-8). No field carries a function. The `clearable` selects are the
118
+ * conductor (BB-309 D2) and the template, type and recipe, which open empty;
119
+ * every other one cycles over real options only, exactly as before BB-233.
114
120
  */
115
121
  export const CREATE_FORM = declareForm({
116
122
  fields: sharedFields,
@@ -177,6 +183,8 @@ const NO_OPTIONS = {
177
183
  users: [],
178
184
  reviewers: [],
179
185
  conductors: [],
186
+ ticketTypes: [],
187
+ recipes: [],
180
188
  labels: [],
181
189
  workflows: [],
182
190
  };
@@ -4,7 +4,8 @@
4
4
  // this module knows both sides and therefore owns the mapping to `Option` and this
5
5
  // popup's policy: read write-client-first for users/conductors, drop Anonymous, filter
6
6
  // conductors to the ticket's project, put liveness in the label, load the
7
- // `bb_labels` vocabulary for the Labels multi-select, and reorder ONLY the
7
+ // `label` vocabulary for the Labels multi-select (and `type`/`recipe` for the
8
+ // Type and Recipe selects), and reorder ONLY the
8
9
  // conductor ring owner-first (BB-460) — the assignee ring is unordered because
9
10
  // the popup preselects the ticket's current assignee. Each rule has exactly one
10
11
  // user, so each is written once, here, beside the create adapter it differs from.
@@ -17,7 +18,10 @@ import { listUsersPreferringWrite } from '../../user/Data/users.js';
17
18
  import { ownFirst } from './create-data.js';
18
19
  import { loadReviewerOptions } from './reviewer-options.js';
19
20
  /** The vocabulary this popup offers as its labels (BB-360 D9 / D11). */
20
- const LABEL_VOCABULARY = 'bb_labels';
21
+ const LABEL_VOCABULARY = 'label';
22
+ /** The vocabularies of the single-valued `ticket_type` / `recipe` (BAKERS-17). */
23
+ const TYPE_VOCABULARY = 'type';
24
+ const RECIPE_VOCABULARY = 'recipe';
21
25
  /**
22
26
  * uid 0 is Drupal's Anonymous and never a valid human assignee. A row whose uid never
23
27
  * loaded is `undefined` — NOT Anonymous — and stays.
@@ -37,11 +41,11 @@ const withStatusSuffix = (c) => ({
37
41
  label: c.status ? `${c.label} (${c.status})` : c.label,
38
42
  });
39
43
  export async function loadEditFormOptions(clients, hints = {}) {
40
- // Labels have no write-preferring helper: terms are not write-gated the way
44
+ // Terms have no write-preferring helper: they are not write-gated the way
41
45
  // assignees/conductors are. Fail-open to [] when the read client is absent or
42
46
  // the vocabulary throws (D11).
43
- const labelsPromise = clients.read
44
- ? listTerms(clients.read, LABEL_VOCABULARY).then((outcome) => outcome.rows.map((t) => ({ id: t.id, label: t.label })), () => [])
47
+ const termsPromise = (vocabulary) => clients.read
48
+ ? listTerms(clients.read, vocabulary).then((outcome) => outcome.rows.map((t) => ({ id: t.id, label: t.label })), () => [])
45
49
  : Promise.resolve([]);
46
50
  const reviewerClient = clients.write ?? clients.read;
47
51
  const reviewersPromise = reviewerClient
@@ -50,16 +54,20 @@ export async function loadEditFormOptions(clients, hints = {}) {
50
54
  options: [],
51
55
  error: 'no client — reviewers cannot be loaded',
52
56
  });
53
- const [users, conductors, labels, reviewers] = await Promise.all([
57
+ const [users, conductors, ticketTypes, recipes, labels, reviewers] = await Promise.all([
54
58
  listUsersPreferringWrite(clients),
55
59
  listConductorsPreferringWrite(clients),
56
- labelsPromise,
60
+ termsPromise(TYPE_VOCABULARY),
61
+ termsPromise(RECIPE_VOCABULARY),
62
+ termsPromise(LABEL_VOCABULARY),
57
63
  reviewersPromise,
58
64
  ]);
59
65
  return {
60
66
  users: excludeAnonymous(users).map((u) => ({ id: u.id, label: u.label })),
61
67
  reviewers: reviewers.options,
62
68
  conductors: ownFirst(inProject(conductors, hints.projectId).filter((c) => c.active), (c) => hints.ownerUserId !== undefined && c.ownerUserId === hints.ownerUserId).map(withStatusSuffix),
69
+ ticketTypes,
70
+ recipes,
63
71
  labels,
64
72
  ...(reviewers.error === undefined
65
73
  ? {}
@@ -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' | 'reviewers' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'labels' | 'submit' | 'cancelTicket';
4
+ export type EditFieldId = 'assignee' | 'reviewers' | 'conductor' | 'estimate' | 'spent' | 'parent' | 'ticketType' | 'recipe' | 'labels' | 'submit' | 'cancelTicket';
5
5
  /**
6
6
  * BB-313 / BB-360 — the ONE lifecycle move this popup offers, as a named
7
7
  * constant.
@@ -11,10 +11,9 @@ export type EditFieldId = 'assignee' | 'reviewers' | 'conductor' | 'estimate' |
11
11
  * `cancelled`, so no served fact carries the cancel edge. The precedent is
12
12
  * `STATE_FILTER_VALUES` in `Data/query.ts`, hardcoded for the same reason.
13
13
  *
14
- * One constant serves every ticket workflow because in ALL of them
15
- * (`bb_feature`, `bb_bug`, `bb_chore`, `bb_deployment`,
16
- * `bb_verification`) `cancel.from` is exactly the set of non-terminal states —
17
- * so "non-terminal" and "the workflow permits cancelling" are the same
14
+ * One constant serves every ticket workflow because in the ticket workflow
15
+ * (`bb_work`) `cancel.from` is exactly the set of non-terminal states — so
16
+ * "non-terminal" and "the workflow permits cancelling" are the same
18
17
  * predicate, and no workflow id is needed to tell them apart.
19
18
  */
20
19
  export declare const CANCELLED_STATE = "cancelled";
@@ -29,7 +28,7 @@ export declare const CANCEL_TICKET_ACTION_ID = "cancelTicket";
29
28
  * The states no transition leaves, in every ticket workflow.
30
29
  *
31
30
  * The server's own definition (`TypeWorkflows::isTerminal` — "a state no
32
- * transition leaves") evaluates to exactly this pair for all five workflows.
31
+ * transition leaves") evaluates to exactly this pair for `bb_work`.
33
32
  */
34
33
  export declare const TERMINAL_TICKET_STATES: ReadonlySet<string>;
35
34
  /** The option lists the popup offers (loaded by `loadEditFormOptions`). */
@@ -37,6 +36,10 @@ export interface EditFormOptions {
37
36
  users: Option[];
38
37
  reviewers: Option[];
39
38
  conductors: Option[];
39
+ /** The `type` vocabulary, for the single-valued `ticket_type` (BAKERS-17). */
40
+ ticketTypes: Option[];
41
+ /** The `recipe` vocabulary, for the single-valued `recipe` (BAKERS-17). */
42
+ recipes: Option[];
40
43
  labels: Option[];
41
44
  errors?: {
42
45
  reviewers?: string | undefined;
@@ -63,6 +66,8 @@ interface EditValues {
63
66
  /** The PICKED parent's uuid, or null. `parentQuery` holds what was typed. */
64
67
  parent: string | null;
65
68
  parentQuery: string;
69
+ ticketType: string | null;
70
+ recipe: string | null;
66
71
  labels: ReadonlySet<string>;
67
72
  }
68
73
  /** The payload of the ordinary Save PATCH (BB-233 Decision 2). */
@@ -81,6 +86,18 @@ export interface EditFormPayload {
81
86
  id: string;
82
87
  } | null;
83
88
  };
89
+ ticket_type: {
90
+ data: {
91
+ type: string;
92
+ id: string;
93
+ } | null;
94
+ };
95
+ recipe: {
96
+ data: {
97
+ type: string;
98
+ id: string;
99
+ } | null;
100
+ };
84
101
  labels: {
85
102
  data: {
86
103
  type: string;
@@ -21,10 +21,9 @@ import { PARENT_PAGE_SIZE } from '../Data/parents.js';
21
21
  * `cancelled`, so no served fact carries the cancel edge. The precedent is
22
22
  * `STATE_FILTER_VALUES` in `Data/query.ts`, hardcoded for the same reason.
23
23
  *
24
- * One constant serves every ticket workflow because in ALL of them
25
- * (`bb_feature`, `bb_bug`, `bb_chore`, `bb_deployment`,
26
- * `bb_verification`) `cancel.from` is exactly the set of non-terminal states —
27
- * so "non-terminal" and "the workflow permits cancelling" are the same
24
+ * One constant serves every ticket workflow because in the ticket workflow
25
+ * (`bb_work`) `cancel.from` is exactly the set of non-terminal states — so
26
+ * "non-terminal" and "the workflow permits cancelling" are the same
28
27
  * predicate, and no workflow id is needed to tell them apart.
29
28
  */
30
29
  export const CANCELLED_STATE = 'cancelled';
@@ -39,7 +38,7 @@ export const CANCEL_TICKET_ACTION_ID = 'cancelTicket';
39
38
  * The states no transition leaves, in every ticket workflow.
40
39
  *
41
40
  * The server's own definition (`TypeWorkflows::isTerminal` — "a state no
42
- * transition leaves") evaluates to exactly this pair for all five workflows.
41
+ * transition leaves") evaluates to exactly this pair for `bb_work`.
43
42
  */
44
43
  export const TERMINAL_TICKET_STATES = new Set([
45
44
  'done',
@@ -51,7 +50,7 @@ const linkage = (type, id) => ({
51
50
  });
52
51
  /** Multi-value term linkage for labels (always present on ordinary Save). */
53
52
  const labelsLinkage = (ids) => ({
54
- data: [...ids].map((id) => ({ type: 'bb_term--bb_labels', id })),
53
+ data: [...ids].map((id) => ({ type: 'bb_term--label', id })),
55
54
  });
56
55
  /**
57
56
  * The two time attributes of the PATCH, in seconds.
@@ -89,7 +88,8 @@ const timeAttributes = (v) => {
89
88
  *
90
89
  * BOTH assignment selects are `clearable`: AC-4 requires REMOVING an assignment,
91
90
  * not only changing it, so `null` is a real position in each cycle. Parent is
92
- * clearable for the same reason (D7). Labels always PATCH the full selected set,
91
+ * clearable for the same reason (D7), and so are Type and Recipe. Labels
92
+ * always PATCH the full selected set,
93
93
  * including empty.
94
94
  *
95
95
  * Ordinary Save NEVER includes `attributes.state` — a self-transition is not a
@@ -119,6 +119,8 @@ const declareEditForm = (savable, cancellable) => declareForm({
119
119
  absentNote: '(no project context — open a project to pick a parent)',
120
120
  pageSize: PARENT_PAGE_SIZE,
121
121
  }),
122
+ f.select('ticketType', 'Type', { from: 'ticketTypes', clearable: true }),
123
+ f.select('recipe', 'Recipe', { from: 'recipes', clearable: true }),
122
124
  f.multi('labels', 'Labels', { from: 'labels' }),
123
125
  ...(savable ? [f.action('Save', 'submit')] : []),
124
126
  ...(cancellable
@@ -131,6 +133,8 @@ const declareEditForm = (savable, cancellable) => declareForm({
131
133
  relationships: {
132
134
  assignee_user_id: linkage('user--user', v.assignee),
133
135
  conductor_id: linkage('bb_conductor--bb_conductor', v.conductor),
136
+ ticket_type: linkage('bb_term--type', v.ticketType),
137
+ recipe: linkage('bb_term--recipe', v.recipe),
134
138
  labels: labelsLinkage(v.labels),
135
139
  parent_id: linkage('bb_ticket--bb_ticket', v.parent),
136
140
  ...(v.reviewersLoaded || v.reviewers.size > 0
@@ -191,6 +195,8 @@ export const EMPTY_EDIT_FORM_OPTIONS = {
191
195
  users: [],
192
196
  reviewers: [],
193
197
  conductors: [],
198
+ ticketTypes: [],
199
+ recipes: [],
194
200
  labels: [],
195
201
  };
196
202
  const relId = (document, name) => {
@@ -266,6 +272,8 @@ export function initialEditForm(document) {
266
272
  spent: timeValue(document, 'time_spent'),
267
273
  parent,
268
274
  parentQuery: parentQueryFromDocument(document, parent),
275
+ ticketType: relId(document, 'ticket_type'),
276
+ recipe: relId(document, 'recipe'),
269
277
  labels: labelIds(document),
270
278
  });
271
279
  }
@@ -73,8 +73,9 @@ export declare class CreateTicketFormScreen extends AsyncModalScreen<CreateFormO
73
73
  enter(context: ScreenContext): void;
74
74
  protected didLoad(options: CreateFormOptions): void;
75
75
  /**
76
- * A new ticket's form. The template is forced to none: a select opens on its
77
- * first option, and a template must be chosen, never preselected.
76
+ * A new ticket's form. The template, type and recipe are forced to none: a
77
+ * select opens on its first option, and each must be chosen, never
78
+ * preselected.
78
79
  */
79
80
  private freshForm;
80
81
  /** The template picked in the form, or null. */
@@ -124,6 +124,8 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
124
124
  users: [],
125
125
  reviewers: [],
126
126
  conductors: [],
127
+ ticketTypes: [],
128
+ recipes: [],
127
129
  labels: [],
128
130
  workflows: [],
129
131
  errors: {
@@ -171,13 +173,16 @@ export class CreateTicketFormScreen extends AsyncModalScreen {
171
173
  this.startOptionLoader();
172
174
  }
173
175
  /**
174
- * A new ticket's form. The template is forced to none: a select opens on its
175
- * first option, and a template must be chosen, never preselected.
176
+ * A new ticket's form. The template, type and recipe are forced to none: a
177
+ * select opens on its first option, and each must be chosen, never
178
+ * preselected.
176
179
  */
177
180
  freshForm(options) {
178
181
  return this.declaration().initial(options, {
179
182
  ...(this.remembered ?? {}),
180
183
  template: null,
184
+ ticketType: null,
185
+ recipe: null,
181
186
  });
182
187
  }
183
188
  /** The template picked in the form, or null. */
@@ -5,6 +5,8 @@ import { TicketContentScreen } from './ticket-content-screen.js';
5
5
  export { ticketDetailInputContext } from '../Data/input.js';
6
6
  const INCLUDES = [
7
7
  'labels',
8
+ 'ticket_type',
9
+ 'recipe',
8
10
  'parent_id',
9
11
  'blocked_by',
10
12
  'conductor_id',
@@ -66,9 +66,8 @@ export class TicketFilterBar {
66
66
  'triage',
67
67
  'backlog',
68
68
  'todo',
69
- 'spec',
70
- 'diagnose',
71
- 'coding',
69
+ 'plan',
70
+ 'working',
72
71
  'review',
73
72
  'done',
74
73
  'cancelled',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-bakers-ui",
3
- "version": "0.1.2-next.93",
3
+ "version": "0.2.0",
4
4
  "description": "bakers.build 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
- "@bakers-build/core": "^0.1.2-next.93",
33
- "@bakers-build/message": "^0.1.2-next.93"
32
+ "@bakers-build/core": "^0.2.0",
33
+ "@bakers-build/message": "^0.2.0"
34
34
  }
35
35
  }