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
14 changes: 14 additions & 0 deletions .changeset/12608-record-pointer-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-grid': minor
'@object-ui/i18n': minor
'@object-ui/console': minor
'@object-ui/fields': patch
---

List grids show the record a pointer field names, and the Approvals Inbox says who else is waiting (objectui#12608).

- **Record names in place of record ids.** A `text` field that declares `referenceVia` holds the id of a record whose object the sibling column names on the same row, as `record_id` does on Approval Requests (`referenceVia: 'object_name'`) or `source_id` on activities. In a list grid such a column now shows the target object's icon and the record's name, linking to the record. The record id moves to the tooltip. The grid reads the records of one page with one request per target object, not one per row.
- **Unavailable records say so.** When the record cannot be read (deleted, or outside what the viewer may read), the cell shows the object's label followed by "record unavailable", with the id in the tooltip, instead of the bare id.
- **The empty Approvals Inbox names who else is waiting.** When nothing is waiting on the viewer but other requests are pending, the empty state adds one muted line: "N requests are waiting on other approvers · Browse all requests". The number comes from one count request and counts only requests the viewer can see; when it cannot be read, the line is not shown.

New language-pack keys, in every shipped language: `grid.recordUnavailable` and the `approvalsInbox.waitingOnOthers` plural family.
34 changes: 33 additions & 1 deletion apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,13 @@ export function ApprovalsInboxPage() {
const [error, setError] = useState<string | null>(null);
/** "My pending" count, independent of the active tab (badge + bell parity). */
const [myPendingCount, setMyPendingCount] = useState(0);
/**
* objectui#12608 — the pending requests this caller can see in the
* environment, read only while the "pending" tab is empty: the empty state
* then says how many are waiting on other approvers. `null` = not read, or
* the read failed or answered no total; the line is then not drawn.
*/
const [environmentPendingCount, setEnvironmentPendingCount] = useState<number | null>(null);

const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<ApprovalRequestRow | null>(null);
Expand Down Expand Up @@ -1301,6 +1308,18 @@ export function ApprovalsInboxPage() {
: [];
setMyPendingCount(requests.length);
setTotal(null);
// objectui#12608 — the caller's pending list is empty, so ONE count
// query asks how many pending requests the environment holds. Paged
// (`limit`), so the server answers `total`; it counts under the same
// visibility as the "all" tab's own read, so the number never names a
// request the caller cannot open from there, and every request it
// counts waits on someone else. A failed read draws no line.
setEnvironmentPendingCount(null);
if (requests.length === 0) {
approvalsApi.listRequests({ status: 'pending', limit: 1, offset: 0 })
.then(res => setEnvironmentPendingCount(typeof res.total === 'number' ? res.total : null))
.catch(() => { /* the line is best-effort: no count, no line */ });
}
} else {
const pageParams = {
q: serverQuery || undefined,
Expand Down Expand Up @@ -2013,7 +2032,20 @@ export function ApprovalsInboxPage() {
? tr('emptyPending', "You're all caught up — nothing is waiting on you.")
: tr('emptyOther', 'Nothing here yet.')}
</EmptyDescription>
{tab === 'pending' && (
{tab === 'pending' && (environmentPendingCount ?? 0) > 0 ? (
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="approvals-inbox-waiting-on-others"
>
{tr('waitingOnOthers', '{{count}} requests are waiting on other approvers', {
count: environmentPendingCount,
})}
{' · '}
<Button variant="link" size="sm" className="h-auto p-0" onClick={() => onTabChange('all')}>
{tr('emptyViewAll', 'Browse all requests')}
</Button>
</p>
) : tab === 'pending' && (
<Button variant="link" size="sm" className="mt-1" onClick={() => onTabChange('all')}>
{tr('emptyViewAll', 'Browse all requests')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Approvals Inbox — the empty "pending" tab says who else is waiting
* (objectui#12608).
*
* A caller with nothing pending read "You're all caught up" while the
* environment held 53 pending requests, and nothing on the page said they were
* waiting on someone else. When the caller's pending list is empty the page now
* makes ONE count query (`listRequests` paged with `limit`, so the server
* answers `total`) and, for a non-zero total, adds one muted line:
* "N requests are waiting on other approvers · Browse all requests".
*
* Pinned here: the line for a non-zero count, nothing for zero, and nothing
* when the count read fails.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

const APP = 'com.objectstack.account';

const { approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => {
const approvalsApiStub = {
listRequests: vi.fn(async (_params?: Record<string, unknown>): Promise<{ data: unknown[]; total?: number }> => ({ data: [] })),
getRequest: vi.fn(async () => ({ data: undefined })),
listActions: vi.fn(async () => ({ data: [] })),
approve: vi.fn(async () => ({ data: undefined, finalized: true })),
reject: vi.fn(async () => ({ data: undefined, finalized: true })),
};
const ADAPTER = { find: vi.fn(async () => ({ data: [] })) };
const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } };
// Interpolates `{{count}}` into the inline default, as i18next would.
const I18N = {
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{count\}\}/g, String(options?.count ?? '')),
language: 'en',
};
return { approvalsApiStub, ADAPTER, AUTH, I18N };
});

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => I18N,
}));

vi.mock('@object-ui/auth', async (importOriginal) => {
const authFetch = vi.fn(async () => new Response('{}', { status: 200 }));
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => AUTH,
createAuthenticatedFetch: () => authFetch,
TokenStorage: { get: () => null },
};
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
DeclaredActionsBar: () => null,
isViaOverrideRow: () => false,
}));

vi.mock('../../services/approvalsApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
approvalsApi: approvalsApiStub,
}));

import { ApprovalsInboxPage } from './ApprovalsInboxPage';

/** The caller's own pending list is empty; the environment count is `total`. */
function stubEnvironment(total: number | Error) {
approvalsApiStub.listRequests.mockImplementation(async (params?: Record<string, unknown>) => {
const isCountQuery = params?.status === 'pending' && params?.limit != null && params?.approverId == null;
if (!isCountQuery) return { data: [] };
if (total instanceof Error) throw total;
return { data: [], total };
});
}

function renderInbox() {
return render(
<MemoryRouter initialEntries={[`/apps/${APP}/system/approvals`]}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>,
);
}

function countQueries() {
return approvalsApiStub.listRequests.mock.calls.filter(
([params]) => params?.status === 'pending' && params?.limit != null && params?.approverId == null,
);
}

beforeEach(() => {
for (const fn of Object.values(approvalsApiStub)) fn.mockReset();
});
afterEach(cleanup);

describe('Approvals Inbox — the empty pending tab names who else is waiting (objectui#12608)', () => {
it('renders the "waiting on other approvers" line for a non-zero environment count', async () => {
stubEnvironment(53);
renderInbox();

const line = await screen.findByTestId('approvals-inbox-waiting-on-others');
expect(line).toHaveTextContent('53 requests are waiting on other approvers · Browse all requests');
expect(line).toHaveClass('text-muted-foreground');
// ONE count query, paged so the server answers `total`.
expect(countQueries()).toHaveLength(1);
expect(countQueries()[0][0]).toMatchObject({ status: 'pending', limit: 1 });
// "Browse all requests" is drawn once, inside the line.
expect(screen.getAllByRole('button', { name: 'Browse all requests' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Browse all requests' }));
await waitFor(() =>
expect(approvalsApiStub.listRequests).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 })),
);
});

it('renders nothing extra for a zero environment count', async () => {
stubEnvironment(0);
renderInbox();

await waitFor(() => expect(countQueries()).toHaveLength(1));
// Let the count answer land before asserting the absence.
await screen.findByText("You're all caught up — nothing is waiting on you.");
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByTestId('approvals-inbox-waiting-on-others')).toBeNull();
expect(screen.getByRole('button', { name: 'Browse all requests' })).toBeInTheDocument();
});

it('renders nothing extra when the count read fails', async () => {
stubEnvironment(new Error('403'));
renderInbox();

await waitFor(() => expect(countQueries()).toHaveLength(1));
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByTestId('approvals-inbox-waiting-on-others')).toBeNull();
});
});
11 changes: 7 additions & 4 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3184,10 +3184,13 @@ export function registerFieldRenderer(type: string, renderer: React.FC<CellRende
* {@link registerFieldRenderer}: `@object-ui/app-shell` registers its record
* preview card there.
*
* The record details grid (`DetailSection` in `@object-ui/plugin-detail`) is
* the one call site today. ⛔ List cells are deliberately not one: a face that
* reads the target record costs one request per row there, so the list face
* waits for a read that batches target rows per object.
* Two call sites ask it. The record details grid (`DetailSection` in
* `@object-ui/plugin-detail`) draws the pair with the renderer registered under
* {@link RECORD_POINTER_CARD_TYPE}. The list grid (`ObjectGrid` in
* `@object-ui/plugin-grid`, objectui#12608) draws it with its own cell face,
* not with that renderer: a face that reads its target one record at a time
* costs one request per row in a list, so the list face batches the page's
* targets into one read per object.
*/
export const RECORD_POINTER_CARD_TYPE = 'record_pointer_card';

Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ const ar = {
pullToRefresh: "اسحب للتحديث",
refreshing: "جارٍ التحديث…",
openRecord: "فتح السجل",
recordUnavailable: "{{object}} · السجل غير متاح",
rowHeight: "ارتفاع الصف: {{mode}}",
openMenu: "فتح القائمة",
empty: "فارغ",
Expand Down Expand Up @@ -4070,6 +4071,13 @@ const ar = {
emptyPending: 'كل شيء منجز — لا يوجد ما ينتظر موافقتك.',
emptyOther: 'لا يوجد شيء هنا بعد.',
emptyViewAll: 'عرض كل الطلبات',
waitingOnOthers: "{{count}} طلب ينتظر موافقين آخرين",
waitingOnOthers_zero: "{{count}} طلب ينتظر موافقين آخرين",
waitingOnOthers_one: "طلب واحد ({{count}}) ينتظر موافقين آخرين",
waitingOnOthers_two: "طلبان ({{count}}) ينتظران موافقين آخرين",
waitingOnOthers_few: "{{count}} طلبات تنتظر موافقين آخرين",
waitingOnOthers_many: "{{count}} طلبًا ينتظر موافقين آخرين",
waitingOnOthers_other: "{{count}} طلب ينتظر موافقين آخرين",
noMatches: 'لا نتائج مطابقة للمرشحات الحالية.',
keyboardHint: 'لوحة المفاتيح: j/k تنقل · Enter فتح · x تحديد · a موافقة · r رفض',
drawerTitle: 'طلب موافقة',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const de = {
pullToRefresh: "Zum Aktualisieren ziehen",
refreshing: "Wird aktualisiert…",
openRecord: "Datensatz öffnen",
recordUnavailable: "{{object}} · Datensatz nicht verfügbar",
rowHeight: "Zeilenhöhe: {{mode}}",
openMenu: "Menü öffnen",
empty: "Leer",
Expand Down Expand Up @@ -3767,6 +3768,9 @@ const de = {
emptyPending: 'Alles erledigt — nichts wartet auf Sie.',
emptyOther: 'Hier ist noch nichts.',
emptyViewAll: 'Alle Anfragen ansehen',
waitingOnOthers: "{{count}} Anfragen warten auf andere Genehmigende",
waitingOnOthers_one: "{{count}} Anfrage wartet auf andere Genehmigende",
waitingOnOthers_other: "{{count}} Anfragen warten auf andere Genehmigende",
noMatches: 'Keine Treffer für die aktuellen Filter.',
keyboardHint: 'Tastatur: j/k bewegen · Enter öffnen · x auswählen · a genehmigen · r ablehnen',
drawerTitle: 'Genehmigungsanfrage',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ const en = {
pullToRefresh: 'Pull to refresh',
refreshing: 'Refreshing…',
openRecord: 'Open record',
recordUnavailable: '{{object}} · record unavailable',
openMenu: 'Open menu',
bulkSelected: '{{count}} selected',
bulkSelected_one: '{{count}} selected',
Expand Down Expand Up @@ -4435,6 +4436,9 @@ const en = {
emptyPending: "You're all caught up — nothing is waiting on you.",
emptyOther: 'Nothing here yet.',
emptyViewAll: 'Browse all requests',
waitingOnOthers: '{{count}} requests are waiting on other approvers',
waitingOnOthers_one: '{{count}} request is waiting on other approvers',
waitingOnOthers_other: '{{count}} requests are waiting on other approvers',
noMatches: 'No matches for current filters.',
keyboardHint: 'Keyboard: j/k move · Enter open · x select · a approve · r reject',
drawerTitle: 'Approval Request',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ const es = {
pullToRefresh: "Desliza para actualizar",
refreshing: "Actualizando…",
openRecord: "Abrir registro",
recordUnavailable: "{{object}} · registro no disponible",
rowHeight: "Altura de fila: {{mode}}",
openMenu: "Abrir menú",
empty: "Vacío",
Expand Down Expand Up @@ -3841,6 +3842,10 @@ const es = {
emptyPending: 'Todo al día: nada espera tu aprobación.',
emptyOther: 'Aún no hay nada aquí.',
emptyViewAll: 'Ver todas las solicitudes',
waitingOnOthers: "{{count}} solicitudes esperan a otros aprobadores",
waitingOnOthers_one: "{{count}} solicitud espera a otros aprobadores",
waitingOnOthers_many: "{{count}} de solicitudes esperan a otros aprobadores",
waitingOnOthers_other: "{{count}} solicitudes esperan a otros aprobadores",
noMatches: 'Sin coincidencias con los filtros actuales.',
keyboardHint: 'Teclado: j/k mover · Enter abrir · x seleccionar · a aprobar · r rechazar',
drawerTitle: 'Solicitud de aprobación',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ const fr = {
pullToRefresh: "Tirer pour actualiser",
refreshing: "Actualisation…",
openRecord: "Ouvrir l'enregistrement",
recordUnavailable: "{{object}} · enregistrement indisponible",
rowHeight: "Hauteur de ligne : {{mode}}",
openMenu: "Ouvrir le menu",
empty: "Vide",
Expand Down Expand Up @@ -3838,6 +3839,10 @@ const fr = {
emptyPending: 'Tout est à jour — rien n\'attend votre approbation.',
emptyOther: 'Rien ici pour l\'instant.',
emptyViewAll: 'Voir toutes les demandes',
waitingOnOthers: "{{count}} demandes attendent d'autres approbateurs",
waitingOnOthers_one: "{{count}} demande attend d'autres approbateurs",
waitingOnOthers_many: "{{count}} de demandes attendent d'autres approbateurs",
waitingOnOthers_other: "{{count}} demandes attendent d'autres approbateurs",
noMatches: 'Aucun résultat avec les filtres actuels.',
keyboardHint: 'Clavier : j/k déplacer · Entrée ouvrir · x sélectionner · a approuver · r rejeter',
drawerTitle: 'Demande d\'approbation',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const ja = {
pullToRefresh: "引っ張って更新",
refreshing: "更新中…",
openRecord: "レコードを開く",
recordUnavailable: "{{object}} · レコードを表示できません",
rowHeight: "行の高さ: {{mode}}",
openMenu: "メニューを開く",
empty: "空",
Expand Down Expand Up @@ -3769,6 +3770,9 @@ const ja = {
emptyPending: 'すべて処理済みです。あなたの承認待ちはありません。',
emptyOther: 'まだ何もありません。',
emptyViewAll: 'すべてのリクエストを見る',
waitingOnOthers: "{{count}} 件の申請が他の承認者を待っています",
waitingOnOthers_one: "{{count}} 件の申請が他の承認者を待っています",
waitingOnOthers_other: "{{count}} 件の申請が他の承認者を待っています",
noMatches: '現在のフィルターに一致するものはありません。',
keyboardHint: 'ショートカット:j/k 移動 · Enter 開く · x 選択 · a 承認 · r 却下',
drawerTitle: '承認リクエスト',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const ko = {
pullToRefresh: "당겨서 새로고침",
refreshing: "새로고침 중…",
openRecord: "레코드 열기",
recordUnavailable: "{{object}} · 레코드를 사용할 수 없음",
rowHeight: "행 높이: {{mode}}",
openMenu: "메뉴 열기",
empty: "비어 있음",
Expand Down Expand Up @@ -3766,6 +3767,9 @@ const ko = {
emptyPending: '모두 처리했습니다. 승인 대기 항목이 없습니다.',
emptyOther: '아직 아무것도 없습니다.',
emptyViewAll: '전체 요청 보기',
waitingOnOthers: "{{count}}건의 요청이 다른 승인자를 기다리고 있습니다",
waitingOnOthers_one: "{{count}}건의 요청이 다른 승인자를 기다리고 있습니다",
waitingOnOthers_other: "{{count}}건의 요청이 다른 승인자를 기다리고 있습니다",
noMatches: '현재 필터와 일치하는 항목이 없습니다.',
keyboardHint: '단축키: j/k 이동 · Enter 열기 · x 선택 · a 승인 · r 거부',
drawerTitle: '승인 요청',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ const pt = {
pullToRefresh: "Puxe para atualizar",
refreshing: "Atualizando…",
openRecord: "Abrir registro",
recordUnavailable: "{{object}} · registro indisponível",
rowHeight: "Altura da linha: {{mode}}",
openMenu: "Abrir menu",
empty: "Vazio",
Expand Down Expand Up @@ -3836,6 +3837,10 @@ const pt = {
emptyPending: 'Tudo em dia — nada aguarda sua aprovação.',
emptyOther: 'Nada por aqui ainda.',
emptyViewAll: 'Ver todas as solicitações',
waitingOnOthers: "{{count}} solicitações aguardam outros aprovadores",
waitingOnOthers_one: "{{count}} solicitação aguarda outros aprovadores",
waitingOnOthers_many: "{{count}} de solicitações aguardam outros aprovadores",
waitingOnOthers_other: "{{count}} solicitações aguardam outros aprovadores",
noMatches: 'Nenhum resultado com os filtros atuais.',
keyboardHint: 'Teclado: j/k mover · Enter abrir · x selecionar · a aprovar · r rejeitar',
drawerTitle: 'Solicitação de aprovação',
Expand Down
Loading
Loading