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
15 changes: 15 additions & 0 deletions .changeset/detail-title-creator-2688.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@object-ui/plugin-detail": patch
---

fix(plugin-detail): #2688 — record surfaces without a caller title no longer floor to `Record #<id>`, and the meta footer never prints a raw audit user id

- `DetailView` header: after every declared-identity step misses and no
`schema.title` was provided, probe name-ish record keys (`name`, `title`,
`*_name`, …) before falling to the `Record #<id>` floor. Fixes records whose
name lives in a field the type-aware derivation deliberately skips (e.g. an
`autonumber` `name`) opened from surfaces like the gantt row drawer.
- `RecordMetaFooter`: `created_by` / `updated_by` are always user references on
ObjectStack — when the fetched schema omits the audit system fields, default
the reference target to `sys_user` so the footer renders a resolved user name
(or the muted placeholder) instead of the raw opaque id.
17 changes: 17 additions & 0 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const EMPTY_DRAFT: Record<string, any> = {};
* unified `@object-ui/core#getRecordDisplayName` (ADR-0079) — so the detail
* header matches gallery / calendar / lookup / search.
* 4. `schema.title` (caller-provided override, typically the object label).
* 4b. Record-key probe (`name`/`title`/`*_name`/…) — last resort before the
* floor, for records whose name lives in a field the type-aware derivation
* skips (e.g. an `autonumber` `name`) and whose caller set no title
* (objectui#2688).
* 5. `Record #<id>` floor, else the translated "Details" fallback.
*/
function resolveDisplayTitle(
Expand Down Expand Up @@ -106,6 +110,19 @@ function resolveDisplayTitle(
}
// 4. Caller-provided title override (object label).
if (schema.title) return schema.title;
// 4b. Record-key probe as the LAST resort before the id floor (objectui#2688).
// Only reached when the caller provided no title, so the "guessed key must
// not outrank schema.title" rule above still holds — but a name-ish value
// sitting right on the record (e.g. `name` typed `autonumber`, which the
// type-aware derivation deliberately skips) beats a bare `Record #<id>`.
if (data && typeof data === 'object') {
const id = (data as any).id ?? (data as any)._id;
const guessed = getRecordDisplayName(objectSchema, data);
const guessedIsFloor =
guessed === 'Untitled' ||
(id !== null && id !== undefined && guessed === `Record #${id}`);
if (!guessedIsFloor) return guessed;
}
// 5. `Record #<id>` floor, else the translated "Details" fallback.
if (data && typeof data === 'object') {
const id = (data as any).id ?? (data as any)._id;
Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-detail/src/RecordMetaFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,15 @@ interface UserRefProps {
const UserRef: React.FC<UserRefProps> = ({ value, objectSchema, fieldName }) => {
if (value === null || value === undefined || value === '') return null;
const fieldDef = objectSchema?.fields?.[fieldName];
const refTarget = fieldDef?.reference_to || fieldDef?.reference;
// created_by / updated_by are ALWAYS user references on ObjectStack, but many
// fetched schemas omit the audit system fields from `fields`. Without a
// fallback the field degrades to `type: 'text'` and the footer prints the raw
// user id (objectui#2688) — so default the reference target to `sys_user`.
const refTarget = fieldDef?.reference_to || fieldDef?.reference || 'sys_user';
const enrichedField: Record<string, any> = {
name: fieldName,
type: fieldDef?.type || (refTarget ? 'lookup' : 'text'),
...(refTarget && { reference_to: refTarget }),
type: fieldDef?.type || 'lookup',
reference_to: refTarget,
...(fieldDef?.reference_field && { reference_field: fieldDef.reference_field }),
};
const resolvedType = resolveCellRendererType(enrichedField as { type?: string }) || enrichedField.type;
Expand Down
98 changes: 98 additions & 0 deletions packages/plugin-detail/src/__tests__/DetailView.title2688.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* 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.
*/

/**
* objectui#2688 — the record surface opened from a gantt row (and any other
* caller that provides neither `schema.title` nor a resolvable declared name
* field) floored the header to `Record #<id>` and the meta footer printed the
* raw `created_by` user id.
*
* - Header: when everything above it misses, a name-ish key sitting right on
* the record (e.g. a `name` typed `autonumber`, which the type-aware
* derivation deliberately skips) must beat the `Record #<id>` floor.
* - Footer: `created_by` / `updated_by` are always user references on
* ObjectStack; when the fetched schema omits the audit system fields the
* footer must still render them through the reference renderer (which shows
* a resolved name or a muted placeholder) — never the raw opaque id.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DetailView } from '../DetailView';
import { RecordMetaFooter } from '../RecordMetaFooter';
import type { DetailViewSchema } from '@object-ui/types';

describe('DetailView header title — record-key probe before the Record # floor (#2688)', () => {
it('uses a name-ish record key when no schema.title and no declared name field resolve', () => {
const schema: DetailViewSchema = {
type: 'detail-view',
objectName: 'production_plan',
data: { id: 'A1', name: '甘特计划A 组焊' },
fields: [{ name: 'status', label: '状态' }],
};
const { container } = render(<DetailView schema={schema} />);
const h1 = container.querySelector('h1');
expect(h1?.textContent).toBe('甘特计划A 组焊');
});

it('still floors to Record #<id> when the record has no name-ish key at all', () => {
const schema: DetailViewSchema = {
type: 'detail-view',
objectName: 'thing',
data: { id: 'B2', qty: 3 },
fields: [{ name: 'qty', label: 'Qty' }],
};
const { container } = render(<DetailView schema={schema} />);
expect(container.querySelector('h1')?.textContent).toBe('Record #B2');
});

it('keeps preferring schema.title over a guessed record key', () => {
const schema: DetailViewSchema = {
type: 'detail-view',
objectName: 'thing',
title: 'Object Label',
data: { id: 'C3', name: 'Real Name' },
fields: [{ name: 'name', label: 'Name' }],
};
const { container } = render(<DetailView schema={schema} />);
// The unified resolver (step 3) resolves `name` via the schema-typed path
// here; the point is the header is never the raw floor when a title exists.
expect(container.querySelector('h1')?.textContent).not.toBe('Record #C3');
});
});

describe('RecordMetaFooter — audit fields default to a sys_user reference (#2688)', () => {
const OPAQUE_ID = 'g3WkZnvugj4DnYw8u5Mo6ig3ljDhiFGO';

it('never prints the raw created_by id when the schema omits the audit field', () => {
render(
<RecordMetaFooter
data={{ created_at: '2024-06-01T00:00:00Z', created_by: OPAQUE_ID }}
objectSchema={{ fields: { name: { type: 'text' } } }}
objectName="production_plan"
/>,
);
expect(screen.getByTestId('record-meta-footer')).toBeInTheDocument();
// Reference renderer shows a resolved name or a muted placeholder — the
// opaque id itself must not leak into the footer text.
expect(screen.queryByText(OPAQUE_ID)).toBeNull();
});

it('still honours an explicit audit-field definition from the schema', () => {
render(
<RecordMetaFooter
data={{ created_at: '2024-06-01T00:00:00Z', created_by: OPAQUE_ID }}
objectSchema={{
fields: { created_by: { type: 'lookup', reference_to: 'sys_user' } },
}}
objectName="production_plan"
/>,
);
expect(screen.queryByText(OPAQUE_ID)).toBeNull();
});
});
Loading