Skip to content

Commit c785740

Browse files
baozhoutaoclaude
andauthored
fix(detail): Attachments become a peer tab with a live count badge, and their copy is translated (objectstack#4358) (#3123)
* fix(detail,i18n): Attachments panel renders beside the discussion feed, and its copy is finally translated (objectstack#4358) Two defects on enable.files record detail pages: 1. Buried placement — RecordDetailView appended RecordAttachmentsPanel AFTER the schema-rendered page tree, whose synthesized default embeds record:discussion as the last main component, so the panel always sat below an ever-growing feed timeline with no metadata knob to move it. buildDefaultPageSchema now emits a footer grid row placing a new record:attachments node to the LEFT of the feed (1/3–2/3 on lg+, stacked attachments-first below). The node is a new app-shell registration wrapping the existing panel via RecordContext; an `attachments` slot + `hideAttachments` option cover slotted pages, and the legacy bottom append survives only as the fallback for authored pages without the node (hasExplicitAttachments). 2. Untranslated copy — the panel's eleven detail.* keys existed only as inline-English defaultValues; no locale bundle carried them. All ten locales now define them. Verified in the browser against a showcase backend: zh console renders 「附件 / 上传 / 暂无附件…」 in the left column with the discussion feed to its right on lg+, and stacks the panel above the feed on mobile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 752e18f commit c785740

20 files changed

Lines changed: 467 additions & 14 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/plugin-detail": patch
4+
"@object-ui/components": patch
5+
"@object-ui/i18n": patch
6+
---
7+
8+
fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358
9+
10+
Two defects on `enable.files: true` record detail pages:
11+
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.
17+
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`).
28+
29+
2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
30+
`uploadAttachment`, `loadingAttachments`, `noAttachments`,
31+
`downloadAttachment`, `deleteAttachment`, and the five
32+
`attachment*Denied/Required` friendly errors) existed only as inline
33+
English `defaultValue`s — no locale bundle carried them, so non-English
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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ slotted pages are the right tool.
3333
| `tabs` | The entire `page:tabs` node — use to add or reorder tabs (wins over `details`) |
3434
| `discussion` | `record:discussion` (the inline conversation footer) |
3535

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.
40+
3641
Each slot accepts a single component schema or an array (arrays are
3742
flattened in place). Each slot is a **full replacement at the slot
3843
boundary** — there is no deep-merge or JSON-Patch in v1.

packages/app-shell/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ import './console/home/CloudOnboardingNext';
235235
// SDUI widget: read-only admin diagnostic for the env's effective AI model
236236
// (cloud#797) — fetches GET /api/v1/ai/effective-model.
237237
import './console/diagnostics/CloudAiModelStatus';
238+
// `record:attachments` — schema-addressable Attachments panel referenced by
239+
// synthesized record pages when `enable.files: true` (objectstack#4358).
240+
import './views/record-attachments-renderer';
238241

239242
// Phase 3c — generic metadata admin engine. Re-exported so plugins
240243
// can call `registerMetadataResource()` to override the per-type

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { hasExplicitDiscussion } from '../pageSchemaIntrospect';
2+
import { hasExplicitDiscussion, hasExplicitAttachments } from '../pageSchemaIntrospect';
33

44
describe('hasExplicitDiscussion', () => {
55
it('returns false for nullish and primitive inputs', () => {
@@ -115,3 +115,56 @@ describe('hasExplicitDiscussion', () => {
115115
expect(hasExplicitDiscussion(a)).toBe(false);
116116
});
117117
});
118+
119+
// objectstack#4358 — the synthesized default page now places
120+
// `record:attachments` beside the discussion feed; RecordDetailView uses this
121+
// walker to skip its legacy bottom-of-page append.
122+
describe('hasExplicitAttachments', () => {
123+
it('returns false for nullish and primitive inputs', () => {
124+
expect(hasExplicitAttachments(null)).toBe(false);
125+
expect(hasExplicitAttachments(undefined)).toBe(false);
126+
expect(hasExplicitAttachments('record:attachments')).toBe(false);
127+
});
128+
129+
it('detects record:attachments at the root and nested in children', () => {
130+
expect(hasExplicitAttachments({ type: 'record:attachments' })).toBe(true);
131+
expect(
132+
hasExplicitAttachments({
133+
type: 'grid',
134+
children: [{ type: 'record:attachments' }, { type: 'record:discussion' }],
135+
}),
136+
).toBe(true);
137+
});
138+
139+
it('detects attachments inside the synthesized Attachments tab (regions[].components[].items[])', () => {
140+
// Mirrors buildDefaultPageSchema output for an enable.files object.
141+
const synthPage = {
142+
type: 'record',
143+
regions: [
144+
{
145+
name: 'main',
146+
components: [
147+
{ type: 'page:header' },
148+
{
149+
type: 'page:tabs',
150+
items: [
151+
{ label: 'Details', value: 'details', children: [{ type: 'record:details' }] },
152+
{ label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] },
153+
],
154+
},
155+
{ type: 'record:discussion' },
156+
],
157+
},
158+
],
159+
};
160+
expect(hasExplicitAttachments(synthPage)).toBe(true);
161+
});
162+
163+
it('returns false when no attachments node exists (discussion alone)', () => {
164+
expect(
165+
hasExplicitAttachments({
166+
regions: [{ components: [{ type: 'record:discussion' }] }],
167+
}),
168+
).toBe(false);
169+
});
170+
});

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
*/
99

1010
const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']);
11+
const ATTACHMENT_TYPES = new Set(['record:attachments']);
1112

1213
/**
13-
* Walks a page schema tree and returns true if any node has a
14-
* `type` of `record:discussion` or `record:chatter`.
14+
* Walks a page schema tree and returns true if any node's `type` is in
15+
* `types`.
1516
*
1617
* Recurses into:
1718
* - `children`, `items`, `body`, `components`
@@ -20,15 +21,15 @@ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']);
2021
*
2122
* Cycles are guarded with a WeakSet.
2223
*/
23-
export function hasExplicitDiscussion(root: unknown): boolean {
24+
function hasNodeOfType(root: unknown, types: ReadonlySet<string>): boolean {
2425
const seen = new WeakSet<object>();
2526
const walk = (node: any): boolean => {
2627
if (!node || typeof node !== 'object') return false;
2728
if (seen.has(node)) return false;
2829
seen.add(node);
2930
if (Array.isArray(node)) return node.some(walk);
3031
const t = node?.type;
31-
if (typeof t === 'string' && DISCUSSION_TYPES.has(t)) return true;
32+
if (typeof t === 'string' && types.has(t)) return true;
3233
const candidates: any[] = [
3334
node.children,
3435
node.items,
@@ -47,3 +48,21 @@ export function hasExplicitDiscussion(root: unknown): boolean {
4748
};
4849
return walk(root);
4950
}
51+
52+
/**
53+
* True when the page schema already places a `record:discussion` /
54+
* `record:chatter` node — the host must then skip its bottom auto-append.
55+
*/
56+
export function hasExplicitDiscussion(root: unknown): boolean {
57+
return hasNodeOfType(root, DISCUSSION_TYPES);
58+
}
59+
60+
/**
61+
* True when the page schema already places a `record:attachments` node —
62+
* the synthesized default does whenever the object declares
63+
* `enable.files: true` (objectstack#4358). The host must then skip its
64+
* legacy bottom-of-page attachments append.
65+
*/
66+
export function hasExplicitAttachments(root: unknown): boolean {
67+
return hasNodeOfType(root, ATTACHMENT_TYPES);
68+
}

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { SkeletonDetail } from '../skeletons';
2424
import { ManagedByBadge } from '../components/ManagedByBadge';
2525
import { resolveCrudAffordances } from '../utils/crudAffordances';
2626
import { deriveRelatedLists } from '../utils/deriveRelatedLists';
27-
import { hasExplicitDiscussion } from '../utils/pageSchemaIntrospect';
27+
import { hasExplicitDiscussion, hasExplicitAttachments } from '../utils/pageSchemaIntrospect';
2828
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
2929
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
3030
import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog';
@@ -1879,6 +1879,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18791879
// `enable.feeds: false` (#2707) suppresses the discussion panel outright —
18801880
// same opt-out contract the server enforces on sys_comment creation.
18811881
const showAutoDiscussion = !disableDiscussion && !hasDiscussion && feedsEnabled;
1882+
// Synthesized pages place `record:attachments` beside the discussion feed
1883+
// (objectstack#4358); the legacy bottom-of-page append below stays only as
1884+
// the fallback for authored pages that don't slot the panel themselves.
1885+
const hasAttachments = hasExplicitAttachments(effectivePage as any);
18821886

18831887
// System actions (Edit / Share / Delete) — synthesized for every record
18841888
// page so objects without authored record_header actions still surface
@@ -2112,8 +2116,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
21122116
)}
21132117
{/* Generic Attachments panel (#2727) — opt-in via
21142118
`enable.files: true`; the server rejects attachments
2115-
targeting any other object (403 FILES_DISABLED). */}
2116-
{filesEnabled && pureRecordId && (
2119+
targeting any other object (403 FILES_DISABLED).
2120+
Fallback only: synthesized pages already place a
2121+
`record:attachments` node beside the discussion feed
2122+
(objectstack#4358), so this bottom append fires only for
2123+
authored pages that omit the panel. */}
2124+
{filesEnabled && !hasAttachments && pureRecordId && (
21172125
<div className="mt-6">
21182126
<RecordAttachmentsPanel
21192127
objectName={objectName!}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `record:attachments` — schema-addressable wrapper around
11+
* `RecordAttachmentsPanel`, mirroring how `record:discussion` wraps
12+
* `RecordChatterPanel` (plugin-detail/renderers/record-chatter.tsx).
13+
*
14+
* Registered here (app-shell) rather than in plugin-detail because the panel
15+
* depends on `@object-ui/providers` (presigned upload adapter) and
16+
* `@object-ui/auth` (Bearer fetch), which plugin-detail deliberately does not
17+
* pull in. The side-effect registration is imported from the app-shell barrel
18+
* (`src/index.ts`), so any host that mounts the shell — the console runtime
19+
* AND Studio's PagePreview — resolves the type before a synthesized page
20+
* references it (objectstack#4358).
21+
*
22+
* Renders nothing without a record identity (e.g. a page composed outside a
23+
* RecordContext). A missing dataSource is tolerated: the panel just shows its
24+
* empty state — that is what Studio's PagePreview binds.
25+
*/
26+
27+
import * as React from 'react';
28+
import { ComponentRegistry } from '@object-ui/core';
29+
import { useRecordContext } from '@object-ui/react';
30+
import { useAuth } from '@object-ui/auth';
31+
import { RecordAttachmentsPanel } from './RecordAttachmentsPanel';
32+
33+
const splitDesigner = (props: Record<string, any>) => {
34+
const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {};
35+
return { designer: { 'data-obj-id': id, 'data-obj-type': type, style }, rest };
36+
};
37+
38+
export interface RecordAttachmentsRendererProps {
39+
schema?: Record<string, any>;
40+
className?: string;
41+
[k: string]: any;
42+
}
43+
44+
export const RecordAttachmentsRenderer: React.FC<RecordAttachmentsRendererProps> = ({
45+
schema: _schema,
46+
className,
47+
...props
48+
}) => {
49+
const ctx = useRecordContext();
50+
const { user } = useAuth();
51+
const { designer } = splitDesigner(props);
52+
53+
const objectName = ctx?.objectName;
54+
const recordId = ctx?.recordId != null ? String(ctx.recordId) : '';
55+
if (!objectName || !recordId) return null;
56+
57+
return (
58+
<div className={className} {...designer}>
59+
<RecordAttachmentsPanel
60+
objectName={objectName}
61+
recordId={recordId}
62+
dataSource={ctx?.dataSource as any}
63+
currentUserId={user?.id}
64+
/>
65+
</div>
66+
);
67+
};
68+
69+
ComponentRegistry.register('attachments', RecordAttachmentsRenderer, {
70+
namespace: 'record',
71+
skipFallback: true,
72+
category: 'record',
73+
label: 'Attachments',
74+
icon: 'Paperclip',
75+
inputs: [{ name: 'className', type: 'string', label: 'CSS Class' }],
76+
});
77+
78+
export default RecordAttachmentsRenderer;

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/i18n/src/locales/ar.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,18 @@ const ar = {
759759
attachmentCount: "{{count}} مرفق",
760760
attachmentCountPlural: "{{count}} مرفقات",
761761
removeAttachment: "إزالة المرفق",
762+
// Record Attachments panel (enable.files, objectstack#4358)
763+
attachments: "المرفقات",
764+
uploadAttachment: "رفع",
765+
loadingAttachments: "جارٍ تحميل المرفقات…",
766+
noAttachments: "لا توجد مرفقات بعد. ارفع ملفًا للبدء.",
767+
downloadAttachment: "تنزيل",
768+
deleteAttachment: "حذف المرفق",
769+
attachmentDeleteDenied: "لا يمكن حذف هذا المرفق إلا لمن رفعه أو لمن يمكنه تعديل هذا السجل.",
770+
attachmentParentAccessDenied: "ليس لديك صلاحية إرفاق ملفات بهذا السجل.",
771+
attachmentDownloadDenied: "ليس لديك صلاحية تنزيل هذا المرفق.",
772+
attachmentAuthRequired: "يرجى تسجيل الدخول لتنزيل هذا المرفق.",
773+
attachmentPermissionDenied: "ليس لديك إذن للقيام بذلك.",
762774
unifiedDiff: "عرض موحد",
763775
sideBySideDiff: "عرض جنباً إلى جنب",
764776
noChanges: "لا توجد تغييرات",

0 commit comments

Comments
 (0)