diff --git a/.changeset/attachments-beside-discussion-and-i18n.md b/.changeset/attachments-beside-discussion-and-i18n.md new file mode 100644 index 0000000000..78ef4f1898 --- /dev/null +++ b/.changeset/attachments-beside-discussion-and-i18n.md @@ -0,0 +1,35 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/plugin-detail": patch +"@object-ui/components": patch +"@object-ui/i18n": patch +--- + +fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358 + +Two defects on `enable.files: true` 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 + landed below an ever-growing feed timeline, undiscoverable without + scrolling to the very bottom, with no metadata knob to move it. + + `buildDefaultTabs` now emits a peer **Attachments** tab (a new + `record:attachments` node rendered by an app-shell registration wrapping + the existing panel via RecordContext) between Related and + Activity/History. `PageTabsRenderer` derives the tab's count badge from a + `sys_attachment` probe scoped to `(parent_object, parent_id)`, riding the + same RelatedCountStore cache/invalidation bus as related-list badges — so + uploads and deletes update the badge live. A `hideAttachments` synthesizer + option suppresses the tab; RecordDetailView keeps its legacy bottom append + only as the fallback for authored pages without the node + (`hasExplicitAttachments`). + +2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`, + `uploadAttachment`, `loadingAttachments`, `noAttachments`, + `downloadAttachment`, `deleteAttachment`, and the five + `attachment*Denied/Required` friendly errors) existed only as inline + English `defaultValue`s — no locale bundle carried them, so non-English + consoles always showed English. All ten locales now define them; the tab + label rides the existing well-known-label dictionary (→ 附件 etc.). diff --git a/content/docs/guide/slotted-pages.md b/content/docs/guide/slotted-pages.md index 918bee0516..04e65b78d1 100644 --- a/content/docs/guide/slotted-pages.md +++ b/content/docs/guide/slotted-pages.md @@ -33,6 +33,11 @@ slotted pages are the right tool. | `tabs` | The entire `page:tabs` node — use to add or reorder tabs (wins over `details`) | | `discussion` | `record:discussion` (the inline conversation footer) | +Objects with `enable.files: true` also get a synthesized **Attachments** tab +(`record:attachments`, with a count badge) beside Details/Related. It is not a +slot of its own — override `tabs` to reshape it, or pass +`hideAttachments: true` to the synthesizer to drop it. + Each slot accepts a single component schema or an array (arrays are flattened in place). Each slot is a **full replacement at the slot boundary** — there is no deep-merge or JSON-Patch in v1. diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 4c0ec8f9a7..e3f4ca5b22 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -235,6 +235,9 @@ import './console/home/CloudOnboardingNext'; // SDUI widget: read-only admin diagnostic for the env's effective AI model // (cloud#797) — fetches GET /api/v1/ai/effective-model. import './console/diagnostics/CloudAiModelStatus'; +// `record:attachments` — schema-addressable Attachments panel referenced by +// synthesized record pages when `enable.files: true` (objectstack#4358). +import './views/record-attachments-renderer'; // Phase 3c — generic metadata admin engine. Re-exported so plugins // can call `registerMetadataResource()` to override the per-type diff --git a/packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts b/packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts index 7e821c7031..8cc60c1ea8 100644 --- a/packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts +++ b/packages/app-shell/src/utils/__tests__/pageSchemaIntrospect.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { hasExplicitDiscussion } from '../pageSchemaIntrospect'; +import { hasExplicitDiscussion, hasExplicitAttachments } from '../pageSchemaIntrospect'; describe('hasExplicitDiscussion', () => { it('returns false for nullish and primitive inputs', () => { @@ -115,3 +115,56 @@ describe('hasExplicitDiscussion', () => { expect(hasExplicitDiscussion(a)).toBe(false); }); }); + +// objectstack#4358 — the synthesized default page now places +// `record:attachments` beside the discussion feed; RecordDetailView uses this +// walker to skip its legacy bottom-of-page append. +describe('hasExplicitAttachments', () => { + it('returns false for nullish and primitive inputs', () => { + expect(hasExplicitAttachments(null)).toBe(false); + expect(hasExplicitAttachments(undefined)).toBe(false); + expect(hasExplicitAttachments('record:attachments')).toBe(false); + }); + + it('detects record:attachments at the root and nested in children', () => { + expect(hasExplicitAttachments({ type: 'record:attachments' })).toBe(true); + expect( + hasExplicitAttachments({ + type: 'grid', + children: [{ type: 'record:attachments' }, { type: 'record:discussion' }], + }), + ).toBe(true); + }); + + it('detects attachments inside the synthesized Attachments tab (regions[].components[].items[])', () => { + // Mirrors buildDefaultPageSchema output for an enable.files object. + const synthPage = { + type: 'record', + regions: [ + { + name: 'main', + components: [ + { type: 'page:header' }, + { + type: 'page:tabs', + items: [ + { label: 'Details', value: 'details', children: [{ type: 'record:details' }] }, + { label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] }, + ], + }, + { type: 'record:discussion' }, + ], + }, + ], + }; + expect(hasExplicitAttachments(synthPage)).toBe(true); + }); + + it('returns false when no attachments node exists (discussion alone)', () => { + expect( + hasExplicitAttachments({ + regions: [{ components: [{ type: 'record:discussion' }] }], + }), + ).toBe(false); + }); +}); diff --git a/packages/app-shell/src/utils/pageSchemaIntrospect.ts b/packages/app-shell/src/utils/pageSchemaIntrospect.ts index d676f42e6f..c4c0383b27 100644 --- a/packages/app-shell/src/utils/pageSchemaIntrospect.ts +++ b/packages/app-shell/src/utils/pageSchemaIntrospect.ts @@ -8,10 +8,11 @@ */ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']); +const ATTACHMENT_TYPES = new Set(['record:attachments']); /** - * Walks a page schema tree and returns true if any node has a - * `type` of `record:discussion` or `record:chatter`. + * Walks a page schema tree and returns true if any node's `type` is in + * `types`. * * Recurses into: * - `children`, `items`, `body`, `components` @@ -20,7 +21,7 @@ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']); * * Cycles are guarded with a WeakSet. */ -export function hasExplicitDiscussion(root: unknown): boolean { +function hasNodeOfType(root: unknown, types: ReadonlySet): boolean { const seen = new WeakSet(); const walk = (node: any): boolean => { if (!node || typeof node !== 'object') return false; @@ -28,7 +29,7 @@ export function hasExplicitDiscussion(root: unknown): boolean { seen.add(node); if (Array.isArray(node)) return node.some(walk); const t = node?.type; - if (typeof t === 'string' && DISCUSSION_TYPES.has(t)) return true; + if (typeof t === 'string' && types.has(t)) return true; const candidates: any[] = [ node.children, node.items, @@ -47,3 +48,21 @@ export function hasExplicitDiscussion(root: unknown): boolean { }; return walk(root); } + +/** + * True when the page schema already places a `record:discussion` / + * `record:chatter` node — the host must then skip its bottom auto-append. + */ +export function hasExplicitDiscussion(root: unknown): boolean { + return hasNodeOfType(root, DISCUSSION_TYPES); +} + +/** + * True when the page schema already places a `record:attachments` node — + * the synthesized default does whenever the object declares + * `enable.files: true` (objectstack#4358). The host must then skip its + * legacy bottom-of-page attachments append. + */ +export function hasExplicitAttachments(root: unknown): boolean { + return hasNodeOfType(root, ATTACHMENT_TYPES); +} diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 37d0b49d50..8de5c551b8 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -24,7 +24,7 @@ import { SkeletonDetail } from '../skeletons'; import { ManagedByBadge } from '../components/ManagedByBadge'; import { resolveCrudAffordances } from '../utils/crudAffordances'; import { deriveRelatedLists } from '../utils/deriveRelatedLists'; -import { hasExplicitDiscussion } from '../utils/pageSchemaIntrospect'; +import { hasExplicitDiscussion, hasExplicitAttachments } from '../utils/pageSchemaIntrospect'; import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog'; import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog'; import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog'; @@ -1873,6 +1873,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // `enable.feeds: false` (#2707) suppresses the discussion panel outright — // same opt-out contract the server enforces on sys_comment creation. const showAutoDiscussion = !disableDiscussion && !hasDiscussion && feedsEnabled; + // Synthesized pages place `record:attachments` beside the discussion feed + // (objectstack#4358); the legacy bottom-of-page append below stays only as + // the fallback for authored pages that don't slot the panel themselves. + const hasAttachments = hasExplicitAttachments(effectivePage as any); // System actions (Edit / Share / Delete) — synthesized for every record // page so objects without authored record_header actions still surface @@ -2106,8 +2110,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri )} {/* Generic Attachments panel (#2727) — opt-in via `enable.files: true`; the server rejects attachments - targeting any other object (403 FILES_DISABLED). */} - {filesEnabled && pureRecordId && ( + targeting any other object (403 FILES_DISABLED). + Fallback only: synthesized pages already place a + `record:attachments` node beside the discussion feed + (objectstack#4358), so this bottom append fires only for + authored pages that omit the panel. */} + {filesEnabled && !hasAttachments && pureRecordId && (
) => { + const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; + return { designer: { 'data-obj-id': id, 'data-obj-type': type, style }, rest }; +}; + +export interface RecordAttachmentsRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const RecordAttachmentsRenderer: React.FC = ({ + schema: _schema, + className, + ...props +}) => { + const ctx = useRecordContext(); + const { user } = useAuth(); + const { designer } = splitDesigner(props); + + const objectName = ctx?.objectName; + const recordId = ctx?.recordId != null ? String(ctx.recordId) : ''; + if (!objectName || !recordId) return null; + + return ( +
+ +
+ ); +}; + +ComponentRegistry.register('attachments', RecordAttachmentsRenderer, { + namespace: 'record', + skipFallback: true, + category: 'record', + label: 'Attachments', + icon: 'Paperclip', + inputs: [{ name: 'className', type: 'string', label: 'CSS Class' }], +}); + +export default RecordAttachmentsRenderer; diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index b1c033b5b9..561668c2fe 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -308,6 +308,27 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => { return acc; }; +/** + * Walk a tab's children (depth-first) and return true when a + * `record:attachments` node is present. The Attachments tab + * (objectstack#4358) derives its badge from a `sys_attachment` count scoped + * by `(parent_object, parent_id)` — a two-key filter the related-list probe + * shape can't express, so it gets its own detection + probe path below. + */ +const containsAttachmentsNode = (nodes: any): boolean => { + if (!nodes) return false; + const list = Array.isArray(nodes) ? nodes : [nodes]; + for (const n of list) { + if (!n || typeof n !== 'object') continue; + if (n.type === 'record:attachments') return true; + const candidates = [n.children, n.properties?.children, n.properties?.items, n.body, n.items]; + for (const c of candidates) { + if (c && containsAttachmentsNode(c)) return true; + } + } + return false; +}; + const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { const { designer } = splitDesignerProps(props); const { language } = useObjectTranslation(); @@ -377,12 +398,13 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { // Snapshot which tabs (index → derived (objectName, relationshipField)) // need a count probe. Cached per items reference so we don't re-walk on // every render. + const recordObject: string | undefined = ctx?.objectName; const probeTargets = React.useMemo(() => { - const out = new Map>(); + const out = new Map>(); items.forEach((it, idx) => { if (it.count !== undefined && it.count !== null && it.count !== '') return; const lists = collectRelatedLists((it as any).children); - const probes: Array<{ objectName: string; relationshipField?: string }> = []; + const probes: Array<{ objectName: string; relationshipField?: string; attachments?: boolean }> = []; for (const rl of lists) { const objectName: string | undefined = rl?.properties?.objectName || rl?.objectName; if (!objectName) continue; @@ -390,10 +412,18 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { rl?.properties?.relationshipField || rl?.relationshipField; probes.push({ objectName, relationshipField }); } + // Attachments tab (objectstack#4358): count sys_attachment rows scoped + // to this record. The synthetic `relationshipField` below is only a + // cache-key discriminator — the actual filter is injected by the probe + // wrapper in the effect, since the store's single-key filter shape + // can't express `(parent_object, parent_id)`. + if (recordObject && containsAttachmentsNode((it as any).children)) { + probes.push({ objectName: 'sys_attachment', relationshipField: `attachments:${recordObject}`, attachments: true }); + } if (probes.length > 0) out.set(idx, probes); }); return out; - }, [items]); + }, [items, recordObject]); React.useEffect(() => { if (!ds || typeof ds.find !== 'function') return; @@ -403,8 +433,19 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { for (const probe of probes) { // RelatedCountStore.fetch is internally deduplicated, so concurrent // mounts of multiple tab strips don't generate redundant requests. + // The attachments probe overrides the store-built single-key filter + // with the two-key `(parent_object, parent_id)` scope; the synthetic + // relationshipField keeps the cache key unique, and the store's + // `sys_attachment` invalidation (data-change bus) still hits it. + const finder = probe.attachments + ? (object: string, query: any) => + ds.find(object, { + ...query, + $filter: { parent_object: recordObject, parent_id: parentId }, + }) + : (object: string, query: any) => ds.find(object, query); void RelatedCountStore.fetch( - (object, query) => ds.find(object, query), + finder, probe.objectName, probe.relationshipField, parentId, diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6f9e7cfddd..de9aee3f34 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -759,6 +759,18 @@ const ar = { attachmentCount: "{{count}} مرفق", attachmentCountPlural: "{{count}} مرفقات", removeAttachment: "إزالة المرفق", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "المرفقات", + uploadAttachment: "رفع", + loadingAttachments: "جارٍ تحميل المرفقات…", + noAttachments: "لا توجد مرفقات بعد. ارفع ملفًا للبدء.", + downloadAttachment: "تنزيل", + deleteAttachment: "حذف المرفق", + attachmentDeleteDenied: "لا يمكن حذف هذا المرفق إلا لمن رفعه أو لمن يمكنه تعديل هذا السجل.", + attachmentParentAccessDenied: "ليس لديك صلاحية إرفاق ملفات بهذا السجل.", + attachmentDownloadDenied: "ليس لديك صلاحية تنزيل هذا المرفق.", + attachmentAuthRequired: "يرجى تسجيل الدخول لتنزيل هذا المرفق.", + attachmentPermissionDenied: "ليس لديك إذن للقيام بذلك.", unifiedDiff: "عرض موحد", sideBySideDiff: "عرض جنباً إلى جنب", noChanges: "لا توجد تغييرات", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 0ded657a49..96bf56b804 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -757,6 +757,18 @@ const de = { attachmentCount: "{{count}} Anhang", attachmentCountPlural: "{{count}} Anhänge", removeAttachment: "Anhang entfernen", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "Anhänge", + uploadAttachment: "Hochladen", + loadingAttachments: "Anhänge werden geladen…", + noAttachments: "Noch keine Anhänge. Laden Sie eine Datei hoch, um zu beginnen.", + downloadAttachment: "Herunterladen", + deleteAttachment: "Anhang löschen", + attachmentDeleteDenied: "Nur die hochladende Person oder jemand mit Bearbeitungsrecht für diesen Datensatz darf diesen Anhang löschen.", + attachmentParentAccessDenied: "Sie sind nicht berechtigt, Dateien an diesen Datensatz anzuhängen.", + attachmentDownloadDenied: "Sie sind nicht berechtigt, diesen Anhang herunterzuladen.", + attachmentAuthRequired: "Bitte melden Sie sich an, um diesen Anhang herunterzuladen.", + attachmentPermissionDenied: "Sie haben keine Berechtigung für diese Aktion.", unifiedDiff: "Einheitliche Ansicht", sideBySideDiff: "Nebeneinander-Ansicht", noChanges: "Keine Änderungen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e3126b99c8..beb85d7a19 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -817,6 +817,18 @@ const en = { attachmentCount: '{{count}} attachment', attachmentCountPlural: '{{count}} attachments', removeAttachment: 'Remove attachment', + // Record Attachments panel (enable.files, objectstack#4358) + attachments: 'Attachments', + uploadAttachment: 'Upload', + loadingAttachments: 'Loading attachments…', + noAttachments: 'No attachments yet. Upload a file to get started.', + downloadAttachment: 'Download', + deleteAttachment: 'Delete attachment', + attachmentDeleteDenied: 'Only the uploader or someone who can edit this record may delete this attachment.', + attachmentParentAccessDenied: "You don't have access to attach files to this record.", + attachmentDownloadDenied: "You don't have access to download this attachment.", + attachmentAuthRequired: 'Please sign in to download this attachment.', + attachmentPermissionDenied: "You don't have permission to do that.", // Diff unifiedDiff: 'Unified diff', sideBySideDiff: 'Side-by-side diff', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a108cad4d0..320ec63c08 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -757,6 +757,18 @@ const es = { attachmentCount: "{{count}} archivo adjunto", attachmentCountPlural: "{{count}} archivos adjuntos", removeAttachment: "Eliminar adjunto", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "Adjuntos", + uploadAttachment: "Subir", + loadingAttachments: "Cargando adjuntos…", + noAttachments: "Aún no hay adjuntos. Suba un archivo para empezar.", + downloadAttachment: "Descargar", + deleteAttachment: "Eliminar adjunto", + attachmentDeleteDenied: "Solo quien lo subió o alguien que pueda editar este registro puede eliminar este adjunto.", + attachmentParentAccessDenied: "No tiene acceso para adjuntar archivos a este registro.", + attachmentDownloadDenied: "No tiene acceso para descargar este adjunto.", + attachmentAuthRequired: "Inicie sesión para descargar este adjunto.", + attachmentPermissionDenied: "No tiene permiso para hacer eso.", unifiedDiff: "Vista unificada", sideBySideDiff: "Vista lado a lado", noChanges: "Sin cambios", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 8450348fc0..4f54362899 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -759,6 +759,18 @@ const fr = { attachmentCount: "{{count}} pièce jointe", attachmentCountPlural: "{{count}} pièces jointes", removeAttachment: "Supprimer la pièce jointe", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "Pièces jointes", + uploadAttachment: "Téléverser", + loadingAttachments: "Chargement des pièces jointes…", + noAttachments: "Aucune pièce jointe pour le moment. Téléversez un fichier pour commencer.", + downloadAttachment: "Télécharger", + deleteAttachment: "Supprimer la pièce jointe", + attachmentDeleteDenied: "Seule la personne qui l'a téléversée ou une personne pouvant modifier cet enregistrement peut supprimer cette pièce jointe.", + attachmentParentAccessDenied: "Vous n'avez pas accès pour joindre des fichiers à cet enregistrement.", + attachmentDownloadDenied: "Vous n'avez pas accès pour télécharger cette pièce jointe.", + attachmentAuthRequired: "Veuillez vous connecter pour télécharger cette pièce jointe.", + attachmentPermissionDenied: "Vous n'avez pas la permission de faire cela.", unifiedDiff: "Vue unifiée", sideBySideDiff: "Vue côte à côte", noChanges: "Aucune modification", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ddae4e46e8..c6448fe944 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -768,6 +768,18 @@ const ja = { attachmentCount: "{{count}} 件の添付ファイル", attachmentCountPlural: "{{count}} 件の添付ファイル", removeAttachment: "添付ファイルを削除", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "添付ファイル", + uploadAttachment: "アップロード", + loadingAttachments: "添付ファイルを読み込み中…", + noAttachments: "添付ファイルはまだありません。ファイルをアップロードして始めましょう。", + downloadAttachment: "ダウンロード", + deleteAttachment: "添付ファイルを削除", + attachmentDeleteDenied: "この添付ファイルを削除できるのは、アップロードした本人またはこのレコードを編集できるユーザーのみです。", + attachmentParentAccessDenied: "このレコードにファイルを添付する権限がありません。", + attachmentDownloadDenied: "この添付ファイルをダウンロードする権限がありません。", + attachmentAuthRequired: "この添付ファイルをダウンロードするにはサインインしてください。", + attachmentPermissionDenied: "この操作を行う権限がありません。", unifiedDiff: "統合差分", sideBySideDiff: "横並び差分", noChanges: "変更なし", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ee3f7c4bac..e60a43ab6f 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -757,6 +757,18 @@ const ko = { attachmentCount: "첨부파일 {{count}}개", attachmentCountPlural: "첨부파일 {{count}}개", removeAttachment: "첨부파일 제거", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "첨부파일", + uploadAttachment: "업로드", + loadingAttachments: "첨부파일 불러오는 중…", + noAttachments: "아직 첨부파일이 없습니다. 파일을 업로드해 시작하세요.", + downloadAttachment: "다운로드", + deleteAttachment: "첨부파일 삭제", + attachmentDeleteDenied: "업로드한 사용자 또는 이 레코드를 편집할 수 있는 사용자만 이 첨부파일을 삭제할 수 있습니다.", + attachmentParentAccessDenied: "이 레코드에 파일을 첨부할 권한이 없습니다.", + attachmentDownloadDenied: "이 첨부파일을 다운로드할 권한이 없습니다.", + attachmentAuthRequired: "이 첨부파일을 다운로드하려면 로그인하세요.", + attachmentPermissionDenied: "이 작업을 수행할 권한이 없습니다.", unifiedDiff: "통합 보기", sideBySideDiff: "나란히 보기", noChanges: "변경 없음", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index caebbbd623..e9f827d35a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -759,6 +759,18 @@ const pt = { attachmentCount: "{{count}} anexo", attachmentCountPlural: "{{count}} anexos", removeAttachment: "Remover anexo", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "Anexos", + uploadAttachment: "Enviar", + loadingAttachments: "Carregando anexos…", + noAttachments: "Ainda não há anexos. Envie um arquivo para começar.", + downloadAttachment: "Baixar", + deleteAttachment: "Excluir anexo", + attachmentDeleteDenied: "Somente quem enviou ou alguém que pode editar este registro pode excluir este anexo.", + attachmentParentAccessDenied: "Você não tem acesso para anexar arquivos a este registro.", + attachmentDownloadDenied: "Você não tem acesso para baixar este anexo.", + attachmentAuthRequired: "Faça login para baixar este anexo.", + attachmentPermissionDenied: "Você não tem permissão para fazer isso.", unifiedDiff: "Visualização unificada", sideBySideDiff: "Visualização lado a lado", noChanges: "Sem alterações", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index d39370bb8a..c508a0caee 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -770,6 +770,18 @@ const ru = { attachmentCount: "{{count}} вложение", attachmentCountPlural: "{{count}} вложений", removeAttachment: "Удалить вложение", + // Record Attachments panel (enable.files, objectstack#4358) + attachments: "Вложения", + uploadAttachment: "Загрузить", + loadingAttachments: "Загрузка вложений…", + noAttachments: "Вложений пока нет. Загрузите файл, чтобы начать.", + downloadAttachment: "Скачать", + deleteAttachment: "Удалить вложение", + attachmentDeleteDenied: "Удалить это вложение может только загрузивший его пользователь или тот, кто может редактировать эту запись.", + attachmentParentAccessDenied: "У вас нет доступа для прикрепления файлов к этой записи.", + attachmentDownloadDenied: "У вас нет доступа для скачивания этого вложения.", + attachmentAuthRequired: "Войдите в систему, чтобы скачать это вложение.", + attachmentPermissionDenied: "У вас нет разрешения на это действие.", unifiedDiff: "Единое представление", sideBySideDiff: "Параллельное представление", noChanges: "Нет изменений", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 45df89a752..363a8a032b 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -829,6 +829,18 @@ const zh = { attachmentCount: '{{count}} 个附件', attachmentCountPlural: '{{count}} 个附件', removeAttachment: '移除附件', + // Record Attachments panel (enable.files, objectstack#4358) + attachments: '附件', + uploadAttachment: '上传', + loadingAttachments: '正在加载附件…', + noAttachments: '暂无附件。上传一个文件开始吧。', + downloadAttachment: '下载', + deleteAttachment: '删除附件', + attachmentDeleteDenied: '只有上传者或可编辑此记录的用户才能删除此附件。', + attachmentParentAccessDenied: '您无权为此记录添加附件。', + attachmentDownloadDenied: '您无权下载此附件。', + attachmentAuthRequired: '请登录后再下载此附件。', + attachmentPermissionDenied: '您没有执行此操作的权限。', // Diff unifiedDiff: '统一视图', sideBySideDiff: '并排视图', diff --git a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 9394f956bc..8135bd7983 100644 --- a/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -14,6 +14,7 @@ import { buildDefaultHighlights, buildDefaultTabs, buildDefaultDiscussion, + buildDefaultAttachments, detectStatusField, deriveStages, deriveHighlightFields, @@ -721,6 +722,64 @@ describe('buildDefaultPageSchema', () => { it('buildDefaultDiscussion returns the record:discussion node', () => { expect(buildDefaultDiscussion()).toEqual({ type: 'record:discussion' }); }); + + it('buildDefaultAttachments returns the record:attachments node', () => { + expect(buildDefaultAttachments()).toEqual({ type: 'record:attachments' }); + }); + }); + + // objectstack#4358 — `enable.files` objects get a peer Attachments tab + // (with a count badge derived by PageTabsRenderer) instead of the legacy + // below-the-feed append that a growing timeline buried. + describe('attachments tab (enable.files, objectstack#4358)', () => { + const filesDef: ObjectDefLike = { ...leadDef, enable: { files: true } }; + + it('no enable.files → no record:attachments anywhere', () => { + const page = buildDefaultPageSchema(leadDef); + expect(JSON.stringify(page)).not.toContain('record:attachments'); + }); + + it('enable.files → tabs carry an Attachments tab wrapping record:attachments', () => { + const page = buildDefaultPageSchema(filesDef); + const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); + const tab = tabs.items.find((t: any) => t.value === 'attachments'); + expect(tab).toBeDefined(); + expect(tab.label).toBe('Attachments'); + expect(tab.children).toEqual([{ type: 'record:attachments' }]); + // The discussion footer is untouched — attachments are a tab, not a + // footer widget. + const components = page.regions[0].components; + expect(components[components.length - 1].type).toBe('record:discussion'); + }); + + it('the Attachments tab sits after Related and before Activity/History', () => { + const tabs = buildDefaultTabs(filesDef, { + related: [{ objectName: 'task', relationshipField: 'lead_id' }], + showActivity: true, + history: { entries: [], loading: false }, + }); + expect(tabs.items.map((t: any) => t.value)).toEqual([ + 'details', + 'related', + 'attachments', + 'activity', + 'history', + ]); + }); + + it('hideAttachments suppresses the tab', () => { + const page = buildDefaultPageSchema(filesDef, { hideAttachments: true }); + expect(JSON.stringify(page)).not.toContain('record:attachments'); + }); + + it('a details slot override keeps the Attachments tab', () => { + const page = buildDefaultPageSchema(filesDef, { + slots: { details: { type: 'div', id: 'custom-details' } }, + }); + const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); + expect(tabs.items.some((t: any) => t.value === 'attachments')).toBe(true); + expect(tabs.items[0].children[0].id).toBe('custom-details'); + }); }); }); diff --git a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 4f669a73a2..d6378bb390 100644 --- a/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -62,6 +62,12 @@ export interface ObjectDefLike { /** @deprecated pre-ADR-0085 pair; honoured by the shared derivation. */ collapsed?: boolean; }>; + /** + * Spec `enable` capability toggles. Only `files` is read here: + * `enable.files === true` (opt-in, #2727) makes the synthesizer emit an + * Attachments tab beside Details/Related (objectstack#4358). + */ + enable?: { files?: boolean; [key: string]: any }; // NOTE: the per-surface `detail` hints block was REMOVED from the spec by // ADR-0085 — presentation intent is declared via the top-level semantic // roles above (stageField / highlightFields / fieldGroups). Per-page @@ -89,6 +95,8 @@ export interface BuildPageOptions { sections?: Array<{ title?: string; columns?: number; fields?: any[] }>; /** Suppress the auto-appended `record:discussion` slot. */ hideDiscussion?: boolean; + /** Suppress the auto-emitted Attachments tab (`enable.files`, objectstack#4358). */ + hideAttachments?: boolean; /** Suppress the auto-prepended `record:highlights` strip. */ hideHighlights?: boolean; /** Suppress the auto-prepended `record:path` stepper. */ @@ -540,7 +548,7 @@ export function buildDefaultDetails( export function buildDefaultTabs( def: ObjectDefLike | undefined, options: Pick = {}, ): any { const statusField = options.statusField ?? detectStatusField(def); @@ -593,6 +601,15 @@ export function buildDefaultTabs( } } } + // Attachments tab (objectstack#4358) — emitted for `enable.files: true` + // objects so the panel is a peer of Details/Related instead of a footer + // widget buried under the discussion feed. `PageTabsRenderer` derives the + // count badge from the `record:attachments` node the same way it counts + // `record:related_list` tabs. The English label goes through the tab + // strip's KNOWN_LABEL_DICT (→ 附件 etc.), matching its sibling tabs. + if (def?.enable?.files === true && !options.hideAttachments) { + items.push({ label: 'Attachments', value: 'attachments', children: [buildDefaultAttachments()] }); + } if (options.showActivity) { items.push({ label: 'Activity', value: 'activity', children: [{ type: 'record:activity' }] }); } @@ -621,6 +638,15 @@ export function buildDefaultDiscussion(): any { return { type: 'record:discussion' }; } +/** + * Sub-builder: the `record:attachments` panel (#2727). Emitted only when the + * object opts in via `enable.files: true` — the same gate the server enforces + * on `sys_attachment` rows (403 FILES_DISABLED otherwise). + */ +export function buildDefaultAttachments(): any { + return { type: 'record:attachments' }; +} + /** * Synthesize the canonical Page schema for an object's default detail * page. @@ -629,6 +655,8 @@ export function buildDefaultDiscussion(): any { * { type:'record', template:'full-width', regions:[ { name:'main', * components: [page:header, record:highlights?, record:path?, * page:tabs, record:discussion?] } ] } + * For `enable.files: true` objects the tabs node carries a peer + * Attachments tab (`record:attachments` — objectstack#4358). * * Notes: * - The `record:details` tab content is registered separately and @@ -723,6 +751,7 @@ export function buildDefaultPageSchema( statusField: options.statusField, hideRelatedTab, relatedLayout: options.relatedLayout, + hideAttachments: options.hideAttachments, }); // Replace the first tab's children (Details) with the override. if (Array.isArray(tabsNode.items) && tabsNode.items.length > 0) { @@ -739,10 +768,13 @@ export function buildDefaultPageSchema( statusField: options.statusField, hideRelatedTab, relatedLayout: options.relatedLayout, + hideAttachments: options.hideAttachments, })); } - // 5) Discussion footer. + // 5) Discussion footer. (Attachments are NOT part of this footer: for + // `enable.files` objects buildDefaultTabs emits a peer Attachments tab + // instead — objectstack#4358.) if ('discussion' in slots && slots.discussion !== undefined) { components.push(...toNodeArray(slots.discussion)); } else if (!options.hideDiscussion) {