Skip to content

Commit 6b17724

Browse files
committed
fix(showcase): the task record page draws each action once
The Task Detail quick-actions bar carried Mark Done, Log Time and Archive, which its page header already draws, so the page showed the same buttons twice. The bar now carries the task's record_section set alone (Recalculate Estimate, which declares record_section only), and the three header actions drop record_section. Every record location keeps a stock host; the platform checklist's action-location matrix is re-pointed to match. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvWX6aMbbnnsKtDC5k
1 parent 1a32595 commit 6b17724

4 files changed

Lines changed: 70 additions & 20 deletions

File tree

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2056,7 +2056,7 @@
20562056
"title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section — and each dispatches for real",
20572057
"since": "v15",
20582058
"status": "active",
2059-
"revision": 17,
2059+
"revision": 18,
20602060
"priority": "P1",
20612061
"surface": "browser",
20622062
"personas": [
@@ -2065,7 +2065,7 @@
20652065
"fixtures": {
20662066
"app": "showcase",
20672067
"requires": [
2068-
"the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, a declarative write on the default script route — operation 'update', patch {done: true, progress: 100} — visible 'has(record.done) && record.done != true'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — declares record_more ONLY, which is what keeps it off the toolbar; objectui#3142), showcase_new_task (locations: [] — the fleet's headless specimen, read by the empty-locations probe), showcase_reopen_task (list_item only, operation 'update' patch {done: false}, visible 'has(record.done) && record.done == true' — Mark Done's mirror, so a done task's row menu offers Reopen where a not-done one offers Mark Done; a click on it is refused by the showcase_guard_task_reopen hook by design, which records-forms.script-action-hook-refusal-toast drives)",
2068+
"the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header, a declarative write on the default script route — operation 'update', patch {done: true, progress: 100} — visible 'has(record.done) && record.done != true'), showcase_log_time (record_header+record_related, form), showcase_open_docs (record_more, url), showcase_recalc_estimate (record_section only, api — the Task Detail quick-actions bar's action), showcase_recalc_selection (record_more, api — declares record_more ONLY, which is what keeps it off the toolbar; objectui#3142), showcase_new_task (locations: [] — the fleet's headless specimen, read by the empty-locations probe), showcase_reopen_task (list_item only, operation 'update' patch {done: false}, visible 'has(record.done) && record.done == true' — Mark Done's mirror, so a done task's row menu offers Reopen where a not-done one offers Mark Done; a click on it is refused by the showcase_guard_task_reopen hook by design, which records-forms.script-action-hook-refusal-toast drives)",
20692069
"seeded tasks in both done and not-done states for the CEL visibility both-sides check"
20702070
],
20712071
"provisioning": {
@@ -2074,7 +2074,7 @@
20742074
},
20752075
"knownGaps": [
20762076
"NO PIN COVERS A FULL CLAUSE — `automated` was dropped at revision 13, so this P1 item is hand-driven (Tier 2). The two objectui live specs it cited are adjacent: e2e/live/list-row-action-cel.spec.ts opens the first task row's menu and asserts only that at most one Mark Done renders (not both sides of acceptance[3], nor its fail-closed half), and e2e/live/action-modal.spec.ts only that the host's row Edit opens and closes a modal (one dispatch kind of acceptance[2], on no declared action). Tier 1 run #24598 scored every clause not-run on them. Re-add `automated` only with a pin that asserts a full clause.",
2077-
"[CLOSED by #22030 (PR #22036, 60ccda5a24), lifted by #22039 — the gap text is rewritten to say where each location now lives] a stock showcase boot hosts all six locations on showcase_task, including the three this gap used to block. record_header and record_more: Task Detail (showcase_task_detail, the record page the console opens for any task), whose first node is a page:header. That header renders the action ids it lists, each placed by its own locations, followed by the host's Edit / Share / Delete. Inline (record_header) on a desktop viewport, where the header's default inline budget is 3 (1 on mobile; a record_header action past the budget folds into ⋯): showcase_mark_done (script, shown only while the task is not done), showcase_log_time (form) and showcase_archive_task (script, disabled until the task is done). Under the ⋯ overflow (record_more without record_header): showcase_open_docs (url), showcase_recalc_estimate (api) and showcase_recalc_selection (api). The Task Detail quick-actions bar (record_section) names its actions explicitly (actionNames: mark_done, log_time and archive_task, in examples/app-showcase/src/ui/pages/task-detail.page.ts), so showcase_recalc_estimate, which declares record_section too, is absent there by page authoring, not a missing placement (run #25817). record_related: Project Detail (showcase_project_detail, the record page for any project) → the Related tab (?tab=related), which holds the Tasks related list, a record:related_list for showcase_task through its project FK; open a project that has tasks. It is an authored node, not the synthesizer's strip: the page's tabs override still replaces that strip, and this tab puts the list back with no actions key, so its rows carry showcase_log_time beside the task's list_item actions, and its header renders the list_toolbar action (Reassign…), as in 18.1 (run #25817): that list's own toolbar placement, not an extra one. The Tasks tab beside it is the record:line_items grid, which hosts no record_related action. An expected action missing from one of these hosts is a missing placement, never blocked(fixture). The hosts were read from objectui source at pin a58626c88dc8 and from the served metadata, not from a browser render, so the first run against them is their first browser evidence",
2077+
"[CLOSED by #22030 (PR #22036, 60ccda5a24), lifted by #22039 — the gap text is rewritten to say where each location now lives] a stock showcase boot hosts all six locations on showcase_task, including the three this gap used to block. record_header and record_more: Task Detail (showcase_task_detail, the record page the console opens for any task), whose first node is a page:header. That header renders the action ids it lists, each placed by its own locations, followed by the host's Edit / Share / Delete. Inline (record_header) on a desktop viewport, where the header's default inline budget is 3 (1 on mobile; a record_header action past the budget folds into ⋯): showcase_mark_done (script, shown only while the task is not done), showcase_log_time (form) and showcase_archive_task (script, disabled until the task is done). Under the ⋯ overflow (record_more without record_header): showcase_open_docs (url) and showcase_recalc_selection (api). The Task Detail quick-actions bar (record_section) names the task's record_section set, showcase_recalc_estimate (api), in examples/app-showcase/src/ui/pages/task-detail.page.ts; no action the header draws is also in the bar, so each action renders once on the page (#27520 row 9). record_related: Project Detail (showcase_project_detail, the record page for any project) → the Related tab (?tab=related), which holds the Tasks related list, a record:related_list for showcase_task through its project FK; open a project that has tasks. It is an authored node, not the synthesizer's strip: the page's tabs override still replaces that strip, and this tab puts the list back with no actions key, so its rows carry showcase_log_time beside the task's list_item actions, and its header renders the list_toolbar action (Reassign…), as in 18.1 (run #25817): that list's own toolbar placement, not an extra one. The Tasks tab beside it is the record:line_items grid, which hosts no record_related action. An expected action missing from one of these hosts is a missing placement, never blocked(fixture). The hosts were read from objectui source at pin a58626c88dc8 and from the served metadata, not from a browser render, so the first run against them is their first browser evidence",
20782078
"CLOSED by the qaact-placement-specimen recipe (#26625) for acceptance[4] half (a) and acceptance[3]'s fail-closed half. A runtime-authored action is still not joined into the served object's actions[] (QA run #23163 A4: its positive control, the same probe with a location, rendered nowhere), and authoring refuses a throwing `visible` (QA run #26456), so ⛔ do not author either probe at runtime. The recipe installs both specimens through the package loader, whose actions the served object does carry. Score half (a) on qaact_no_locations and the fail-closed half on qaact_visible_throws, each only on a surface where the recipe's qaact_control renders: the list row menu and the Project Detail Related-tab row menus. Task Detail's header, ⋯ overflow and quick-actions bar list their action ids, so the control is absent there and they are no evidence for either half (the recipe's knownGaps[0]). Half (b) stays on showcase_new_task as before."
20792079
]
20802080
},
@@ -2084,7 +2084,7 @@
20842084
"record_header — showcase_mark_done / showcase_log_time in the detail title bar",
20852085
"record_more — showcase_open_docs / showcase_recalc_selection under the ⋯ overflow",
20862086
"record_related — showcase_log_time on each row of the related-list section inside a record (the Tasks related list on a showcase_project record), and on no row of the showcase_task list view",
2087-
"record_section — showcase_mark_done / showcase_log_time in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)",
2087+
"record_section — showcase_recalc_estimate in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)",
20882088
"empty-locations semantics probe — an action with NO locations key renders on NO surface, and `locations: []` likewise (objectui#3142 collapsed the renderers onto one membership predicate, `actionRendersAt`; placement is a declaration, which is why recalc_selection has to name record_more)"
20892089
],
20902090
"steps": [
@@ -2247,6 +2247,12 @@
22472247
"date": "2026-10-31",
22482248
"change": "fixtures.requires[0], steps[5] and acceptance[3]'s clause name Mark Done's real predicate and list showcase_reopen_task. From QA run #27441 (W3-C3b, at c4c515ad; wave anchor #27377, wave-3 accuracy slice S1). showcase_mark_done's `visible` has been 'has(record.done) && record.done != true' since #8990 (has() guards the sparse list_item face, and != true never faults on a projected null), not '!record.done', and it is a declarative operation 'update' write on the default script route. showcase_reopen_task, the hook-refusal specimen, is its list_item-only mirror ('has(record.done) && record.done == true'), so a done task's row menu shows Reopen where the not-done one shows Mark Done; steps[5] says not to count it. Read at base a0f865491d: examples/app-showcase/src/ui/actions/index.ts. The both-sides contract is unchanged",
22492249
"ref": "#27441"
2250+
},
2251+
{
2252+
"revision": 18,
2253+
"date": "2026-11-01",
2254+
"change": "The Task Detail page draws each task action once (#27520 row 9: the walkthrough saw Mark Done, Log Time and Archive both in the header and as a button row in the body). The quick-actions bar now carries the task's record_section set alone, showcase_recalc_estimate, which declares record_section only, and Mark Done, Log Time and Archive drop record_section. fixtures.requires[0], the record_section variant and knownGaps[1] re-pointed: record_section is still hosted on showcase_task, by Recalculate Estimate in the bar, and the ⋯ overflow keeps open_docs (url) and recalc_selection (api). Read from the examples source at this branch, not a browser run; no clause changed",
2255+
"ref": "#27520"
22502256
}
22512257
],
22522258
"enumSource": {

‎examples/app-showcase/src/ui/actions/index.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,9 @@ export const MarkDoneAction = defineAction({
9090
// still reads an unset `done` as "not done". `has()` alone is therefore the
9191
// whole guard here. Rule: `packages/objectql/src/declared-fields.ts`.
9292
visible: 'has(record.done) && record.done != true',
93-
// `record_section` so the Task Detail page's `record:quick_actions` bar
94-
// (which names this action) resolves it — the engine location-filters even
95-
// explicitly-named actions, mirroring the platform's own sys-user pages.
96-
locations: ['list_item', 'record_header', 'record_section'],
93+
// No `record_section`: the Task Detail header already draws it, and its
94+
// quick-actions bar carries only what the header does not (one action row).
95+
locations: ['list_item', 'record_header'],
9796
refreshAfter: true,
9897
});
9998

@@ -190,7 +189,11 @@ export const RecalcEstimateAction = defineAction({
190189
execution: 'perRecord',
191190
// Names the task (see MarkDoneAction's note on the hole and its fallback).
192191
successMessage: "Estimate recalculated for {{ record.title | default:'the task' }}.",
193-
locations: ['record_more', 'record_section'],
192+
// `record_section` only: the Task Detail page's `record:quick_actions` bar
193+
// names it, and the engine location-filters even explicitly-named actions.
194+
// Not `record_more` too, so the page draws it once, in that bar, and not
195+
// again under the header's ⋯ overflow.
196+
locations: ['record_section'],
194197
// The endpoint is record-scoped and rejects a body without an id. On a
195198
// record surface the page supplies it; invoked from a LIST ROW (see the
196199
// `legacy_row_actions` view) nothing else can, so name the body key the
@@ -268,8 +271,7 @@ export const LogTimeAction = defineAction({
268271
// implicitly claims the `default` key), so pointing a form action there opens
269272
// a list as a form — now a build error (#2554).
270273
target: 'showcase_task.form',
271-
// `record_section` so it surfaces in the Task Detail quick-actions bar too.
272-
locations: ['record_header', 'record_related', 'record_section'],
274+
locations: ['record_header', 'record_related'],
273275
refreshAfter: true,
274276
});
275277

@@ -501,7 +503,7 @@ export const ArchiveTaskAction = defineAction({
501503
// "Not available for this record", which names neither. Display only; it
502504
// never gates the dispatch.
503505
disabledReason: 'Mark the task done first.',
504-
locations: ['record_header', 'record_section'],
506+
locations: ['record_header'],
505507
refreshAfter: false,
506508
});
507509

‎examples/app-showcase/src/ui/pages/task-detail.page.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { definePage } from '@objectstack/spec/ui';
1313
* • `record:alert` — a conditional banner shown only while the task is
1414
* In Review (demonstrates the ADR-0089 component-node
1515
* `visibleWhen` predicate, `has()`-guarded).
16-
* • `record:quick_actions` — object Actions surfaced as inline buttons.
16+
* • `record:quick_actions` — object Actions surfaced as inline buttons: the
17+
* task's `record_section` set, which the header
18+
* does not draw, so each action shows once.
1719
* • `record:highlights` + `record:details` — the standard compact + section
1820
* layout.
1921
* `kind: 'full'` — this page fully owns the record layout, as the Project
@@ -57,9 +59,8 @@ export const TaskDetailPage = definePage({
5759
'showcase_mark_done',
5860
'showcase_log_time',
5961
'showcase_archive_task',
60-
// record_more only — the ⋯ overflow (url, api, api).
62+
// record_more only — the ⋯ overflow (url, api).
6163
'showcase_open_docs',
62-
'showcase_recalc_estimate',
6364
'showcase_recalc_selection',
6465
],
6566
},
@@ -110,10 +111,10 @@ export const TaskDetailPage = definePage({
110111
properties: {
111112
location: 'record_section',
112113
align: 'start',
113-
// showcase_archive_task is the `disabled`-predicate specimen:
114-
// visible on every task, greyed until `record.done` (contrast with
115-
// showcase_mark_done's `visible`, which HIDES once done).
116-
actionNames: ['showcase_mark_done', 'showcase_log_time', 'showcase_archive_task'],
114+
// The task's `record_section` set, and nothing the header above
115+
// draws: one action row per action, never the same buttons twice.
116+
// Mark Done, Log Time and Archive live in the header only.
117+
actionNames: ['showcase_recalc_estimate'],
117118
},
118119
},
119120
{

‎examples/app-showcase/test/record-action-location-hosts.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { PageHeaderProps, RecordRelatedListProps } from '@objectstack/spec/ui';
4+
import { PageHeaderProps, RecordQuickActionsProps, RecordRelatedListProps } from '@objectstack/spec/ui';
55

66
import stack from '../objectstack.config.js';
77
import { Task } from '../src/data/objects/task.object.js';
@@ -157,4 +157,45 @@ describe('showcase_task record action locations have a stock host (#22030)', ()
157157
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
158158
});
159159
});
160+
161+
describe('record_section — the Task Detail quick-actions bar (objectstack#27520 row 9)', () => {
162+
const bars = taskPage ? allComponents(taskPage).filter((c) => c.type === 'record:quick_actions') : [];
163+
const names = (bars[0]?.properties?.actionNames ?? []) as unknown[];
164+
const headerIds = (taskPage
165+
? (allComponents(taskPage).find((c) => c.type === 'page:header')?.properties?.actions ?? [])
166+
: []) as unknown[];
167+
168+
it('composes one quick-actions bar at record_section that parses against the spec row', () => {
169+
expect(bars).toHaveLength(1);
170+
expect(bars[0]?.properties?.location).toBe('record_section');
171+
const result = RecordQuickActionsProps.safeParse(bars[0]?.properties);
172+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
173+
});
174+
175+
it('names the task\'s whole record_section set, each declaring record_section', () => {
176+
expect(names.length).toBeGreaterThan(0);
177+
const sectionSet = taskActions.filter(declares('record_section')).map((a) => a.name as string);
178+
expect([...names].sort()).toEqual([...sectionSet].sort());
179+
});
180+
181+
it('draws each action once: nothing the bar names is also drawn by the header', () => {
182+
// Control: both lists are populated, so an empty overlap is a reading.
183+
expect(names.length).toBeGreaterThan(0);
184+
expect(headerIds.length).toBeGreaterThan(0);
185+
expect(names.filter((n) => headerIds.includes(n))).toEqual([]);
186+
// And by declaration: no task action is placed both in the header's
187+
// locations and in the bar's, so a synthesized page cannot double it either.
188+
const doubled = taskActions
189+
.filter(declares('record_section'))
190+
.filter((a) => declares('record_header')(a) || declares('record_more')(a))
191+
.map((a) => a.name);
192+
expect(doubled).toEqual([]);
193+
});
194+
});
195+
196+
it('every record location keeps a stock host for a task action', () => {
197+
for (const location of ['record_header', 'record_more', 'record_related', 'record_section']) {
198+
expect(taskActions.filter(declares(location)).length, location).toBeGreaterThan(0);
199+
}
200+
});
160201
});

0 commit comments

Comments
 (0)