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
17 changes: 17 additions & 0 deletions .changeset/record-header-manual-refresh-3460.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
"@object-ui/app-shell": patch
"@object-ui/components": patch
"@object-ui/plugin-detail": patch
---

Record detail pages: a header ⟳ that refreshes the record, its related lists and its tab counts in place — no browser reload

Concurrent-editing scenario from the shop floor (MES work orders): operator A sits on a record's detail page while operator B starts or reports the same order. A had no way to see the new state except F5, which throws away the open tab, the scroll position and any in-progress inline edit along with the stale data.

The pipeline for this already existed — the objectui#2269 invalidation bus refetches every mounted reader in place, and `RecordContext.refresh` had been declared for it — but nothing produced that field and no UI reached for it. Three changes give it a trigger:

- **`RecordDetailView` produces `RecordContext.refresh`**, publishing `notifyDataChanged({ objectName: '*' })`. The wildcard is deliberate: a user reaches for refresh because of a write made by SOMEONE ELSE, which this client never saw and therefore cannot attribute to particular objects. `'*'` marks everything mounted as stale, so the main record, every related child list and the tab-count badges all refetch — no remount, so tab / scroll / draft state survive. First phase covers the standalone record route; embedded hosts (list drawer, split-pane preview) keep their existing chrome unchanged.
- **`page:header` renders the ⟳** at the far end of the header row when — and only when — the host provides `refresh`. It is page chrome rather than a header action, so its position is the same on every record page regardless of which business actions the object declares, and it can never be collapsed into the `⋯` overflow. Styled as that `⋯` trigger's twin so the row reads as one button family. Its accessible name and tooltip come from the existing `common.refresh` key, so the icon-only button is not English-only in the other nine locales. The icon spins for a short floor after a click, because the bus is fire-and-forget and a warm backend would otherwise finish before the click looked like it landed.
- **`RelatedList` accepts the `'*'` wildcard** on the legacy `objectui:related-changed` event, matching what `dataChangeMatches` already does for the bus's own readers. This listener compared the payload's object name to its own, so a wildcard invalidation reached everything on the page except the related lists — a concrete foreign object name is still ignored.

Hosts that provide no `refresh` render exactly as before.
26 changes: 26 additions & 0 deletions content/docs/guide/slotted-pages.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -159,6 +159,32 @@ slots: {
},
```

### The refresh button is chrome, not an action

Past the `⋯` menu, at the far end of the row, a record page shows a **⟳
refresh** button. It is deliberately *not* part of the action row:

- **Nothing to author.** It appears when the page **host** supplies
`RecordContext.refresh` — the standalone record route does, so every
record page has it, in the same place, whatever actions the object
declares. There is no metadata key for it and no `locations` to write.
- **Never collapsed.** Because it is outside the action list, it is not
part of the `maxVisible` budget and can never be pushed into the `⋯`
menu by an object that declares many actions.
- **Not permission-gated.** Re-reading a record already on screen is not
a privileged operation, so it skips the `requiredPermissions` gate the
action pipeline applies.

Clicking it invalidates data on the client bus (`notifyDataChanged` from
`@object-ui/react`) with the wildcard scope `'*'`: the record, every
related list and the tab-count badges refetch **in place**. Nothing
remounts, so the open tab, the scroll position and any in-progress
inline edit survive the refresh — the point of the button is to see
another user's writes without an F5.

A host that supplies no `refresh` (an embedded drawer, a designer
preview, a non-record page) renders no button.

## Composing default + custom

When you want "the default actions plus one custom button," you have
Expand Down
232 changes: 232 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.headerRefresh.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* RecordDetailView — the PRODUCER for the header's manual ⟳ (objectui#3460).
*
* `RecordContextValue.refresh` has been declared (and consumed nowhere) since
* the context was introduced: a field the types, the memo dep list and lint all
* accepted while no code path ever wrote it. This view is its producer, and
* `page:header` (see `page-header-refresh.test.tsx` in @object-ui/components)
* is the consumer.
*
* Two properties of the wiring can only be checked here, on the host:
*
* 1. The published scope is the `'*'` WILDCARD, not this record. The reason a
* user reaches for refresh is a write made by someone else — a write this
* client never saw, so it cannot know which objects it touched. `'*'` is
* the bus's "everything is stale", which is what also reaches the related
* lists and the tab-count badges. A record-scoped notify would look
* identical on the main record and silently leave those stale, so the
* payload is asserted on the bus itself, not inferred from a refetch.
* 2. The record refetches IN PLACE (AGENTS.md #8 / objectui#2269): the same
* `findOne` runs again and the header DOM node survives. A `key=` bump
* would also produce a second `findOne` while destroying tab, scroll and
* inline-edit state, so "it refetched" alone is not the property.
*
* The embedded case pins the #3460 ruling's scope decision: the list drawer and
* the split-pane preview mount THIS view with `embedded` (ObjectView,
* ObjectDataPage, InterfaceListPage all do), so the first phase's "no ⟳ in
* drawer/split-pane" has to be enforced right here at the producer.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx, subscribeDataChanges } from '@object-ui/react';
import type { DataChange } from '@object-ui/react';

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));

vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
}),
}));

// Orthogonal chrome — same posture as RecordDetailView.feedLoading.test.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT_NAME = 'mes_work_order';
const RECORD_ID = 'WO-1';

const OBJECTS = [
{
name: OBJECT_NAME,
label: 'Work Order',
managedBy: 'platform',
fields: {
id: { type: 'text', label: 'Id' },
name: { type: 'text', label: 'Name' },
status: { type: 'text', label: 'Status' },
},
},
];

function makeDataSource() {
// Each read answers a fresh status so a refetch is observable in the DOM as
// well as in the call count.
let generation = 0;
const findOne = vi.fn(async () => {
generation += 1;
return { id: RECORD_ID, name: 'WO-0001', status: `gen-${generation}` };
});
return {
find: vi.fn(async () => ({ data: [] })),
findOne,
create: vi.fn(async () => ({})),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
} as any;
}

function makeMetadata() {
return {
objects: OBJECTS,
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
} as any;
}

function renderDetail(dataSource: any, opts?: { embedded?: boolean }) {
return render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={dataSource}
objects={OBJECTS}
onEdit={() => {}}
objectNameOverride={OBJECT_NAME}
recordIdOverride={RECORD_ID}
embedded={opts?.embedded}
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

const refreshButton = () => document.querySelector('[data-page-refresh]') as HTMLButtonElement | null;

beforeEach(() => {
cleanup();
// Unrelated chrome (approvals, favourites) reaches for the platform API; in
// jsdom that is a real socket. Answer locally so the only asynchrony here is
// the record read.
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

describe('RecordDetailView — the header ⟳ refetches in place (objectui#3460)', () => {
it('renders the ⟳ on the standalone record route', async () => {
const dataSource = makeDataSource();
renderDetail(dataSource);

await waitFor(() => expect(refreshButton()).toBeTruthy());
});

it('publishes the WILDCARD scope on the invalidation bus, not this record', async () => {
const dataSource = makeDataSource();
const changes: DataChange[] = [];
const unsubscribe = subscribeDataChanges((c) => changes.push(c));
try {
renderDetail(dataSource);
await waitFor(() => expect(refreshButton()).toBeTruthy());

fireEvent.click(refreshButton()!);

expect(changes).toEqual([{ objectName: '*' }]);
// Explicitly NOT the record scope: that would refresh the main record and
// leave every related list and count badge stale.
expect(changes[0]).not.toHaveProperty('recordId', RECORD_ID);
} finally {
unsubscribe();
}
});

it('re-runs the record read without remounting the page', async () => {
const dataSource = makeDataSource();
renderDetail(dataSource);

await waitFor(() => expect(refreshButton()).toBeTruthy());
await waitFor(() => expect(dataSource.findOne).toHaveBeenCalledTimes(1));
const headerBefore = document.querySelector('header');
expect(headerBefore).toBeTruthy();

fireEvent.click(refreshButton()!);

// The bus re-runs the SAME load effect…
await waitFor(() => expect(dataSource.findOne).toHaveBeenCalledTimes(2));
// …and the header is the same DOM node it was before the click. A `key=`
// bump would have refetched too, while throwing away tab/scroll/inline-edit
// state (AGENTS.md #8).
expect(document.querySelector('header')).toBe(headerBefore);
expect(refreshButton()).toBeTruthy();
});
});

describe('RecordDetailView — embedded hosts opt out of the ⟳ (objectui#3460 ruling)', () => {
it('renders no ⟳ in a drawer / split-pane host', async () => {
const dataSource = makeDataSource();
renderDetail(dataSource, { embedded: true });

// Wait for the record read to settle so this is "the header rendered and
// has no ⟳", not "the header hasn't rendered yet".
await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled());
await waitFor(() => expect(document.querySelector('header')).toBeTruthy());
expect(refreshButton()).toBeNull();
});

it('publishes nothing on the bus for an embedded host', async () => {
const dataSource = makeDataSource();
const changes: DataChange[] = [];
const unsubscribe = subscribeDataChanges((c) => changes.push(c));
try {
renderDetail(dataSource, { embedded: true });
await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled());
expect(changes).toEqual([]);
} finally {
unsubscribe();
}
});
});
21 changes: 21 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -364,6 +364,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const notifyRecordChanged = useCallback(() => {
if (objectName) notifyDataChanged({ objectName, recordId: pureRecordId || undefined });
}, [objectName, pureRecordId]);
// Manual refresh (objectui#3460) — the producer for `RecordContext.refresh`,
// which `page:header` turns into the ⟳ button at the end of the header row.
//
// Scope is deliberately `'*'`, not this record: the reason a user reaches for
// refresh is a write made by SOMEONE ELSE (another operator started the work
// order, a child row got reported) — a write this client never saw, so it
// cannot know which objects it touched. `'*'` treats everything mounted as
// stale, so the main record, every related child list and the tab-count
// badges all refetch in place over the #2269 bus: no remount, so tab /
// scroll / in-progress inline-edit state all survive.
const handleManualRefresh = useCallback(() => {
notifyDataChanged({ objectName: '*' });
}, []);
// First phase covers the standalone record ROUTE only. Embedded hosts — the
// list drawer and the split-pane preview (ObjectView / ObjectDataPage /
// InterfaceListPage all mount this same view with `embedded`) — wrap it in
// overlay chrome that already owns its own controls, and the #3460 ruling
// keeps ⟳ off those surfaces for now. `undefined` is the opt-out
// `page:header` reads, so nothing renders there.
const headerRefresh = embedded ? undefined : handleManualRefresh;

// Record-scoped presence ("who else is viewing this record"). The default
// PresenceProvider source is a no-op, so this resolves to `[]` until a
Expand DownExpand Up@@ -2110,6 +2130,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
objectSchema={objectDef}
dataSource={dataSource}
embedded={embedded}
refresh={headerRefresh}
headerSystemActions={synthSystemActions}
isFavorite={isRecordFavorite}
onToggleFavorite={favoriteRecord ? handleToggleRecordFavorite : undefined}
Expand Down
Loading
Loading