diff --git a/.changeset/12110-hotcrm-rendering.md b/.changeset/12110-hotcrm-rendering.md new file mode 100644 index 0000000000..3d4ff18566 --- /dev/null +++ b/.changeset/12110-hotcrm-rendering.md @@ -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. diff --git a/apps/console/src/components/FormPage.publicHeading.test.tsx b/apps/console/src/components/FormPage.publicHeading.test.tsx index 86a7ddddfb..3d31c6c4f8 100644 --- a/apps/console/src/components/FormPage.publicHeading.test.tsx +++ b/apps/console/src/components/FormPage.publicHeading.test.tsx @@ -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); + }); }); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 13d0256960..95bf75de28 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -1433,7 +1433,19 @@ async function loadPublicForm(slug: string): Promise { // `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, diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx new file mode 100644 index 0000000000..c24f73dfd2 --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx @@ -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:` 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 => SCHEMA); + type Row = Record; + 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(options?.defaultValue ?? key), + language: 'en', + }; + return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + 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 } { + 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): Record { + 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, store: MetadataContextValue | null): Promise { + approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 }); + approvalsApiStub.getRequest.mockResolvedValue({ data: row }); + const page = ( + + + + } /> + + + + ); + render(store ? {page} : 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')); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx index 6bb1e29447..aeaf46546f 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx @@ -422,8 +422,15 @@ 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), 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]'); @@ -431,7 +438,7 @@ describe('Approvals drawer summary — field metadata faces (objectui#11677)', ( // 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 () => { diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index e72c3445ba..67532d07c2 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -77,6 +77,7 @@ import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; +import { useMetadata } from '@object-ui/react'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { isExpandableFieldType } from '@object-ui/core'; import { isSystemManagedField, type FieldMetadata } from '@object-ui/types'; @@ -213,20 +214,93 @@ function formatDate(s: string | null | undefined, locale: string): string { try { return new Date(s).toLocaleString(locale); } catch { return s; } } +/** The approver prefix whose value is a position's machine NAME (ADR-0090 D3). */ +const POSITION_REF_PREFIX = 'position:'; + /** * Render an actor/approver identifier in a friendly form. * - emails → shown as-is * - `role:` → shown as "Role: name" + * - `position:` → the position's label (objectui#12110 item 6), from + * `positionLabels` when the caller has them, else the prettified name * - opaque 16+ char IDs → truncated middle (e.g. `5aF9BX3J…wTk`) + * + * The position arm is finding C-18 of the HotCRM browser pass: a position + * reference carries a machine NAME, not an opaque id, yet the truncation arm + * below cut `position:sales_manager` to `positi…ager`. A name has prose to + * recover, so it is never truncated; see {@link usePositionLabels} for where + * the label comes from. */ -function formatIdentity(id: string | null | undefined): string { +function formatIdentity( + id: string | null | undefined, + positionLabels?: ReadonlyMap, +): string { if (!id) return '—'; if (id.includes('@')) return id; if (id.startsWith('role:')) return `Role: ${id.slice(5)}`; + if (id.startsWith(POSITION_REF_PREFIX) && id.length > POSITION_REF_PREFIX.length) { + const name = id.slice(POSITION_REF_PREFIX.length); + return positionLabels?.get(name) || prettifyMachineName(name); + } if (id.length > 14) return `${id.slice(0, 6)}…${id.slice(-4)}`; return id; } +/** The empty answer, one object, so an unasked row hands back a stable value. */ +const NO_POSITION_LABELS: ReadonlyMap = new Map(); + +/** + * The labels of the `position:` approvers on `row` that the SERVER left + * unresolved (objectui#12110 item 6), keyed by position name. + * + * Read from the console's metadata store — the `position` registry, which + * ADR-0131 D3 makes positions' one home, through the same + * `useMetadata().ensureType('position')` read Setup's sharing-rule recipient + * picker makes (objectui#7611). The store fetches the type once per session + * and shares it, so this adds no request on a page that already read it, and + * it asks NOTHING when every approver is server-resolved or no approver is a + * position: `pending_approver_names` stays authoritative, as it is in + * {@link approverDisplay}. + * + * Keyed on a string of the names (AGENTS.md #10), and the store is read + * through a ref: its context value is rebuilt whenever any type it caches + * settles. + */ +function usePositionLabels(row: ApprovalRequestRow | null | undefined): ReadonlyMap { + const store = useMetadata(); + const storeRef = useRef(store); + // Synced in an effect, not during render; declared before the read below, + // so it runs first in the same commit. + useEffect(() => { + storeRef.current = store; + }); + const wantedKey = (row?.pending_approvers ?? []) + .filter((a): a is string => + typeof a === 'string' && a.startsWith(POSITION_REF_PREFIX) && !row?.pending_approver_names?.[a]) + .join('|'); + const [labels, setLabels] = useState>(NO_POSITION_LABELS); + useEffect(() => { + if (!wantedKey) return; + let cancelled = false; + storeRef.current.ensureType('position').then( + (items) => { + if (cancelled) return; + const next = new Map(); + for (const item of Array.isArray(items) ? items : []) { + const name = (item as { name?: unknown } | null)?.name; + const label = (item as { label?: unknown } | null)?.label; + if (typeof name === 'string' && typeof label === 'string' && label !== '') next.set(name, label); + } + setLabels(next); + }, + // A store that cannot list positions leaves the prettified names. + () => {}, + ); + return () => { cancelled = true; }; + }, [wantedKey]); + return labels; +} + /** `manager_review` → "Manager Review" (display fallback for legacy rows). */ function prettifyMachineName(raw: string | null | undefined): string { if (!raw) return '—'; @@ -246,8 +320,8 @@ function submitterDisplay(r: ApprovalRequestRow): string { return r.submitter_name || formatIdentity(r.submitter_id); } /** Approver chip text: server-resolved display name, else readable identity. */ -function approverDisplay(a: string, r: ApprovalRequestRow): string { - return r.pending_approver_names?.[a] || formatIdentity(a); +function approverDisplay(a: string, r: ApprovalRequestRow, positionLabels?: ReadonlyMap): string { + return r.pending_approver_names?.[a] || formatIdentity(a, positionLabels); } /** The group(s) a pending approver represents (会签), joined for display. */ function approverGroup(a: string, r: ApprovalRequestRow): string | undefined { @@ -265,11 +339,12 @@ function approverGroup(a: string, r: ApprovalRequestRow): string | undefined { */ function approverChips( r: ApprovalRequestRow, + positionLabels?: ReadonlyMap, ): Array<{ label: string; group?: string; count: number; title: string }> { const order: string[] = []; const byKey = new Map(); for (const a of r.pending_approvers || []) { - const label = approverDisplay(a, r); + const label = approverDisplay(a, r, positionLabels); const group = approverGroup(a, r); const key = group ? `${label} ${group}` : label; const seen = byKey.get(key); @@ -627,7 +702,23 @@ const AMOUNT_KEY_RE = /(amount|total|price|value|cost|sum|budget|salary|fee|reve * `defs` is the object's field declarations, which only the drawer has * (objectui#11677). When given, a key the object does not declare is not a * candidate, by the same rule as the drawer's field grid. The queue passes - * nothing, which keeps today's choice. + * nothing. + * + * ## Which candidate leads (objectui#12110 item 6) + * + * The first key whose NAME matched {@link AMOUNT_KEY_RE} used to lead, zero or + * not. HotCRM's account (finding C-18 of the HotCRM browser pass) 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". So, in order: + * + * 1. a field the object DECLARES as an amount — `type: 'currency'`, read off + * `defs` — and among those the first non-zero one, else the first one: a + * declared amount's zero is the record's amount; + * 2. else the first NAME match whose value is not zero. A name match is a + * guess about what the key means, and a zero from a guessed key is the + * headline the finding measured, so it never leads. Without `defs` (the + * queue) every candidate is a name match. */ function decisionAmountEntry( r: ApprovalRequestRow, @@ -637,23 +728,32 @@ function decisionAmountEntry( ): { key: string; label: string; value: number; display: string } | null { const payload = r.payload; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + let declaredZero: { key: string; label: string; value: number; display: string } | null = null; + let nameMatch: { key: string; label: string; value: number; display: string } | null = null; for (const [k, v] of Object.entries(payload as Record)) { if (isBookkeepingKey(k, defs?.[k])) continue; if (hiddenKeys.has(k)) continue; // author declared `hidden: true` (#5565) if (defs && !defs[k]) continue; // not a field of this object (#11677) - if (!AMOUNT_KEY_RE.test(k)) continue; + const declaredAmount = defs?.[k]?.type === 'currency'; + if (!declaredAmount && !AMOUNT_KEY_RE.test(k)) continue; const num = typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : null); if (num == null || !Number.isFinite(num)) continue; - return { + const entry = { key: k, label: r.payload_labels?.[k] ?? prettifyKey(k), value: num, display: r.payload_display?.[k] ?? num.toLocaleString(locale), }; + if (declaredAmount) { + if (num !== 0) return entry; + declaredZero ??= entry; + } else if (num !== 0) { + nameMatch ??= entry; + } } - return null; + return declaredZero ?? nameMatch; } /** The page's scoped translator, as the row cells receive it. */ @@ -1103,6 +1203,8 @@ export function ApprovalsInboxPage() { * before. See `useRequestFieldDefs` for the cost and the fail-open choice. */ const requestFieldDefs = useRequestFieldDefs(selected?.object_name); + // The open request's position approvers, named (objectui#12110 item 6). + const selectedPositionLabels = usePositionLabels(selected); const { fieldLabel } = useSafeFieldLabel(); // Approve/reject/reassign/send-back/… are server-declared actions rendered by // DeclaredActionsBar (objectui#2697 + framework#3300); their param dialog @@ -2465,7 +2567,7 @@ export function ApprovalsInboxPage() { {tr('waitingOn', 'Waiting on')}
- {approverChips(selected).map((chip, i) => ( + {approverChips(selected, selectedPositionLabels).map((chip, i) => ( {chip.label} {chip.group && ( @@ -2727,7 +2829,7 @@ export function ApprovalsInboxPage() { {canRecall ? tr('whyDisabledSubmitter', 'You submitted this request, so you can recall it — but only the assigned approvers can approve or reject.') : tr('whyDisabled', 'Only the assigned approvers can act on this request. It is waiting on: {{who}}.', { - who: (selected.pending_approvers || []).map(a => approverDisplay(a, selected)).join(', ') || '—', + who: (selected.pending_approvers || []).map(a => approverDisplay(a, selected, selectedPositionLabels)).join(', ') || '—', })}
)} diff --git a/content/docs/guide/public-forms.md b/content/docs/guide/public-forms.md index 8bdcad49cb..702a060251 100644 --- a/content/docs/guide/public-forms.md +++ b/content/docs/guide/public-forms.md @@ -265,7 +265,9 @@ spec-merging code path: URL parameters of the form `?prefill_=` populate the matching fields on mount; the rest of the chrome (label, section columns, post-submit -behaviour) comes from the `FormView` spec itself. +behaviour) comes from the `FormView` spec itself. The public page's heading is +the form's `title`; a form with no title is headed by its object's label, and +the object's API name is used only when the payload carries no label at all. For richer public forms with anti-spam, GDPR consent, prefill whitelisting and open-redirect protection, host `EmbeddableForm` directly inside your own diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 06a605e19b..e79463fe7a 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -84,7 +84,7 @@ has **23 declared keys**, and `name` is the only **required** one: `type` and | `description` | `string` | help text rendered under the control | | `type` | `string` | optional, defaults to `'input'`. Built-ins: `input`, `textarea`, `checkbox`, `switch`, `select`; any other value resolves through the registry (`field:` first, then the bare name) | | `inputType` | `string` | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, … | -| `widget` | `string` | widget override; wins over `type` (spec `FormField.widget`) | +| `widget` | `string` | widget override; wins over `type` when it can render — a registered `field:`, a built-in control, or a native email / password input. Any other name falls back to `type`, as the spec's `FieldSchema.widget` says ("Degrades to the `type` renderer when unregistered") | | `placeholder` | `string` | placeholder text | | `required` | `boolean` | the presence rule. `validation.required` does **not** make a field required — it only supplies the message | | `disabled` | `boolean` | not interactive, muted | diff --git a/content/docs/plugins/plugin-gantt.mdx b/content/docs/plugins/plugin-gantt.mdx index 6d9653ee83..fd885e97b2 100644 --- a/content/docs/plugins/plugin-gantt.mdx +++ b/content/docs/plugins/plugin-gantt.mdx @@ -27,6 +27,10 @@ The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for Obje - **Progress Tracking**: Display task completion percentage (0-100%) - **Dependencies**: Visualize task dependencies and relationships - **Date Ranges**: Automatic date range calculation +- **Dates name their year**: the task list's Start / End cells and date line, + the bar tooltip and the drag previews add the year to a date outside the + current year (two digits on the compact faces), so a plan crossing a year + boundary never shows two different days alike - **Interactive**: Click handling for tasks - **Drag-and-drop rescheduling**: Drag a bar to move it; drag either edge to resize start/end. Snaps to whole days, keeps each date's time of day across diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 16cc4268c2..df0c1d2a3f 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -181,7 +181,7 @@ re-exported from `@object-ui/types`; it is the type of `ObjectGridSchema`'s | Key | Type | Meaning | | --- | --- | --- | | `field` | `string` (**required**) | The field this column reads. There is no `accessorKey`. | -| `label` | `string \| Record` | Header text, or an inline locale map. There is no `header`. | +| `label` | `string \| Record` | Header text, or an inline locale map. There is no `header`. An authored label is the header as written; a column without one takes the field's (translated) label. | | `width` | `number` | Column width in pixels. | | `align` | `'left' \| 'center' \| 'right'` | Cell alignment. | | `hidden` | `boolean` | Hidden by default, revealable in the column chooser. | diff --git a/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx b/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx index 1faeb02756..851f666215 100644 --- a/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx +++ b/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx @@ -62,6 +62,13 @@ const IDS = [ * Measured on `origin/main` @ `d88e20f55` through `measure()` below, mirror * untouched. `sha256` is over the full `textContent`; `visibleText` is the * same string with the chart's `