Skip to content

Commit 2f23aea

Browse files
baozhoutaoclaude
andcommitted
fix(detail): Attachments move from footer widget to a peer tab with a live count badge (objectstack#4358)
Rework of the previous commit's side-by-side footer after design review: the narrow attachments card next to a tall feed read as a broken layout. - buildDefaultTabs emits an Attachments tab (record:attachments) between Related and Activity/History for enable.files objects; hideAttachments suppresses it. The footer grid, the attachments slot and toFooterCell are gone — the discussion footer is back to its original shape. - PageTabsRenderer derives the tab badge from a sys_attachment probe scoped to (parent_object, parent_id): the probe wrapper injects the two-key filter the RelatedCountStore shape can't express, while the synthetic relationshipField keeps the cache key unique and the store's existing sys_attachment invalidation (MutationEvent bridge) updates the badge on upload/delete. - Tab label rides the existing KNOWN_LABEL_DICT (→ 附件). Verified in the browser against a showcase backend (authored page assignment temporarily detached, since showcase's demo project page overrides the tabs slot): tab strip renders 详情 / 相关 1 / 附件 1, the panel fills the tab, and the badge reflects a created+deleted sys_attachment row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 352eb64 commit 2f23aea

6 files changed

Lines changed: 132 additions & 136 deletions

File tree

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,35 @@
11
---
22
"@object-ui/app-shell": patch
33
"@object-ui/plugin-detail": patch
4+
"@object-ui/components": patch
45
"@object-ui/i18n": patch
56
---
67

7-
fix(detail): the record Attachments panel renders beside the discussion feed and its copy is translated — objectstack#4358
8+
fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358
89

910
Two defects on `enable.files: true` record detail pages:
1011

11-
1. **Buried placement.** `RecordAttachmentsPanel` was appended by
12-
`RecordDetailView` AFTER the schema-rendered page tree, whose synthesized
13-
default embeds `record:discussion` as the last main component — so the
14-
panel always landed below an ever-growing feed timeline, undiscoverable
15-
without scrolling to the very bottom, with no metadata knob to move it.
12+
1. **Buried placement.** `RecordDetailView` appended `RecordAttachmentsPanel`
13+
AFTER the schema-rendered page tree, whose synthesized default embeds
14+
`record:discussion` as the last main component — so the panel always
15+
landed below an ever-growing feed timeline, undiscoverable without
16+
scrolling to the very bottom, with no metadata knob to move it.
1617

17-
`buildDefaultPageSchema` now emits a footer grid row placing a new
18-
`record:attachments` node to the LEFT of the discussion feed (1/3–2/3 on
19-
`lg+`, stacked attachments-first below). The node is rendered by a new
20-
app-shell registration wrapping the existing panel via RecordContext; a
21-
new `attachments` slot and `hideAttachments` option cover slotted pages,
22-
and RecordDetailView keeps its bottom append only as a fallback for
23-
authored pages that omit the node (detected via `hasExplicitAttachments`).
18+
`buildDefaultTabs` now emits a peer **Attachments** tab (a new
19+
`record:attachments` node rendered by an app-shell registration wrapping
20+
the existing panel via RecordContext) between Related and
21+
Activity/History. `PageTabsRenderer` derives the tab's count badge from a
22+
`sys_attachment` probe scoped to `(parent_object, parent_id)`, riding the
23+
same RelatedCountStore cache/invalidation bus as related-list badges — so
24+
uploads and deletes update the badge live. A `hideAttachments` synthesizer
25+
option suppresses the tab; RecordDetailView keeps its legacy bottom append
26+
only as the fallback for authored pages without the node
27+
(`hasExplicitAttachments`).
2428

2529
2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
2630
`uploadAttachment`, `loadingAttachments`, `noAttachments`,
2731
`downloadAttachment`, `deleteAttachment`, and the five
2832
`attachment*Denied/Required` friendly errors) existed only as inline
2933
English `defaultValue`s — no locale bundle carried them, so non-English
30-
consoles always showed English. All ten locales now define them.
34+
consoles always showed English. All ten locales now define them; the tab
35+
label rides the existing well-known-label dictionary (→ 附件 etc.).

content/docs/guide/slotted-pages.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ slotted pages are the right tool.
3232
| `details` | The Details tab body (other tabs stay synthesized) |
3333
| `tabs` | The entire `page:tabs` node — use to add or reorder tabs (wins over `details`) |
3434
| `discussion` | `record:discussion` (the inline conversation footer) |
35-
| `attachments` | `record:attachments` (the Attachments panel; synthesized to the left of the discussion footer for objects with `enable.files: true`) |
35+
36+
Objects with `enable.files: true` also get a synthesized **Attachments** tab
37+
(`record:attachments`, with a count badge) beside Details/Related. It is not a
38+
slot of its own — override `tabs` to reshape it, or pass
39+
`hideAttachments: true` to the synthesizer to drop it.
3640

3741
Each slot accepts a single component schema or an array (arrays are
3842
flattened in place). Each slot is a **full replacement at the slot

packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('hasExplicitAttachments', () => {
136136
).toBe(true);
137137
});
138138

139-
it('detects attachments inside the synthesized footer grid (regions[].components[])', () => {
139+
it('detects attachments inside the synthesized Attachments tab (regions[].components[].items[])', () => {
140140
// Mirrors buildDefaultPageSchema output for an enable.files object.
141141
const synthPage = {
142142
type: 'record',
@@ -145,15 +145,14 @@ describe('hasExplicitAttachments', () => {
145145
name: 'main',
146146
components: [
147147
{ type: 'page:header' },
148-
{ type: 'page:tabs', items: [{ type: 'page:tab', children: [] }] },
149148
{
150-
type: 'grid',
151-
columns: { xs: 1, lg: 3 },
152-
children: [
153-
{ type: 'record:attachments', className: 'lg:col-span-1' },
154-
{ type: 'record:discussion', className: 'lg:col-span-2' },
149+
type: 'page:tabs',
150+
items: [
151+
{ label: 'Details', value: 'details', children: [{ type: 'record:details' }] },
152+
{ label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] },
155153
],
156154
},
155+
{ type: 'record:discussion' },
157156
],
158157
},
159158
],

packages/components/src/renderers/layout/containers.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,27 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => {
308308
return acc;
309309
};
310310

311+
/**
312+
* Walk a tab's children (depth-first) and return true when a
313+
* `record:attachments` node is present. The Attachments tab
314+
* (objectstack#4358) derives its badge from a `sys_attachment` count scoped
315+
* by `(parent_object, parent_id)` — a two-key filter the related-list probe
316+
* shape can't express, so it gets its own detection + probe path below.
317+
*/
318+
const containsAttachmentsNode = (nodes: any): boolean => {
319+
if (!nodes) return false;
320+
const list = Array.isArray(nodes) ? nodes : [nodes];
321+
for (const n of list) {
322+
if (!n || typeof n !== 'object') continue;
323+
if (n.type === 'record:attachments') return true;
324+
const candidates = [n.children, n.properties?.children, n.properties?.items, n.body, n.items];
325+
for (const c of candidates) {
326+
if (c && containsAttachmentsNode(c)) return true;
327+
}
328+
}
329+
return false;
330+
};
331+
311332
const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
312333
const { designer } = splitDesignerProps(props);
313334
const { language } = useObjectTranslation();
@@ -377,23 +398,32 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
377398
// Snapshot which tabs (index → derived (objectName, relationshipField))
378399
// need a count probe. Cached per items reference so we don't re-walk on
379400
// every render.
401+
const recordObject: string | undefined = ctx?.objectName;
380402
const probeTargets = React.useMemo(() => {
381-
const out = new Map<number, Array<{ objectName: string; relationshipField?: string }>>();
403+
const out = new Map<number, Array<{ objectName: string; relationshipField?: string; attachments?: boolean }>>();
382404
items.forEach((it, idx) => {
383405
if (it.count !== undefined && it.count !== null && it.count !== '') return;
384406
const lists = collectRelatedLists((it as any).children);
385-
const probes: Array<{ objectName: string; relationshipField?: string }> = [];
407+
const probes: Array<{ objectName: string; relationshipField?: string; attachments?: boolean }> = [];
386408
for (const rl of lists) {
387409
const objectName: string | undefined = rl?.properties?.objectName || rl?.objectName;
388410
if (!objectName) continue;
389411
const relationshipField: string | undefined =
390412
rl?.properties?.relationshipField || rl?.relationshipField;
391413
probes.push({ objectName, relationshipField });
392414
}
415+
// Attachments tab (objectstack#4358): count sys_attachment rows scoped
416+
// to this record. The synthetic `relationshipField` below is only a
417+
// cache-key discriminator — the actual filter is injected by the probe
418+
// wrapper in the effect, since the store's single-key filter shape
419+
// can't express `(parent_object, parent_id)`.
420+
if (recordObject && containsAttachmentsNode((it as any).children)) {
421+
probes.push({ objectName: 'sys_attachment', relationshipField: `attachments:${recordObject}`, attachments: true });
422+
}
393423
if (probes.length > 0) out.set(idx, probes);
394424
});
395425
return out;
396-
}, [items]);
426+
}, [items, recordObject]);
397427

398428
React.useEffect(() => {
399429
if (!ds || typeof ds.find !== 'function') return;
@@ -403,8 +433,19 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
403433
for (const probe of probes) {
404434
// RelatedCountStore.fetch is internally deduplicated, so concurrent
405435
// mounts of multiple tab strips don't generate redundant requests.
436+
// The attachments probe overrides the store-built single-key filter
437+
// with the two-key `(parent_object, parent_id)` scope; the synthetic
438+
// relationshipField keeps the cache key unique, and the store's
439+
// `sys_attachment` invalidation (data-change bus) still hits it.
440+
const finder = probe.attachments
441+
? (object: string, query: any) =>
442+
ds.find(object, {
443+
...query,
444+
$filter: { parent_object: recordObject, parent_id: parentId },
445+
})
446+
: (object: string, query: any) => ds.find(object, query);
406447
void RelatedCountStore.fetch(
407-
(object, query) => ds.find(object, query),
448+
finder,
408449
probe.objectName,
409450
probe.relationshipField,
410451
parentId,

packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -728,74 +728,57 @@ describe('buildDefaultPageSchema', () => {
728728
});
729729
});
730730

731-
// objectstack#4358 — `enable.files` objects get the Attachments panel to
732-
// the LEFT of the discussion feed instead of the legacy below-the-feed
733-
// append that a growing timeline buried.
734-
describe('attachments footer row (enable.files, objectstack#4358)', () => {
731+
// objectstack#4358 — `enable.files` objects get a peer Attachments tab
732+
// (with a count badge derived by PageTabsRenderer) instead of the legacy
733+
// below-the-feed append that a growing timeline buried.
734+
describe('attachments tab (enable.files, objectstack#4358)', () => {
735735
const filesDef: ObjectDefLike = { ...leadDef, enable: { files: true } };
736736

737737
it('no enable.files → no record:attachments anywhere', () => {
738738
const page = buildDefaultPageSchema(leadDef);
739739
expect(JSON.stringify(page)).not.toContain('record:attachments');
740740
});
741741

742-
it('enable.files → footer grid with attachments LEFT of discussion', () => {
742+
it('enable.files → tabs carry an Attachments tab wrapping record:attachments', () => {
743743
const page = buildDefaultPageSchema(filesDef);
744+
const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs');
745+
const tab = tabs.items.find((t: any) => t.value === 'attachments');
746+
expect(tab).toBeDefined();
747+
expect(tab.label).toBe('Attachments');
748+
expect(tab.children).toEqual([{ type: 'record:attachments' }]);
749+
// The discussion footer is untouched — attachments are a tab, not a
750+
// footer widget.
744751
const components = page.regions[0].components;
745-
const footer = components[components.length - 1];
746-
expect(footer.type).toBe('grid');
747-
expect(footer.columns).toEqual({ xs: 1, lg: 3 });
748-
expect(footer.children.map((c: any) => c.type)).toEqual([
749-
'record:attachments',
750-
'record:discussion',
751-
]);
752-
// Attachments takes the narrow column, discussion the wide one.
753-
expect(footer.children[0].className).toContain('lg:col-span-1');
754-
expect(footer.children[1].className).toContain('lg:col-span-2');
755-
// The bare record:discussion node is gone from the top level.
756-
expect(components.map((c: any) => c.type)).not.toContain('record:discussion');
752+
expect(components[components.length - 1].type).toBe('record:discussion');
757753
});
758754

759-
it('enable.files + hideDiscussion → full-width attachments, no grid', () => {
760-
const page = buildDefaultPageSchema(filesDef, { hideDiscussion: true });
761-
const components = page.regions[0].components;
762-
const last = components[components.length - 1];
763-
expect(last).toEqual({ type: 'record:attachments' });
755+
it('the Attachments tab sits after Related and before Activity/History', () => {
756+
const tabs = buildDefaultTabs(filesDef, {
757+
related: [{ objectName: 'task', relationshipField: 'lead_id' }],
758+
showActivity: true,
759+
history: { entries: [], loading: false },
760+
});
761+
expect(tabs.items.map((t: any) => t.value)).toEqual([
762+
'details',
763+
'related',
764+
'attachments',
765+
'activity',
766+
'history',
767+
]);
764768
});
765769

766-
it('hideAttachments suppresses the panel and restores the plain discussion footer', () => {
770+
it('hideAttachments suppresses the tab', () => {
767771
const page = buildDefaultPageSchema(filesDef, { hideAttachments: true });
768-
const components = page.regions[0].components;
769-
expect(components[components.length - 1].type).toBe('record:discussion');
770772
expect(JSON.stringify(page)).not.toContain('record:attachments');
771773
});
772774

773-
it('attachments slot overrides the default node inside the grid cell', () => {
774-
const page = buildDefaultPageSchema(filesDef, {
775-
slots: { attachments: { type: 'div', id: 'custom-attachments' } },
776-
});
777-
const footer = page.regions[0].components[page.regions[0].components.length - 1];
778-
expect(footer.type).toBe('grid');
779-
expect(footer.children[0].id).toBe('custom-attachments');
780-
expect(footer.children[0].className).toContain('lg:col-span-1');
781-
});
782-
783-
it('a multi-node discussion slot is wrapped into ONE grid cell', () => {
775+
it('a details slot override keeps the Attachments tab', () => {
784776
const page = buildDefaultPageSchema(filesDef, {
785-
slots: {
786-
discussion: [
787-
{ type: 'div', id: 'd1' },
788-
{ type: 'div', id: 'd2' },
789-
],
790-
},
777+
slots: { details: { type: 'div', id: 'custom-details' } },
791778
});
792-
const footer = page.regions[0].components[page.regions[0].components.length - 1];
793-
expect(footer.type).toBe('grid');
794-
expect(footer.children).toHaveLength(2);
795-
const cell = footer.children[1];
796-
expect(cell.type).toBe('flex');
797-
expect(cell.className).toContain('lg:col-span-2');
798-
expect(cell.children.map((c: any) => c.id)).toEqual(['d1', 'd2']);
779+
const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs');
780+
expect(tabs.items.some((t: any) => t.value === 'attachments')).toBe(true);
781+
expect(tabs.items[0].children[0].id).toBe('custom-details');
799782
});
800783
});
801784
});

0 commit comments

Comments
 (0)