|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui#10462: the spec ACTION-ENTRY surfaces follow ruling A on |
| 11 | + * objectui#10289 (`params` is only the `ActionParam[]` input list) for every |
| 12 | + * action type except `api`. |
| 13 | + * |
| 14 | + * The four surfaces are `element:button`'s inline `action`, `action:group` |
| 15 | + * items, `action:menu` items and `page:header`'s `dispatchHeaderAction`. They |
| 16 | + * hand the runner a spec action entry, not an SDUI node, so there is no |
| 17 | + * `properties.params` bag to read values from. Per surface: |
| 18 | + * |
| 19 | + * - a non-`api` action with an OBJECT `params` does not reach the runner |
| 20 | + * with those values, and a development build says so once; |
| 21 | + * - an ARRAY `params` still reaches the runner as `actionParams`; |
| 22 | + * - a `type: 'api'` action with an OBJECT `params` is unchanged. That is the |
| 23 | + * objectstack#5777 window: the runner still reads it as the request payload |
| 24 | + * until 18. It is the control leg of every surface below. |
| 25 | + * |
| 26 | + * The value asserted is the `ActionDef` a registered handler receives, which |
| 27 | + * is what the runner dispatched. |
| 28 | + */ |
| 29 | + |
| 30 | +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; |
| 31 | +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; |
| 32 | +import React from 'react'; |
| 33 | +import { ComponentRegistry } from '@object-ui/core'; |
| 34 | +import type { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core'; |
| 35 | +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; |
| 36 | +// Module-scope side-effect imports: the renderers register themselves on |
| 37 | +// import, and the light `dom` project does not load the components graph. |
| 38 | +// Module scope, not a `beforeAll`, per AGENTS.md 测试纪律. |
| 39 | +import '../action-group'; |
| 40 | +import '../action-menu'; |
| 41 | +import '../../basic/elements'; |
| 42 | +import '../../layout/containers'; |
| 43 | +import { resetStaticParamsWarnings } from '../static-params'; |
| 44 | + |
| 45 | +type Handler = Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>; |
| 46 | + |
| 47 | +let navigateEdit: Handler; |
| 48 | +let api: Handler; |
| 49 | +let onParamCollection: Mock<ParamCollectionHandler>; |
| 50 | +let warn: ReturnType<typeof vi.spyOn>; |
| 51 | + |
| 52 | +beforeEach(() => { |
| 53 | + navigateEdit = vi.fn(async () => ({ success: true })); |
| 54 | + api = vi.fn(async () => ({ success: true })); |
| 55 | + // Cancel collection: the array leg is about what the runner is HANDED. |
| 56 | + onParamCollection = vi.fn(async () => null); |
| 57 | + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 58 | + resetStaticParamsWarnings(); |
| 59 | +}); |
| 60 | + |
| 61 | +afterEach(() => { |
| 62 | + warn.mockRestore(); |
| 63 | +}); |
| 64 | + |
| 65 | +const VALUES = { objectName: 'account', recordId: 'rec_7' }; |
| 66 | +const INPUTS = [{ name: 'reason', type: 'text', label: 'Reason' }]; |
| 67 | +const RECORD = { id: 'rec-1', status: 'open' }; |
| 68 | + |
| 69 | +/** This change's development warnings, as strings. */ |
| 70 | +const entryWarnings = () => |
| 71 | + warn.mock.calls.map((c: unknown[]) => String(c[0])).filter((m: string) => m.includes('objectui#10462')); |
| 72 | + |
| 73 | +function Registered({ type, schema }: { type: string; schema: Record<string, unknown> }) { |
| 74 | + const C = ComponentRegistry.get(type); |
| 75 | + if (!C) throw new Error(`${type} is not registered`); |
| 76 | + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered renderer (stable reference), not a component created during render |
| 77 | + return <C schema={{ ...schema, type }} />; |
| 78 | +} |
| 79 | + |
| 80 | +type Surface = 'element:button' | 'action:group' | 'action:menu' | 'page:header' | 'page:header (no record)'; |
| 81 | + |
| 82 | +/** The node that carries one action entry on each surface. */ |
| 83 | +function nodeFor(surface: Surface, entry: Record<string, unknown>): { type: string; schema: Record<string, unknown> } { |
| 84 | + switch (surface) { |
| 85 | + case 'element:button': |
| 86 | + return { |
| 87 | + type: 'element:button', |
| 88 | + schema: { properties: { label: String(entry.label), action: entry } }, |
| 89 | + }; |
| 90 | + case 'action:group': |
| 91 | + return { type: 'action:group', schema: { display: 'inline', actions: [entry] } }; |
| 92 | + case 'action:menu': |
| 93 | + // `autoTrigger` runs `handleExecute`, the function a menu-item click |
| 94 | + // calls, without opening the Radix dropdown (see action-forward-parity). |
| 95 | + return { type: 'action:menu', schema: { actions: [{ ...entry, autoTrigger: true }] } }; |
| 96 | + default: |
| 97 | + return { |
| 98 | + type: 'page:header', |
| 99 | + schema: { title: 'Plan', actions: [{ ...entry, locations: ['record_header'] }] }, |
| 100 | + }; |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +/** Mount one action entry on a surface and run it. */ |
| 105 | +function mount(surface: Surface, entry: Record<string, unknown>) { |
| 106 | + const { type, schema } = nodeFor(surface, entry); |
| 107 | + const node = <Registered type={type} schema={schema} />; |
| 108 | + render( |
| 109 | + <ActionProvider handlers={{ navigate_edit: navigateEdit, api }} onParamCollection={onParamCollection}> |
| 110 | + {surface === 'page:header' ? ( |
| 111 | + <RecordContextProvider |
| 112 | + objectName="os_plan" |
| 113 | + recordId={RECORD.id} |
| 114 | + data={RECORD} |
| 115 | + objectSchema={{ name: 'os_plan', label: 'Plan' }} |
| 116 | + > |
| 117 | + {node} |
| 118 | + </RecordContextProvider> |
| 119 | + ) : ( |
| 120 | + node |
| 121 | + )} |
| 122 | + </ActionProvider>, |
| 123 | + ); |
| 124 | + if (surface !== 'action:menu') { |
| 125 | + fireEvent.click(screen.getByRole('button', { name: new RegExp(String(entry.label)) })); |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +async function dispatchedTo(handler: Handler): Promise<ActionDef> { |
| 130 | + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); |
| 131 | + return handler.mock.calls[0][0]; |
| 132 | +} |
| 133 | + |
| 134 | +/** `element:button` names its executor `actionType`; the spec entries say `type`. */ |
| 135 | +const typed = (surface: Surface, type: string) => |
| 136 | + surface === 'element:button' ? { actionType: type } : { type }; |
| 137 | + |
| 138 | +const SURFACES: Surface[] = ['element:button', 'action:group', 'action:menu', 'page:header', 'page:header (no record)']; |
| 139 | + |
| 140 | +describe.each(SURFACES)('%s: an object `params` is values only on `type: "api"` (objectui#10462)', (surface) => { |
| 141 | + const where = surface.startsWith('page:header') ? 'page:header' : surface; |
| 142 | + |
| 143 | + it('does NOT forward an object `params` as values on a non-api action, and says so once', async () => { |
| 144 | + mount(surface, { name: 'edit_it', label: 'Edit It', ...typed(surface, 'navigate_edit'), params: VALUES }); |
| 145 | + const def = await dispatchedTo(navigateEdit); |
| 146 | + const params = def.params as Record<string, unknown> | undefined; |
| 147 | + expect(params?.objectName).toBeUndefined(); |
| 148 | + expect(params?.recordId).toBeUndefined(); |
| 149 | + expect(def.actionParams).toBeUndefined(); |
| 150 | + const warnings = entryWarnings(); |
| 151 | + expect(warnings).toHaveLength(1); |
| 152 | + expect(warnings[0]).toContain(`[${where}]`); |
| 153 | + expect(warnings[0]).toContain('"edit_it"'); |
| 154 | + }); |
| 155 | + |
| 156 | + it('keeps an `ActionParam[]` input list, as `actionParams`', async () => { |
| 157 | + mount(surface, { name: 'ask_it', label: 'Ask It', ...typed(surface, 'navigate_edit'), params: INPUTS }); |
| 158 | + await waitFor(() => expect(onParamCollection).toHaveBeenCalledTimes(1)); |
| 159 | + expect(onParamCollection.mock.calls[0][0]).toEqual(INPUTS); |
| 160 | + // Outside a record context `page:header` dispatches the entry as authored, |
| 161 | + // and the runner reads the array `params` itself; everywhere else the list |
| 162 | + // is forwarded under `actionParams`, as `action:button` does. |
| 163 | + if (surface !== 'page:header (no record)') { |
| 164 | + expect((onParamCollection.mock.calls[0][1] as ActionDef).actionParams).toEqual(INPUTS); |
| 165 | + } |
| 166 | + expect(entryWarnings()).toEqual([]); |
| 167 | + }); |
| 168 | + |
| 169 | + it('control: a `type: "api"` object `params` still arrives as the payload (objectstack#5777 window)', async () => { |
| 170 | + mount(surface, { name: 'post_it', label: 'Post It', ...typed(surface, 'api'), target: '/api/v1/ping', params: VALUES }); |
| 171 | + const def = await dispatchedTo(api); |
| 172 | + expect(def.params).toMatchObject(VALUES); |
| 173 | + expect(entryWarnings()).toEqual([]); |
| 174 | + }); |
| 175 | +}); |
| 176 | + |
| 177 | +describe('page:header keeps its record stash (objectui#10462)', () => { |
| 178 | + it('stashes `_rowRecord` on a non-api action whose object `params` it drops', async () => { |
| 179 | + mount('page:header', { name: 'edit_stash', label: 'Edit Stash', type: 'navigate_edit', params: VALUES }); |
| 180 | + const def = await dispatchedTo(navigateEdit); |
| 181 | + expect(def.params).toEqual({ _rowRecord: RECORD }); |
| 182 | + }); |
| 183 | + |
| 184 | + it('control: merges the stash into an api action`s object `params`, as before', async () => { |
| 185 | + mount('page:header', { name: 'post_stash', label: 'Post Stash', type: 'api', target: '/api/v1/ping', params: VALUES }); |
| 186 | + const def = await dispatchedTo(api); |
| 187 | + expect(def.params).toEqual({ ...VALUES, _rowRecord: RECORD }); |
| 188 | + }); |
| 189 | +}); |
| 190 | + |
| 191 | +describe('the warning is development-only and once per action (objectui#10462)', () => { |
| 192 | + it('warns once across repeated clicks', async () => { |
| 193 | + mount('action:group', { name: 'edit_twice', label: 'Edit Twice', type: 'navigate_edit', params: VALUES }); |
| 194 | + await dispatchedTo(navigateEdit); |
| 195 | + fireEvent.click(screen.getByRole('button', { name: /Edit Twice/ })); |
| 196 | + await waitFor(() => expect(navigateEdit).toHaveBeenCalledTimes(2)); |
| 197 | + expect(entryWarnings()).toHaveLength(1); |
| 198 | + }); |
| 199 | + |
| 200 | + it('is silent in production and still drops the values', async () => { |
| 201 | + vi.stubEnv('NODE_ENV', 'production'); |
| 202 | + try { |
| 203 | + mount('element:button', { name: 'edit_prod', label: 'Edit Prod', actionType: 'navigate_edit', params: VALUES }); |
| 204 | + const def = await dispatchedTo(navigateEdit); |
| 205 | + expect(def.params).toBeUndefined(); |
| 206 | + expect(entryWarnings()).toEqual([]); |
| 207 | + } finally { |
| 208 | + vi.unstubAllEnvs(); |
| 209 | + } |
| 210 | + }); |
| 211 | +}); |
0 commit comments