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
13 changes: 13 additions & 0 deletions .changeset/27502-approval-summary-as-submitted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(app-shell): the approval decision panel's record summary is headed "As submitted" (#27502)

Clause-②: yes (widening)
Metadata-protocol: none

The summary the approval request page's decision panel (`record:approval_decision`) draws comes from the request's record snapshot: the target record as it was submitted, kept as the request's evidence of what was submitted. After a decision the request's status, which the page's highlights show, moves on while the snapshot keeps the submitted values, so the summary read as the record's current state and the page contradicted itself. The summary is now a section headed "As submitted", in the muted label style of the panel's "Waiting on", and that heading is its accessible name. A pending request carries the heading too, and a summary that draws nothing draws no heading. The values, the rows and the reads are unchanged: the panel still reads no record, and the record's current values stay on the Details tab's record card.

New language-pack key in all ten packs: `approvalsInbox.snapshotAsSubmitted`.
6 changes: 5 additions & 1 deletion ui/packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,11 @@ key that only looks like an amount by its name never leads. A key the object
does not declare, a framework column, an
unresolved reference id, a field declared `hidden: true` and a field the
reader may not read take no row. Without the target object's definition, the
summary is not drawn.
summary is not drawn. The summary is headed "As submitted"
(`approvalsInbox.snapshotAsSubmitted`): the snapshot is the target record as it
was submitted, not its current state, so after a decision its values can
differ from the request's status in the page's highlights. The record's
current values are the Details tab's record card.

Two more parts come from the same request row. A request no person submitted
(its `submitter_id` absent or null, as the approvals get serves a request a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* 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.
*/

/**
* After a decision, the approval request page must not tell two stories
* (objectstack#27502, ruling record 6499914328). The page's highlights read the
* REQUEST row, whose status moves to approved; the decision panel's summary
* reads the request's `payload`, the target record as it was SUBMITTED, which
* keeps its submitted status (objectstack#10749, ruling record 5377367175: the
* snapshot is kept as the evidence of what was submitted). So the summary is
* labelled as the submitted values: a section whose accessible name is the
* pack's `approvalsInbox.snapshotAsSubmitted` row, holding the snapshot's own
* values.
*
* The rows are in the shape the approvals get serves the request page after a
* reload: a decided request, its snapshot still at submission. The declared
* actions bar is doubled: what is under test is the summary's label.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';

vi.mock('../DeclaredActionsBar.js', () => ({
DeclaredActionsBar: () => <div data-testid="declared-actions-probe" />,
}));

import { MetadataCtx, RecordContextProvider } from '@object-ui/react';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel';
import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory';

const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });

/** The heading's pack key, and what one pack says for it. */
const HEADING_KEY = 'approvalsInbox.snapshotAsSubmitted';
const packHeading = (language: string): unknown =>
HEADING_KEY.split('.').reduce<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
(builtInLocales as Record<string, unknown>)[language],
);

/** The pack row the heading must come from, rendered by the instance under test. */
const HEADING = String(i18n.t(HEADING_KEY));

const EXPENSE_REPORT = {
name: 'showcase_expense_report',
fields: {
title: { type: 'text', label: 'Title' },
status: {
type: 'select',
label: 'Status',
options: [
{ value: 'submitted', label: 'Submitted' },
{ value: 'approved', label: 'Approved' },
],
},
approved_amount: { type: 'currency', label: 'Approved' },
},
};

const seen: string[] = [];
const metadata = {
getItem: async (type: string, name: string) => {
seen.push(`${type}:${name}`);
return type === 'object' && name === EXPENSE_REPORT.name ? EXPENSE_REPORT : null;
},
};

/** The target record as it was submitted: it does not move on a decision. */
const SUBMITTED = { title: 'EXP-DEMO', status: 'submitted', approved_amount: 0 };

function requestRow(status: string, payload: Record<string, unknown> = SUBMITTED): Record<string, unknown> {
return {
id: 'areq_27502',
process_name: 'flow:showcase_expense_committee',
object_name: EXPENSE_REPORT.name,
record_id: 'rec_1',
status,
submitter_id: 'u_dev_admin',
pending_approvers: status === 'pending' ? ['u_dev_admin'] : [],
pending_approver_names: { u_dev_admin: 'Dev Admin' },
viewer: { can_act: status === 'pending', is_submitter: false, can_override: false },
payload,
};
}

beforeEach(() => {
resetApproverDirectoryCache();
seen.length = 0;
});

afterEach(() => {
cleanup();
});

function mount(data: Record<string, unknown>): HTMLElement {
render(
<I18nProvider instance={i18n}>
<MetadataCtx.Provider value={metadata as never}>
<RecordContextProvider objectName="sys_approval_request" recordId={data.id as string} data={data}>
<ApprovalDecisionRenderer />
</RecordContextProvider>
</MetadataCtx.Provider>
</I18nProvider>,
);
return screen.getByTestId('approval-decision-panel');
}

describe('ApprovalDecisionPanel — the summary is labelled as the submitted values (objectstack#27502)', () => {
it('a decided request: the snapshot keeps its submitted status, under the heading, and nobody is waited on', async () => {
const panel = mount(requestRow('approved'));
const summary = await within(panel).findByTestId('record-snapshot-summary');

// The decided state the reload serves: nobody is waited on any more.
expect(within(panel).queryByTestId('approval-decision-pending-approvers')).toBeNull();
// The summary is the region the heading names, and the heading is the pack row.
expect(HEADING).not.toBe(HEADING_KEY);
expect(within(panel).getByRole('region', { name: HEADING })).toBe(summary);
expect(summary.querySelector('[data-summary-heading]')?.textContent).toBe(HEADING);
// What the section holds is the snapshot as submitted, under the declared select field.
const status = summary.querySelector('[data-summary-field="status"]');
expect(status?.textContent).toBe('StatusSubmitted');
});

it('a pending request carries the heading too: it names the source, not a status', async () => {
const panel = mount(requestRow('pending'));
const summary = await within(panel).findByTestId('record-snapshot-summary');

expect(within(panel).getByTestId('approval-decision-pending-approvers')).toBeTruthy();
expect(within(panel).getByRole('region', { name: HEADING })).toBe(summary);
});

it('CONTROL: a summary that draws nothing draws no heading', async () => {
// Only keys the object does not declare: the definition answers, and no row is drawn.
const panel = mount(requestRow('approved', { legacy_note: 'x' }));

await waitFor(() => expect(seen).toContain(`object:${EXPENSE_REPORT.name}`));
await new Promise((r) => setTimeout(r, 0));
expect(within(panel).queryByTestId('record-snapshot-summary')).toBeNull();
expect(within(panel).queryByText(HEADING)).toBeNull();
});

it('the heading copy is a key in all ten language packs', () => {
const languages = Object.keys(builtInLocales);
expect(languages).toHaveLength(10);
for (const language of languages) {
const value = packHeading(language);
expect(typeof value, `${language} defines ${HEADING_KEY}`).toBe('string');
expect((value as string).trim(), `${language} defines ${HEADING_KEY}`).not.toBe('');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
* declared labels and through the record page's own cells, with the
* object's `hidden: true` fields and the reader's unreadable fields left
* out. `RecordSnapshotSummary` draws it from the bound row's `payload`
* and `payload_display`; see its header for what it reads.
* and `payload_display`: the target record as SUBMITTED, kept as the
* request's evidence of what was submitted (objectstack#10749), never a
* live read. It is headed "As submitted" so a decided request's snapshot
* never reads as the record's current state (objectstack#27502); the
* current record is the Details tab's pointer card. See its header for
* what it reads.
* 2. **Where the flow stands** (objectui#12491, objectstack#26668) — the
* vertical step line of this request's own route, the bound row's
* `flow_route` as the approvals get serves it: the steps its run passed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@
* refused read), nothing is drawn: without the declarations there is no
* declared label, no face and no `hidden` to honour.
*
* ## Labelled as the submitted values (objectstack#27502)
*
* The snapshot is the record as it was SUBMITTED, kept at rest as the
* request's evidence of what was submitted and served redacted per reader
* (objectstack#10749, ruling record 5377367175, Option B). It is not the
* record's current state: after a decision the record's own status moves on
* while the snapshot's stays. So the summary is a section headed "As
* submitted" (`approvalsInbox.snapshotAsSubmitted`, in the muted label style
* of the panel's "Waiting on"), its accessible name, and a snapshot value
* never reads as the record's current one beside the page's highlights, which
* read the request row (ruling record 6499914328 on objectstack#27502). The
* heading names the source, not a status, so a pending request carries it
* too; a summary that draws nothing draws no heading. The record's current
* values are the Details tab's pointer card's to show, a live read of the
* target that the reader may be refused; this face reads no record.
*
* ## Cost
*
* A row with no snapshot asks for nothing. Otherwise the definition is
Expand All @@ -72,7 +88,7 @@ import { isDeadlineSettled, withoutDeniedFields } from '@object-ui/core';
import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
import { useSafeFieldLabel } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
import { useMetadataItem, usePredicateScope } from '@object-ui/react';
import { useMetadataItem, useObjectTranslation, usePredicateScope } from '@object-ui/react';
import type { FieldMetadata } from '@object-ui/types';
import { distinctLabels, planSnapshotSummary, type FieldDef } from './snapshotSummaryPlan.js';

Expand Down Expand Up @@ -151,6 +167,8 @@ function DeclaredSummary({ target, fields, shown, display }: {
const summed = planSnapshotSummary(shown, fields, display).summed;
const child = useMetadataItem('object', summed);
const { fieldLabel } = useSafeFieldLabel();
const { t } = useObjectTranslation();
const headingId = React.useId();
if (summed !== null && child.loading) return null;

// An unreadable definition leaves every `sum` an ordinary row.
Expand All @@ -172,7 +190,10 @@ function DeclaredSummary({ target, fields, shown, display }: {
);

return (
<div className="space-y-3" data-testid="record-snapshot-summary">
<section className="space-y-3" aria-labelledby={headingId} data-testid="record-snapshot-summary">
<div id={headingId} className="text-[11px] text-muted-foreground mb-1" data-summary-heading>
{String(t('approvalsInbox.snapshotAsSubmitted', { defaultValue: 'As submitted' }))}
</div>
{plan.lead && (
<div data-summary-lead={plan.lead}>
<div className="text-[11px] text-muted-foreground">{labelOf.get(plan.lead)}</div>
Expand All @@ -191,6 +212,6 @@ function DeclaredSummary({ target, fields, shown, display }: {
))}
</div>
)}
</div>
</section>
);
}
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4493,6 +4493,7 @@ const ar = {
progressBar: 'تقدم القرار',
declaredActions: 'الإجراءات',
decisionPanelOffRequestPage: 'لوحة قرار الموافقة تعمل فقط في صفحات طلبات الموافقة.',
snapshotAsSubmitted: 'كما قُدِّم',
attachmentChip: 'مرفق',
attachmentOpenFailed: 'تعذّر فتح المرفق — يرجى المحاولة مرة أخرى',
approveOneTitle: 'الموافقة على "{{title}}"؟',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4156,6 +4156,7 @@ const de = {
progressBar: 'Entscheidungsfortschritt',
declaredActions: 'Aktionen',
decisionPanelOffRequestPage: 'Der Entscheidungsbereich für Genehmigungen funktioniert nur auf Seiten von Genehmigungsanfragen.',
snapshotAsSubmitted: 'Wie eingereicht',
attachmentChip: 'Anhang',
attachmentOpenFailed: 'Anhang konnte nicht geöffnet werden – bitte erneut versuchen',
approveOneTitle: '„{{title}}“ genehmigen?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4935,6 +4935,7 @@ const en = {
progressBar: 'Decision progress',
declaredActions: 'Actions',
decisionPanelOffRequestPage: 'The approval decision panel only works on approval request pages.',
snapshotAsSubmitted: 'As submitted',
attachmentChip: 'Attachment',
attachmentOpenFailed: 'Could not open the attachment — please try again',
approveOneTitle: 'Approve "{{title}}"?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4241,6 +4241,7 @@ const es = {
progressBar: 'Progreso de la decisión',
declaredActions: 'Acciones',
decisionPanelOffRequestPage: 'El panel de decisión de aprobación solo funciona en las páginas de solicitudes de aprobación.',
snapshotAsSubmitted: 'Tal como se envió',
attachmentChip: 'Adjunto',
attachmentOpenFailed: 'No se pudo abrir el archivo adjunto: inténtalo de nuevo',
approveOneTitle: '¿Aprobar «{{title}}»?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4237,6 +4237,7 @@ const fr = {
progressBar: 'Avancement de la décision',
declaredActions: 'Actions',
decisionPanelOffRequestPage: 'Le panneau de décision d\'approbation ne fonctionne que sur les pages de demande d\'approbation.',
snapshotAsSubmitted: 'Tel que soumis',
attachmentChip: 'Pièce jointe',
attachmentOpenFailed: "Impossible d'ouvrir la pièce jointe — veuillez réessayer",
approveOneTitle: 'Approuver « {{title}} » ?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4158,6 +4158,7 @@ const ja = {
progressBar: '承認の進捗',
declaredActions: 'アクション',
decisionPanelOffRequestPage: '承認の決定パネルは承認リクエストのページでのみ機能します。',
snapshotAsSubmitted: '申請時の内容',
attachmentChip: '添付ファイル',
attachmentOpenFailed: '添付ファイルを開けませんでした。もう一度お試しください',
approveOneTitle: '「{{title}}」を承認しますか?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4155,6 +4155,7 @@ const ko = {
progressBar: '결정 진행 상황',
declaredActions: '작업',
decisionPanelOffRequestPage: '승인 결정 패널은 승인 요청 페이지에서만 작동합니다.',
snapshotAsSubmitted: '제출 당시 내용',
attachmentChip: '첨부 파일',
attachmentOpenFailed: '첨부 파일을 열 수 없습니다. 다시 시도해 주세요',
approveOneTitle: '"{{title}}"을(를) 승인할까요?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4236,6 +4236,7 @@ const pt = {
progressBar: 'Progresso da decisão',
declaredActions: 'Ações',
decisionPanelOffRequestPage: 'O painel de decisão de aprovação só funciona nas páginas de solicitações de aprovação.',
snapshotAsSubmitted: 'Conforme enviado',
attachmentChip: 'Anexo',
attachmentOpenFailed: 'Não foi possível abrir o anexo — tente novamente',
approveOneTitle: 'Aprovar "{{title}}"?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4335,6 +4335,7 @@ const ru = {
progressBar: 'Ход решения',
declaredActions: 'Действия',
decisionPanelOffRequestPage: 'Панель решения по согласованию работает только на страницах запросов на согласование.',
snapshotAsSubmitted: 'На момент подачи',
attachmentChip: 'Вложение',
attachmentOpenFailed: 'Не удалось открыть вложение — попробуйте ещё раз',
approveOneTitle: 'Согласовать «{{title}}»?',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4346,6 +4346,7 @@ const zh = {
progressBar: '决策进度',
declaredActions: '操作',
decisionPanelOffRequestPage: '审批决策面板仅在审批请求页面上可用。',
snapshotAsSubmitted: '提交时的内容',
attachmentChip: '附件',
attachmentOpenFailed: '无法打开附件,请重试',
approveOneTitle: '通过“{{title}}”?',
Expand Down
Loading