Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/attachments-beside-discussion-and-i18n.md
Original file line number Diff line number Diff line change
@@ -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.).
5 changes: 5 additions & 0 deletions content/docs/guide/slotted-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
27 changes: 23 additions & 4 deletions packages/app-shell/src/utils/pageSchemaIntrospect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -20,15 +21,15 @@ 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<string>): boolean {
const seen = new WeakSet<object>();
const walk = (node: any): boolean => {
if (!node || typeof node !== 'object') return false;
if (seen.has(node)) return false;
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,
Expand All @@ -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);
}
14 changes: 11 additions & 3 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
<div className="mt-6">
<RecordAttachmentsPanel
objectName={objectName!}
Expand Down
78 changes: 78 additions & 0 deletions packages/app-shell/src/views/record-attachments-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `record:attachments` — schema-addressable wrapper around
* `RecordAttachmentsPanel`, mirroring how `record:discussion` wraps
* `RecordChatterPanel` (plugin-detail/renderers/record-chatter.tsx).
*
* Registered here (app-shell) rather than in plugin-detail because the panel
* depends on `@object-ui/providers` (presigned upload adapter) and
* `@object-ui/auth` (Bearer fetch), which plugin-detail deliberately does not
* pull in. The side-effect registration is imported from the app-shell barrel
* (`src/index.ts`), so any host that mounts the shell — the console runtime
* AND Studio's PagePreview — resolves the type before a synthesized page
* references it (objectstack#4358).
*
* Renders nothing without a record identity (e.g. a page composed outside a
* RecordContext). A missing dataSource is tolerated: the panel just shows its
* empty state — that is what Studio's PagePreview binds.
*/

import * as React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { useRecordContext } from '@object-ui/react';
import { useAuth } from '@object-ui/auth';
import { RecordAttachmentsPanel } from './RecordAttachmentsPanel';

const splitDesigner = (props: Record<string, any>) => {
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<string, any>;
className?: string;
[k: string]: any;
}

export const RecordAttachmentsRenderer: React.FC<RecordAttachmentsRendererProps> = ({
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 (
<div className={className} {...designer}>
<RecordAttachmentsPanel
objectName={objectName}
recordId={recordId}
dataSource={ctx?.dataSource as any}
currentUserId={user?.id}
/>
</div>
);
};

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;
49 changes: 45 additions & 4 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> = ({ schema, className, ...props }) => {
const { designer } = splitDesignerProps(props);
const { language } = useObjectTranslation();
Expand Down Expand Up @@ -377,23 +398,32 @@ const PageTabsRenderer: React.FC<any> = ({ 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<number, Array<{ objectName: string; relationshipField?: string }>>();
const out = new Map<number, Array<{ objectName: string; relationshipField?: string; attachments?: boolean }>>();
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;
const relationshipField: string | undefined =
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;
Expand All @@ -403,8 +433,19 @@ const PageTabsRenderer: React.FC<any> = ({ 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,
Expand Down
12 changes: 12 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "لا توجد تغييرات",
Expand Down
Loading
Loading