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
11 changes: 11 additions & 0 deletions .changeset/33009-flow-dropped-fields-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

fix(app-shell): a flow started from a record action, or resumed from its screen, warns the person who clicked when a write step dropped a read-only field, naming the field by its label

Clause-②: yes (widening) — `@object-ui/data-objectstack` exports its existing `droppedFields` wire gate and parser (`isWireDroppedFieldsEntry`, `asDroppedFieldsNotice`, and the `WireDroppedFieldsEntry` type they share); no i18n key is added
Metadata-protocol: none

A flow whose `update_record` step wrote a `readonly` field (「提交审批」 writing `status`) lost the value in silence: the step succeeded, the run answered success and the record stayed where it was. Since objectstack#32861 the run's answer names those fields (`droppedFields`). The console now reads that member on both launch hosts (the list and page action runtime, the record page) and on the screen-flow runner's resume, and shows the same warning a record save shows for the same strip: "Saved — but some fields did not take effect", one line per reason, fields named by their labels from the object's schema. A run that drops nothing, or an answer from a server that predates the member, shows exactly what it showed before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* 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.
*/

/**
* objectstack#33009 — a flow behind a record action whose write step dropped a
* read-only field warns the person who clicked, naming the field by its label.
*
* The #32666 shape: 「提交审批」 starts a flow whose `update_record` step
* writes `status`, which is `readonly: true`. The data engine drops the value,
* the step and the run succeed, and since objectstack#32861 the run's answer
* says so (`droppedFields`). Before this card the console read none of it.
*
* The list / page runtime is one of the two launch hosts (the record page is
* pinned in `RecordDetailView.flowDroppedFields-33009`). Driven end to end:
* the real `ConsoleActionRuntimeProvider` (real `ActionProvider` /
* `ActionRunner`), the real flow handler, launch module, reader and
* `writeWarningToast`, and the real `FlowRunner` it mounts, under the real en
* pack. Only the network (`authFetch`), the router, the toast library and the
* data source's `getObjectSchema` (the label source) are stubbed.
*/

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

vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));

const authFetchSpy = vi.fn();
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => authFetchSpy,
}));

vi.mock('sonner', () => {
const fn: any = vi.fn();
fn.error = vi.fn();
fn.success = vi.fn();
fn.info = vi.fn();
fn.warning = vi.fn();
return { toast: fn };
});

import { toast } from 'sonner';
import { ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
import { I18nProvider, useAction } from '@object-ui/react';

const OBJECT = 'expense_report';
const FLOW = 'expense_submit';
/** The declared action, as the object metadata carries it. */
const DECLARED = { name: 'submit_for_approval', label: '提交审批', type: 'flow', target: FLOW };
/** An inline page button naming no declared action: the trigger route. */
const INLINE = { name: 'inline_submit', label: 'Submit', type: 'flow', target: FLOW };
/** The object's schema as the adapter serves it: `status` is labelled. */
const SCHEMA = { name: OBJECT, fields: { status: { type: 'select', label: 'Approval status', readonly: true } } };

function answered(data: Record<string, unknown>) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
/** The #32666 run: completed, its `update_record` step's `status` dropped as read-only. */
const DROPPED = answered({
success: true,
status: 'completed',
flowLabel: 'Submit',
droppedFields: [{ object: OBJECT, fields: ['status'], reason: 'readonly' }],
});
/** A clean run: the same flow, nothing dropped (the member is omitted, as the server omits it). */
const CLEAN = answered({ success: true, status: 'completed', flowLabel: 'Submit' });
const PAUSED = answered({
success: true,
status: 'paused',
runId: 'run-1',
flowLabel: 'Submit',
screen: { nodeId: 'note', title: 'Approval note', fields: [] },
});

const TITLE = 'Saved — but some fields did not take effect';
const READONLY_LINE = 'Read-only, so it did not take effect: Approval status';

const getObjectSchema = vi.fn(async (name: string) => (name === OBJECT ? SCHEMA : undefined));
const warnings = () => (toast as any).warning.mock.calls as unknown[][];

beforeEach(() => {
authFetchSpy.mockReset();
getObjectSchema.mockClear();
for (const k of ['error', 'success', 'info', 'warning']) (toast as any)[k].mockClear();
});

/** Mount the runtime, click `action` once through the real runner, and wait for its result. */
async function click(action: Record<string, unknown>) {
const objects = [{ name: OBJECT, label: 'Expense report', fields: SCHEMA.fields, actions: [DECLARED] }];
let result: any;
function Probe() {
const { execute } = useAction();
return (
<button onClick={() => {
void execute({ ...action, params: { _rowRecord: { id: 'exp-7' } } } as any).then((r) => { result = r; });
}}>
run-flow
</button>
);
}
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<ConsoleActionRuntimeProvider dataSource={{ getObjectSchema }} objects={objects} objectName={OBJECT}>
<Probe />
</ConsoleActionRuntimeProvider>
</I18nProvider>,
);
fireEvent.click(screen.getByText('run-flow'));
await waitFor(() => expect(result).toBeDefined());
return result;
}

describe('objectstack#33009 — a flow launch warns about the read-only fields its write steps dropped', () => {
it('the #32666 shape: a record action whose flow dropped readonly `status` warns, naming the label', async () => {
authFetchSpy.mockResolvedValueOnce(DROPPED);

const result = await click(DECLARED);

expect(String(authFetchSpy.mock.calls[0][0])).toBe(`/api/v1/actions/${OBJECT}/submit_for_approval`);
// The run still succeeded: the click's own outcome is unchanged.
expect(result).toMatchObject({ success: true });
await waitFor(() => expect(warnings()).toHaveLength(1));
expect(warnings()[0]).toEqual([TITLE, { description: READONLY_LINE }]);
expect(getObjectSchema).toHaveBeenCalledWith(OBJECT);
expect((toast as any).error).not.toHaveBeenCalled();
});

it('a clean click warns about nothing', async () => {
authFetchSpy.mockResolvedValueOnce(CLEAN);

const result = await click(DECLARED);

expect(result).toMatchObject({ success: true });
// Let any stray async announcement land before reading the silence.
await new Promise((r) => setTimeout(r, 0));
expect(warnings()).toEqual([]);
});

it('the trigger route (an inline button naming no declared action) warns the same way', async () => {
authFetchSpy.mockResolvedValueOnce(DROPPED);

await click(INLINE);

expect(String(authFetchSpy.mock.calls[0][0])).toBe(`/api/v1/automation/${FLOW}/trigger`);
await waitFor(() => expect(warnings()).toHaveLength(1));
expect(warnings()[0]).toEqual([TITLE, { description: READONLY_LINE }]);
});

it('the resume path: a paused run whose write after the pause dropped `status` warns on Submit', async () => {
authFetchSpy.mockResolvedValueOnce(PAUSED);
const result = await click(DECLARED);
expect(result).toEqual({ success: true, silent: true, handedOff: true });
expect(warnings()).toEqual([]);

const dialog = await screen.findByRole('dialog');
authFetchSpy.mockResolvedValueOnce(DROPPED);
fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(2));
expect(String(authFetchSpy.mock.calls[1][0])).toBe(`/api/v1/automation/${FLOW}/runs/run-1/resume`);

await waitFor(() => expect(warnings()).toHaveLength(1));
expect(warnings()[0]).toEqual([TITLE, { description: READONLY_LINE }]);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});

it('control: a resume answer without `droppedFields` closes exactly as before, with no warning', async () => {
authFetchSpy.mockResolvedValueOnce(PAUSED);
await click(DECLARED);

const dialog = await screen.findByRole('dialog');
authFetchSpy.mockResolvedValueOnce(CLEAN);
fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());

expect((toast as any).success.mock.calls.map((c: unknown[]) => c[0])).toEqual(['Flow "Submit" completed']);
expect(warnings()).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../en
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements.js';
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
import { launchConsoleFlow } from '../utils/flowLaunch.js';
import type { TranslateFn } from '@object-ui/i18n';
import { apiActionRefusalResult } from '../utils/flowResponse.js';
import {
findDeclaredApiAction,
Expand Down Expand Up @@ -787,6 +788,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
triggerParams: params,
objects: withKnownObjects(objects, metadata.objects),
language,
// The run's `droppedFields` warning (objectstack#33009): labels read
// through this host's data source, the console's adapter.
droppedFields: { t: t as TranslateFn, fieldLabel, dataSource },
});
const judged = launch.judged;
// Paused at a `screen` node: FlowRunner renders the form + resumes, and
Expand All @@ -812,7 +816,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
} catch (error) {
return { success: false, error: (error as Error).message };
}
}, [authFetch, objApiName, objects, metadata, refresh, language, t]);
}, [authFetch, objApiName, objects, metadata, refresh, language, t, fieldLabel, dataSource]);

// Server-side action handler — POST /api/v1/actions/{object}/{action}, built
// from @object-ui/core's `createServerActionHandler` via the shared console
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* 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.
*/

/**
* objectstack#33009 — the one reader of a flow answer's `droppedFields`, below
* its three hosts (pinned end to end in
* `useConsoleActionRuntime.flowDroppedFields-33009` and
* `RecordDetailView.flowDroppedFields-33009`).
*
* What only this level can say cheaply: the parse is data-objectstack's own
* (its skew arm reaches the toast), a run that wrote two objects is labelled
* per object, an off-spec entry with no `object` is named by api name, and a
* failed launch carrying the member says nothing beyond its failure.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UNRECOGNIZED_DROP_REASON } from '@object-ui/data-objectstack';

const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('sonner', () => ({ toast: toasts }));

import { announceFlowDroppedFields, flowAnswerDroppedFields, launchConsoleFlow } from '../flowLaunch';

const SCHEMAS: Record<string, { fields: Record<string, { label: string }> }> = {
expense_report: { fields: { status: { label: 'Approval status' } } },
expense_line: { fields: { status: { label: 'Line status' }, amount: { label: 'Amount' } } },
};
const dataSource = { getObjectSchema: vi.fn(async (name: string) => SCHEMAS[name]) };
const t = ((key: string, opts?: { fields?: string; defaultValue?: string }) =>
(opts?.defaultValue ?? key).replace('{{fields}}', opts?.fields ?? '')) as never;
const fieldLabel = (_object: string, _field: string, fallback: string) => fallback;
const ctx = { t, fieldLabel, dataSource };

function sink() {
return { warning: vi.fn(), info: vi.fn() };
}
const answer = (droppedFields: unknown) => ({ success: true, data: { success: true, status: 'completed', droppedFields } });

beforeEach(() => {
dataSource.getObjectSchema.mockClear();
for (const fn of Object.values(toasts)) fn.mockClear();
});

describe('flowAnswerDroppedFields — the wire through data-objectstack’s own parser', () => {
it('parses the member, keeps the skew arm for a reason the pin cannot name, and drops an entry naming no field', () => {
expect(
flowAnswerDroppedFields(answer([
{ object: 'expense_report', fields: ['status'], reason: 'readonly' },
{ object: 'expense_report', fields: ['status'], reason: 'from_the_future' },
{ object: 'expense_report', fields: [42] },
])),
).toEqual([
{ object: 'expense_report', fields: ['status'], reason: 'readonly' },
{ object: 'expense_report', fields: ['status'], reason: UNRECOGNIZED_DROP_REASON, unrecognizedReason: 'from_the_future' },
]);
});

it('control: an answer without the member (or no answer) reads as none', () => {
expect(flowAnswerDroppedFields({ success: true, data: { success: true, status: 'completed' } })).toEqual([]);
expect(flowAnswerDroppedFields(null)).toEqual([]);
});
});

describe('announceFlowDroppedFields', () => {
it('labels each object’s fields on that object: one warning per object', async () => {
const s = sink();
await announceFlowDroppedFields(answer([
{ object: 'expense_report', fields: ['status'], reason: 'readonly' },
{ object: 'expense_line', fields: ['status', 'amount'], reason: 'readonly' },
]), ctx, s);

expect(s.warning.mock.calls.map((c) => c[1].description)).toEqual([
'Read-only, so it did not take effect: Approval status',
'Read-only, so it did not take effect: Line status, Amount',
]);
expect(dataSource.getObjectSchema.mock.calls.map((c) => c[0])).toEqual(['expense_report', 'expense_line']);
});

it('a reason from the future is named without claiming a cause', async () => {
const s = sink();
await announceFlowDroppedFields(answer([{ object: 'expense_report', fields: ['status'], reason: 'from_the_future' }]), ctx, s);
expect(s.warning.mock.calls[0][1].description).toBe('Not applied by the server: Approval status');
});

it('an off-spec entry with no `object` is named by api name, never by a guessed object', async () => {
const s = sink();
await announceFlowDroppedFields(answer([{ fields: ['status'], reason: 'readonly' }]), ctx, s);
expect(s.warning.mock.calls[0][1].description).toBe('Read-only, so it did not take effect: status');
expect(dataSource.getObjectSchema).not.toHaveBeenCalled();
});

it('a data source without getObjectSchema still warns, by api name', async () => {
const s = sink();
await announceFlowDroppedFields(answer([{ object: 'expense_report', fields: ['status'], reason: 'readonly' }]), { ...ctx, dataSource: {} }, s);
expect(s.warning.mock.calls[0][1].description).toBe('Read-only, so it did not take effect: status');
});

it('control: no member, no warning', async () => {
const s = sink();
await announceFlowDroppedFields({ success: true, data: { success: true, status: 'completed' } }, ctx, s);
expect(s.warning).not.toHaveBeenCalled();
expect(s.info).not.toHaveBeenCalled();
});
});

describe('launchConsoleFlow — a failed run says only that it failed', () => {
it('a failed answer carrying the member gets its error and no "saved, but" warning', async () => {
const fetch = vi.fn(async () => new Response(JSON.stringify({
success: true,
data: {
success: false,
status: 'failed',
error: 'boom',
droppedFields: [{ object: 'expense_report', fields: ['status'], reason: 'readonly' }],
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const launch = await launchConsoleFlow({
fetch,
baseUrl: '',
action: { name: 'expense_submit', type: 'flow', target: 'expense_submit' },
objectName: 'expense_report',
recordId: 'exp-7',
triggerParams: {},
objects: [],
language: 'en',
droppedFields: ctx,
});
expect(launch.judged.result.success).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(toasts.warning).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function launch(fetch: ReturnType<typeof answering>, language = 'zh-CN') {
triggerParams: {},
objects: [],
language,
droppedFields: { t: ((key: string) => key) as never, fieldLabel: (_o, _f, fallback) => fallback, dataSource: null },
});
}

Expand Down
Loading
Loading