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
19 changes: 19 additions & 0 deletions .changeset/12110-hotcrm-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/components': patch
'@object-ui/fields': patch
'@object-ui/plugin-form': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-gantt': patch
'@object-ui/console': patch
---

Console rendering fixes from the HotCRM browser pass (objectui#12110):

- **Grid:** a view column's authored `label` is the header. It used to lose to the field's translated label whenever the app bundle translated the field.
- **Form:** a field `widget` that names no registered component falls back to the field type's renderer, as the spec's `widget` description says. A `rating` field declaring `widget: 'star_rating'` now draws the star widget instead of a text box. A built-in control name (`input`, `textarea`, …) and the native password and email inputs still render as before.
- **Cells:** a `rating` value draws stars out of the field's `max` (5 by default) in list, detail and related-list cells, instead of a bare number.
- **Reference rail:** a related row is named by its object's declared `nameField`, and the rail refetches in place when the record, a related object or everything is invalidated. A record action with `refreshAfter: true` therefore refreshes the rail too.
- **Gantt:** the task list, the tooltip and the drag previews show a date's year when it is not the current year.
- **Approvals drawer:** a `position:` approver shows the position's label, from the console's position registry, instead of a truncated id. The headline figure prefers the object's declared `currency` field and never shows a zero from a field matched only by its name.
- **Public form:** a form with no title of its own is headed by its object's label, not the object's API name.
28 changes: 22 additions & 6 deletions apps/console/src/components/FormPage.publicHeading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,34 @@ describe('objectui#8408 — public form heading and description', () => {
expect(header.querySelector('h1 ~ p')?.textContent).toBe(DESCRIPTION);
});

it('NO_TITLE_STILL_FALLS_BACK — a form with neither title nor label keeps the API name', async () => {
// The no-widening control. This card adds one arm to a fallback chain; it
// does not change what happens when every arm legitimately misses, and a
// renderer that started printing something else there would be a different
// change than the one that was ruled.
it('NO_TITLE_TAKES_THE_OBJECT_LABEL — a form with neither title nor label is headed by the object\'s label (objectui#12110 item 7)', async () => {
// This leg used to pin the API name here, as the no-widening control of
// objectui#8408. The HotCRM browser pass (finding A-09) measured that
// fallback as the defect it is — an anonymous visitor greeted by the
// database table name — and objectui#12110 rules the object's label in
// ahead of it. The served payload carries that label on `objectSchema`.
const { title, description, ...untitled } = APPLY_FORM;
void title;
void description;
renderPublic({ ...SERVED, form: untitled });
const header = await liveHeader();

expect(header.querySelector('h1')?.textContent).toBe(API_NAME);
expect(header.querySelector('h1')?.textContent).toBe(INQUIRY_SCHEMA.label);
expect(header.textContent).not.toContain(API_NAME);
expect(header.querySelector('h1 ~ p')).toBeNull();
});

it('NO_LABEL_ANYWHERE_FALLS_BACK — with no object label either, the API name is the last resort', async () => {
// The no-widening control, moved one arm down: when every arm before it
// legitimately misses, the chain still ends where it always ended.
const { title, description, ...untitled } = APPLY_FORM;
void title;
void description;
const { label, ...unlabelledSchema } = INQUIRY_SCHEMA;
void label;
renderPublic({ ...SERVED, form: untitled, objectSchema: unlabelledSchema });
const header = await liveHeader();

expect(header.querySelector('h1')?.textContent).toBe(API_NAME);
});
});
14 changes: 13 additions & 1 deletion apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,19 @@ async function loadPublicForm(slug: string): Promise<LoadedForm> {
// `payload.label` keeps its precedence: when an envelope identity label
// does arrive it is a deliberate per-view name, and this card changed the
// fallback ORDER, not which value wins where one already did.
label: payload.label ?? payload.form?.title ?? payload.form?.label ?? payload.object,
//
// objectui#12110 item 7 (finding A-09 of the HotCRM browser pass): a form
// that names itself nowhere is headed by its OBJECT's label, which this
// endpoint serves on `objectSchema`, before the API name — the database
// table name is what an anonymous visitor must never be greeted by. The
// API name stays the last resort, for a payload that carries no label at
// all; `||` rather than `??` there so an empty label cannot blank the
// heading.
label:
payload.label
?? payload.form?.title
?? payload.form?.label
?? (payload.objectSchema?.label || payload.object),
object: payload.object,
form: payload.form,
objectSchema: payload.objectSchema,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Approvals drawer — the approver chip names a position by its label, and the
* headline figure is the object's declared amount (objectui#12110 item 6,
* finding C-18 of the HotCRM browser pass).
*
* ## The approver chip
*
* A `position:<name>` approver the server did not resolve went through the
* page's `formatIdentity`, which middle-truncates anything over 14 characters:
* `position:sales_manager` read `positi…ager`. It now reads the position's
* label from the console's metadata store — the `position` registry
* (ADR-0131 D3), the same `ensureType('position')` read Setup's sharing-rule
* picker makes, fetched once per session and shared — and the prettified
* machine name when the registry has no such item. The raw reference stays in
* the chip's `title`.
*
* ## The headline figure
*
* `decisionAmountEntry` took the first payload key matching an amount-ish
* pattern. HotCRM's account declares `annual_revenue` as a `currency` field
* and `child_account_revenue` as a `summary` roll-up of its children, and the
* drawer led with "Child Account Revenue 0". A field the object DECLARES as an
* amount (`currency`) now wins over a name match, and a name match is never
* headlined when its value is zero.
*
* Fixtures are the HotCRM account's own two declarations (`account.object.ts`).
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, within, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';

const APP = 'com.example.crm';

const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA } = vi.hoisted(() => {
const SCHEMA = {
name: 'crm_account',
label: 'Account',
fields: {
name: { type: 'text', label: 'Account Name' },
annual_revenue: { type: 'currency', label: 'Annual Revenue' },
child_account_revenue: {
type: 'summary',
label: 'Child Account Revenue',
summaryOperations: { object: 'crm_account', field: 'annual_revenue', function: 'sum' },
},
industry: { type: 'text', label: 'Industry' },
},
};
const getObjectSchema = vi.fn(async (): Promise<unknown> => SCHEMA);
type Row = Record<string, unknown>;
const approvalsApiStub = {
listRequests: vi.fn(async (): Promise<{ data: Row[]; total: number }> => ({ data: [], total: 0 })),
getRequest: vi.fn(async (): Promise<{ data: Row | null }> => ({ data: null })),
listActions: vi.fn(async () => ({ data: [] })),
approve: vi.fn(async () => ({ data: null, finalized: true })),
reject: vi.fn(async () => ({ data: null, finalized: true })),
};
const ADAPTER = { find: vi.fn(async () => ({ data: [] })), getObjectSchema };
const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } };
const I18N = {
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
};
return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA };
});

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,
}));

// Imported after the mocks so the page picks them up.
import { ApprovalsInboxPage } from './ApprovalsInboxPage';

function permissionsPayload(): MePermissionsResponse {
return {
authenticated: true,
userId: 'u_1',
tenantId: 't_1',
roles: [],
permissionSets: [],
objects: {},
fields: {},
systemPermissions: ['setup.access'],
};
}

/** The `position` registry as `GET /api/v1/meta/position` serves it. */
const POSITIONS = [
{ name: 'sales_manager', label: 'Regional Sales Manager' },
{ name: 'auditor', label: 'Auditor' },
];

/** A metadata store answering the `position` registry; STABLE across renders. */
function makeStore(positions: unknown[]): MetadataContextValue & { ensureType: ReturnType<typeof vi.fn> } {
return {
apps: [], objects: [], dashboards: [], reports: [], pages: [],
loading: false, error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: vi.fn(async (type: string) => (type === 'position' ? positions : [])),
getItem: async () => null,
getItemsByType: () => [],
};
}

function request(over: Record<string, unknown>): Record<string, unknown> {
return {
id: 'req_1',
status: 'pending',
process_name: 'crm_account_credit',
process_label: 'Credit Limit Review',
object_name: 'crm_account',
object_label: 'Account',
record_id: 'acc_1',
record_title: 'Fabrikam',
submitter_id: 'u_9',
submitter_name: 'Dev Admin',
submitted_at: '2026-10-06T05:00:00.000Z',
pending_approvers: ['position:sales_manager'],
payload: { name: 'Fabrikam', industry: 'Retail' },
...over,
};
}

async function openedDrawer(row: Record<string, unknown>, store: MetadataContextValue | null): Promise<HTMLElement> {
approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 });
approvalsApiStub.getRequest.mockResolvedValue({ data: row });
const page = (
<MePermissionsProvider initialPermissions={permissionsPayload()}>
<MemoryRouter initialEntries={[`/apps/${APP}/system/approvals?request=${String(row.id)}`]}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>
</MePermissionsProvider>
);
render(store ? <MetadataCtx.Provider value={store}>{page}</MetadataCtx.Provider> : page);
const dialog = await screen.findByRole('dialog');
await within(dialog).findByText(String(row.process_label));
return dialog;
}

const chip = (dialog: HTMLElement, ref: string) => within(dialog).getByTitle(ref);

beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
getObjectSchema.mockClear();
getObjectSchema.mockImplementation(async () => SCHEMA);
for (const fn of Object.values(approvalsApiStub)) fn.mockClear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe('Approvals drawer — a position approver reads as the position (objectui#12110 item 6)', () => {
it('THE DEFECT — `position:sales_manager` reads the registry label, never "positi…ager"', async () => {
const store = makeStore(POSITIONS);
const dialog = await openedDrawer(request({}), store);
await waitFor(() => expect(chip(dialog, 'position:sales_manager')).toHaveTextContent('Regional Sales Manager'));
expect(within(dialog).queryByText(/positi…ager/)).toBeNull();
expect(store.ensureType).toHaveBeenCalledWith('position');
});

it('a position the registry does not carry reads as its prettified machine name', async () => {
const dialog = await openedDrawer(request({ pending_approvers: ['position:regional_controller'] }), makeStore([]));
await waitFor(() => expect(chip(dialog, 'position:regional_controller')).toHaveTextContent('Regional Controller'));
expect(within(dialog).queryByText(/positi…/)).toBeNull();
});

it('CONTROL: the server\'s own name still wins, and the registry is not asked', async () => {
const store = makeStore(POSITIONS);
const dialog = await openedDrawer(
request({ pending_approver_names: { 'position:sales_manager': 'Sales Manager (served)' } }),
store,
);
expect(chip(dialog, 'position:sales_manager')).toHaveTextContent('Sales Manager (served)');
expect(store.ensureType).not.toHaveBeenCalledWith('position');
});
});

describe('Approvals drawer — the headline is the declared amount (objectui#12110 item 6)', () => {
const lead = (dialog: HTMLElement) => dialog.querySelector('[data-summary-lead]');

it('THE DEFECT — a zero roll-up named like an amount does not outrank the declared currency field', async () => {
const dialog = await openedDrawer(
request({ payload: { name: 'Fabrikam', child_account_revenue: 0, annual_revenue: 5000000, industry: 'Retail' } }),
makeStore(POSITIONS),
);
await waitFor(() => expect(lead(dialog)?.getAttribute('data-summary-lead')).toBe('annual_revenue'));
expect(lead(dialog)?.children[0]?.textContent).toBe('Annual Revenue');
});

it('a zero from a name match alone is never the headline', async () => {
const dialog = await openedDrawer(
request({ payload: { name: 'Fabrikam', child_account_revenue: 0, industry: 'Retail' } }),
makeStore(POSITIONS),
);
await waitFor(() => expect(getObjectSchema).toHaveBeenCalled());
// Settled on the declared labels before reading the absence.
await waitFor(() => expect(dialog.querySelector('[data-summary-field="industry"]')).not.toBeNull());
expect(lead(dialog)).toBeNull();
});

it('CONTROL: the declared amount field leads with its own zero — that zero is the record\'s amount', async () => {
const dialog = await openedDrawer(
request({ payload: { name: 'Fabrikam', annual_revenue: 0, industry: 'Retail' } }),
makeStore(POSITIONS),
);
await waitFor(() => expect(lead(dialog)?.getAttribute('data-summary-lead')).toBe('annual_revenue'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -422,16 +422,23 @@ describe('Approvals drawer summary — field metadata faces (objectui#11677)', (
});

it('labels the lead from the metadata read, as the record page does — not from `payload_labels`', async () => {
useRequest(INVOICE);
const dialog = await openedDrawer(INVOICE);
// A billed invoice: `total` is a `summary` roll-up, so it leads only with a
// non-zero value — the dogfood invoice's own `total: 0` is a zero from a
// name match, which objectui#12110 item 6 keeps out of the headline.
const billed = {
...INVOICE,
payload: { ...(INVOICE.payload as Record<string, unknown>), total: 1180 },
};
useRequest(billed);
const dialog = await openedDrawer(billed);
await waitFor(() => expect(summaryRows(dialog).get('region')?.value).toBe('EMEA'));

const lead = dialog.querySelector('[data-summary-lead]');
expect(lead?.getAttribute('data-summary-lead')).toBe('total');
// The record page reads "Total" for this field; the snapshot's server
// label ("Subtotal") is what the card showed before.
expect(lead?.children[0]?.textContent).toBe('Total');
expect((lead?.children[1]?.textContent ?? '').trim()).toBe(recordPageFace('showcase_invoice', 'total', 0));
expect((lead?.children[1]?.textContent ?? '').trim()).toBe(recordPageFace('showcase_invoice', 'total', 1180));
});

it('a payload key the object does not declare takes no row once the schema answered', async () => {
Expand Down
Loading
Loading