Skip to content

Commit 352eb64

Browse files
baozhoutaoclaude
andcommitted
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>
1 parent 912496d commit 352eb64

19 files changed

Lines changed: 471 additions & 14 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
"@object-ui/plugin-detail": patch
4+
"@object-ui/i18n": patch
5+
---
6+
7+
fix(detail): the record Attachments panel renders beside the discussion feed and its copy is translated — objectstack#4358
8+
9+
Two defects on `enable.files: true` record detail pages:
10+
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.
16+
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`).
24+
25+
2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
26+
`uploadAttachment`, `loadingAttachments`, `noAttachments`,
27+
`downloadAttachment`, `deleteAttachment`, and the five
28+
`attachment*Denied/Required` friendly errors) existed only as inline
29+
English `defaultValue`s — no locale bundle carried them, so non-English
30+
consoles always showed English. All ten locales now define them.

content/docs/guide/slotted-pages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ 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`) |
3536

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

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: 55 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,57 @@ 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 footer grid (regions[].components[])', () => {
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+
{ type: 'page:tabs', items: [{ type: 'page:tab', children: [] }] },
149+
{
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' },
155+
],
156+
},
157+
],
158+
},
159+
],
160+
};
161+
expect(hasExplicitAttachments(synthPage)).toBe(true);
162+
});
163+
164+
it('returns false when no attachments node exists (discussion alone)', () => {
165+
expect(
166+
hasExplicitAttachments({
167+
regions: [{ components: [{ type: 'record:discussion' }] }],
168+
}),
169+
).toBe(false);
170+
});
171+
});

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';
@@ -1873,6 +1873,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18731873
// `enable.feeds: false` (#2707) suppresses the discussion panel outright —
18741874
// same opt-out contract the server enforces on sys_comment creation.
18751875
const showAutoDiscussion = !disableDiscussion && !hasDiscussion && feedsEnabled;
1876+
// Synthesized pages place `record:attachments` beside the discussion feed
1877+
// (objectstack#4358); the legacy bottom-of-page append below stays only as
1878+
// the fallback for authored pages that don't slot the panel themselves.
1879+
const hasAttachments = hasExplicitAttachments(effectivePage as any);
18761880

18771881
// System actions (Edit / Share / Delete) — synthesized for every record
18781882
// page so objects without authored record_header actions still surface
@@ -2106,8 +2110,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
21062110
)}
21072111
{/* Generic Attachments panel (#2727) — opt-in via
21082112
`enable.files: true`; the server rejects attachments
2109-
targeting any other object (403 FILES_DISABLED). */}
2110-
{filesEnabled && pureRecordId && (
2113+
targeting any other object (403 FILES_DISABLED).
2114+
Fallback only: synthesized pages already place a
2115+
`record:attachments` node beside the discussion feed
2116+
(objectstack#4358), so this bottom append fires only for
2117+
authored pages that omit the panel. */}
2118+
{filesEnabled && !hasAttachments && pureRecordId && (
21112119
<div className="mt-6">
21122120
<RecordAttachmentsPanel
21132121
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/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: "لا توجد تغييرات",

packages/i18n/src/locales/de.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,18 @@ const de = {
757757
attachmentCount: "{{count}} Anhang",
758758
attachmentCountPlural: "{{count}} Anhänge",
759759
removeAttachment: "Anhang entfernen",
760+
// Record Attachments panel (enable.files, objectstack#4358)
761+
attachments: "Anhänge",
762+
uploadAttachment: "Hochladen",
763+
loadingAttachments: "Anhänge werden geladen…",
764+
noAttachments: "Noch keine Anhänge. Laden Sie eine Datei hoch, um zu beginnen.",
765+
downloadAttachment: "Herunterladen",
766+
deleteAttachment: "Anhang löschen",
767+
attachmentDeleteDenied: "Nur die hochladende Person oder jemand mit Bearbeitungsrecht für diesen Datensatz darf diesen Anhang löschen.",
768+
attachmentParentAccessDenied: "Sie sind nicht berechtigt, Dateien an diesen Datensatz anzuhängen.",
769+
attachmentDownloadDenied: "Sie sind nicht berechtigt, diesen Anhang herunterzuladen.",
770+
attachmentAuthRequired: "Bitte melden Sie sich an, um diesen Anhang herunterzuladen.",
771+
attachmentPermissionDenied: "Sie haben keine Berechtigung für diese Aktion.",
760772
unifiedDiff: "Einheitliche Ansicht",
761773
sideBySideDiff: "Nebeneinander-Ansicht",
762774
noChanges: "Keine Änderungen",

packages/i18n/src/locales/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,18 @@ const en = {
817817
attachmentCount: '{{count}} attachment',
818818
attachmentCountPlural: '{{count}} attachments',
819819
removeAttachment: 'Remove attachment',
820+
// Record Attachments panel (enable.files, objectstack#4358)
821+
attachments: 'Attachments',
822+
uploadAttachment: 'Upload',
823+
loadingAttachments: 'Loading attachments…',
824+
noAttachments: 'No attachments yet. Upload a file to get started.',
825+
downloadAttachment: 'Download',
826+
deleteAttachment: 'Delete attachment',
827+
attachmentDeleteDenied: 'Only the uploader or someone who can edit this record may delete this attachment.',
828+
attachmentParentAccessDenied: "You don't have access to attach files to this record.",
829+
attachmentDownloadDenied: "You don't have access to download this attachment.",
830+
attachmentAuthRequired: 'Please sign in to download this attachment.',
831+
attachmentPermissionDenied: "You don't have permission to do that.",
820832
// Diff
821833
unifiedDiff: 'Unified diff',
822834
sideBySideDiff: 'Side-by-side diff',

0 commit comments

Comments
 (0)