diff --git a/.changeset/27520-approvals-inbox-back-link-reject-reason.md b/.changeset/27520-approvals-inbox-back-link-reject-reason.md new file mode 100644 index 00000000000..8e0a169568c --- /dev/null +++ b/.changeset/27520-approvals-inbox-back-link-reject-reason.md @@ -0,0 +1,13 @@ +--- +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(console): the approvals inbox keeps a way back from a record, takes an optional reason on Reject, and marks requests you submitted + +Clause-②: yes (widening: `@object-ui/i18n` gains the `approvalsInbox.submittedByYou` key in all ten locales; no accept set moves) +Metadata-protocol: none + +- **Back to the inbox.** A row's record link still opens the record in the inbox's own app. When that app's navigation does not list the record's object (the Account app lists the inbox), the record page had no way back. The link now hands the record page the same origin a list row does, so the page draws "‹ Approvals Inbox" above the record. +- **Reason on Reject.** The Reject confirmation, for one row and for a bulk selection, offers an optional comment. What the approver types is sent as the reject call's `comment`, which the approvals service already records on the request's timeline. Left empty, the reject is sent as before. +- **"You submitted this".** A request the viewer submitted shows "You submitted this" beside the requester, read from the server's `viewer.is_submitter`. It informs the decision and gates nothing; who may decide a request is unchanged. The Submitted by me tab does not repeat it on every row. diff --git a/ui/apps/console/src/pages/system/ApprovalsInboxPage.recordBackLink-27520.test.tsx b/ui/apps/console/src/pages/system/ApprovalsInboxPage.recordBackLink-27520.test.tsx new file mode 100644 index 00000000000..8699442b0ec --- /dev/null +++ b/ui/apps/console/src/pages/system/ApprovalsInboxPage.recordBackLink-27520.test.tsx @@ -0,0 +1,182 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectstack#27520 row 12 — a row's record link keeps a way back to the inbox. + * + * The record link stays where it was: the console's standard record route in + * the inbox's own app segment (`ApprovalsInboxPage.recordLink.test.tsx` pins + * the href). Inside an app whose navigation does not list the record's object, + * such as the Account app, which lists the inbox, the record page had no way + * back. The link now hands the record page the history-state origin every list + * row hands it (`state.from`), so the page's crumb row draws a link back to the + * inbox, named as the inbox's own header names it. + * + * The harness mounts the REAL inbox page and, on the record route, the REAL + * `RecordDetailView`; only the HTTP transport, the console adapter and the + * inbox's list read (`approvalsApi.listRequests`) are doubled. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +const { approvalsApiStub, ROW, AUTH } = vi.hoisted(() => { + const ROW = { + id: 'req_27520', + process_name: 'flow:invoice_approval', + process_label: 'Invoice Approval', + object_name: 'invoice', + object_label: 'Invoice', + record_id: 'inv_1', + record_title: 'INV-1010', + status: 'pending', + pending_approvers: ['u1'], + submitter_id: 'u2', + submitter_name: 'Sam Submitter', + submitted_at: '2026-10-20T09:00:00.000Z', + updated_at: '2026-10-20T09:00:00.000Z', + }; + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: [ROW], total: 1 })), + getRequest: vi.fn(async () => ({ data: ROW })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: ROW, finalized: true })), + reject: vi.fn(async () => ({ data: ROW, finalized: true })), + }; + // A STABLE singleton: a fresh user per render re-runs the inbox's load + // effect forever, and the queue never leaves its skeleton. + const AUTH = { user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }; + return { approvalsApiStub, ROW, AUTH }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => AUTH, +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + 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(), + }), +})); +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +import { AdapterCtx, MetadataCtx, SchemaRendererProvider, useAdapter } from '@object-ui/react'; +import { I18nProvider, type I18nConfig } from '@object-ui/i18n'; +import { RecordDetailView } from '@object-ui/app-shell'; +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +/** The Account app's route segment: its nav lists the inbox, not invoices. */ +const APP = 'account'; +const INBOX = `/apps/${APP}/component/approvals/inbox`; +const RECORD_PAGE = `/apps/${APP}/invoice/record/${ROW.record_id}`; + +const INVOICE_DEF = { + name: 'invoice', + label: 'Invoice', + pluralLabel: 'Invoices', + nameField: 'name', + fields: { name: { type: 'text', label: 'Invoice Number' } }, +}; +const RECORD = { id: ROW.record_id, name: ROW.record_title }; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} +vi.stubGlobal('fetch', vi.fn(async () => json(200, { success: true, data: [] }))); + +const host = { + // The inbox's readability probe reads `id in (…)`: the record is readable. + find: vi.fn(async (_object: string, params?: Record) => { + const ids = (params?.$filter as { id?: { $in?: string[] } } | undefined)?.id?.$in; + return ids ? { data: ids.map((id) => ({ id })), total: ids.length } : { data: [], total: 0 }; + }), + findOne: vi.fn(async (object: string, id: string) => (object === 'invoice' && id === RECORD.id ? RECORD : null)), + create: vi.fn(), update: vi.fn(), delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => (name === 'invoice' ? INVOICE_DEF : null)), +}; +const metadata = { + apps: [], objects: [INVOICE_DEF], dashboards: [], reports: [], + pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], + getItem: vi.fn(async (type: string, name: string) => (type === 'object' && name === 'invoice' ? INVOICE_DEF : null)), + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; +const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; + +/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */ +function GenericRecordRoute() { + return {}} />; +} + +function RouterProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderAt(entry: string) { + return render( + + + + + + + + } /> + } /> + + + + + + , + ); +} + +const where = () => screen.getByTestId('where').textContent; + +/** The desktop row's record link (the mobile card draws none). */ +async function recordLink(): Promise { + await screen.findAllByText(ROW.record_title, undefined, { timeout: 8000 }); + const row = screen.getAllByRole('row').find((r) => within(r).queryAllByText(ROW.record_title).length > 0); + if (!row) throw new Error('no inbox row for the request'); + return within(row).findByRole('link', { name: ROW.record_title }, { timeout: 8000 }); +} + +beforeEach(() => { + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); +}); +afterEach(cleanup); + +describe('the inbox record link keeps a way back to the inbox (objectstack#27520 row 12)', () => { + it('the record page it opens draws a link back to the inbox, and the link returns there', async () => { + renderAt(INBOX); + const link = await recordLink(); + // The target app stays the inbox's own (the recordLink pin's href). + expect(link).toHaveAttribute('href', RECORD_PAGE); + fireEvent.click(link); + expect(where()).toBe(RECORD_PAGE); + + const back = await screen.findByRole('link', { name: 'Approvals Inbox' }, { timeout: 8000 }); + expect(back).toHaveAttribute('href', INBOX); + fireEvent.click(back); + expect(where()).toBe(INBOX); + }); + + it('CONTROL: a record page reached without the inbox draws no such link', async () => { + renderAt(RECORD_PAGE); + await screen.findAllByText(ROW.record_title, undefined, { timeout: 8000 }); + expect(screen.queryByRole('link', { name: 'Approvals Inbox' })).toBeNull(); + }); +}); diff --git a/ui/apps/console/src/pages/system/ApprovalsInboxPage.rejectReasonSelfSubmitted-27520.test.tsx b/ui/apps/console/src/pages/system/ApprovalsInboxPage.rejectReasonSelfSubmitted-27520.test.tsx new file mode 100644 index 00000000000..f176c704884 --- /dev/null +++ b/ui/apps/console/src/pages/system/ApprovalsInboxPage.rejectReasonSelfSubmitted-27520.test.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27520 row 12 — deciding from the queue. + * + * - **Reject takes an optional reason.** The row's Reject confirmation, and the + * bulk one, offer a comment box; what the approver types reaches the reject + * call as `comment`, the field the approvals REST door already accepts and + * records on the request's timeline. Left empty, the call carries no + * `comment` at all, as before. + * - **A request the viewer submitted says so.** The row reads the server's own + * `viewer.is_submitter` (attached by the approvals list read) and marks the + * requester "You submitted this". Who may decide is unchanged: the marker + * informs, it gates nothing. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const APP = 'account'; + +const { approvalsApiStub, rows, ADAPTER, AUTH, I18N } = vi.hoisted(() => { + const base = { + process_name: 'invoice_approval', + process_label: 'Invoice Approval', + object_name: 'showcase_invoice', + object_label: 'Invoice', + status: 'pending', + pending_approvers: ['u_1'], + submitted_at: '2026-10-20T00:00:00.000Z', + }; + const rows = [ + { + ...base, + id: 'req_other', + record_id: 'inv_other', + record_title: 'Someone Else Invoice', + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + viewer: { can_act: true, is_submitter: false, can_override: true }, + }, + { + ...base, + id: 'req_mine', + record_id: 'inv_mine', + record_title: 'My Own Invoice', + submitter_id: 'u_1', + submitter_name: 'Ada Admin', + viewer: { can_act: true, is_submitter: true, can_override: true }, + }, + ]; + + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: rows })), + getRequest: vi.fn(async (id: string) => ({ data: rows.find((r) => r.id === id) })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: rows[0], finalized: true })), + reject: vi.fn(async () => ({ data: rows[0], finalized: true })), + }; + + // STABLE singletons: a fresh identity per render re-runs the load effect forever. + const ADAPTER = { + find: vi.fn(async (_object: string, params?: Record) => { + const ids = (params?.$filter as { id?: { $in?: string[] } } | undefined)?.id?.$in ?? []; + return { data: ids.map((id) => ({ id })) }; + }), + }; + const AUTH = { user: { id: 'u_1', email: 'ada@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }; + + return { approvalsApiStub, rows, ADAPTER, AUTH, I18N }; +}); + +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('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function renderInbox() { + return render( + + + } /> + + , + ); +} + +/** The desktop table row holding `title`. */ +async function rowFor(title: string): Promise { + await screen.findAllByText(title, undefined, { timeout: 8000 }); + const found = screen.getAllByRole('row').find((r) => within(r).queryByText(title)); + if (!found) throw new Error(`no row for ${title}`); + return found; +} + +/** The mobile card holding `title` (a card is not a table row). */ +function cardFor(title: string): HTMLElement { + const card = screen.getAllByText(title).map((el) => el.closest('.md\\:hidden > *')).find(Boolean); + if (!card) throw new Error(`no card for ${title}`); + return card as HTMLElement; +} + +beforeEach(() => { + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); +}); +afterEach(cleanup); + +describe('Reject from the queue takes an optional reason (objectstack#27520 row 12)', () => { + it('the row confirmation sends what the approver typed as the comment', async () => { + renderInbox(); + fireEvent.click(within(await rowFor('Someone Else Invoice')).getByRole('button', { name: 'Reject' })); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.change(within(dialog).getByRole('textbox', { name: 'Comment (optional)' }), { + target: { value: ' Missing the signed PO. ' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Reject' })); + await waitFor(() => expect(approvalsApiStub.reject).toHaveBeenCalledTimes(1)); + expect(approvalsApiStub.reject).toHaveBeenCalledWith('req_other', { actor_id: 'u_1', comment: 'Missing the signed PO.' }); + }); + + it('left empty, the reject carries no comment, and a reopened dialog starts empty', async () => { + renderInbox(); + const row = await rowFor('Someone Else Invoice'); + fireEvent.click(within(row).getByRole('button', { name: 'Reject' })); + let dialog = await screen.findByRole('alertdialog'); + fireEvent.change(within(dialog).getByRole('textbox', { name: 'Comment (optional)' }), { target: { value: 'draft' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + + fireEvent.click(within(await rowFor('Someone Else Invoice')).getByRole('button', { name: 'Reject' })); + dialog = await screen.findByRole('alertdialog'); + expect(within(dialog).getByRole('textbox', { name: 'Comment (optional)' })).toHaveValue(''); + fireEvent.click(within(dialog).getByRole('button', { name: 'Reject' })); + await waitFor(() => expect(approvalsApiStub.reject).toHaveBeenCalledTimes(1)); + expect(approvalsApiStub.reject).toHaveBeenCalledWith('req_other', { actor_id: 'u_1' }); + }); + + it('the bulk confirmation sends the same reason on every selected request', async () => { + renderInbox(); + for (const title of ['Someone Else Invoice', 'My Own Invoice']) { + fireEvent.click(within(await rowFor(title)).getByRole('checkbox', { name: 'Select request' })); + } + fireEvent.click(screen.getByRole('button', { name: 'Reject {{count}}' })); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.change(within(dialog).getByRole('textbox', { name: 'Comment (optional)' }), { + target: { value: 'Budget frozen this quarter.' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Reject {{count}}' })); + await waitFor(() => expect(approvalsApiStub.reject).toHaveBeenCalledTimes(2)); + expect(approvalsApiStub.reject).toHaveBeenCalledWith('req_other', { actor_id: 'u_1', comment: 'Budget frozen this quarter.' }); + expect(approvalsApiStub.reject).toHaveBeenCalledWith('req_mine', { actor_id: 'u_1', comment: 'Budget frozen this quarter.' }); + }); +}); + +describe('A request the viewer submitted says so (objectstack#27520 row 12)', () => { + it('marks the self-submitted row and card, and only those', async () => { + renderInbox(); + expect(within(await rowFor('My Own Invoice')).getByText('You submitted this')).toBeInTheDocument(); + expect(within(await rowFor('Someone Else Invoice')).queryByText('You submitted this')).toBeNull(); + expect(within(cardFor('My Own Invoice')).getByText('You submitted this')).toBeInTheDocument(); + expect(within(cardFor('Someone Else Invoice')).queryByText('You submitted this')).toBeNull(); + }); + + it('gates nothing: the self-submitted row stays decidable', async () => { + renderInbox(); + const row = await rowFor('My Own Invoice'); + expect(within(row).getByRole('button', { name: 'Approve' })).toBeEnabled(); + expect(within(row).getByRole('button', { name: 'Reject' })).toBeEnabled(); + }); + + it('is not repeated on the Submitted by me tab, where every row is the viewer\'s', async () => { + renderInbox(); + await rowFor('My Own Invoice'); + // Radix tabs switch on pointer down / keyboard, not on a bare click. + const tab = screen.getByRole('tab', { name: 'Submitted by me' }); + fireEvent.mouseDown(tab, { button: 0 }); + await waitFor(() => expect(tab).toHaveAttribute('aria-selected', 'true')); + await rowFor('My Own Invoice'); + expect(screen.queryByText('You submitted this')).toBeNull(); + }); + + it('reads the server flag, not the id: a row without viewer.is_submitter is not marked', async () => { + const mine = rows[1] as { viewer?: unknown }; + const saved = mine.viewer; + delete mine.viewer; + try { + renderInbox(); + await rowFor('My Own Invoice'); + expect(screen.queryByText('You submitted this')).toBeNull(); + } finally { + mine.viewer = saved; + } + }); +}); diff --git a/ui/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/ui/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 51d9929320e..46056de6a12 100644 --- a/ui/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/ui/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -30,7 +30,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ConsolePageHeader } from '@object-ui/app-shell'; import { Button, @@ -63,6 +63,7 @@ import { AlertDialogTitle, AlertDialogTrigger, Input, + Textarea, Checkbox, Select, SelectContent, @@ -423,6 +424,42 @@ function RequestCell({ r, tr }: { r: ApprovalRequestRow; tr: Translate }) { ); } +/** + * The `comment` member of a decision body: the trimmed reason, or nothing when + * the approver left it empty, so an unexplained decision sends what it always did. + */ +function decisionComment(comment: string | undefined): { comment?: string } { + const text = (comment ?? '').trim(); + return text ? { comment: text } : {}; +} + +/** The optional reason box a Reject confirmation carries. */ +function RejectCommentField({ id, label, value, onChange }: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ +