|
| 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 | + * Legacy string `rowActions` must reach the ActionRunner as a real action DEF |
| 11 | + * (objectui#2960). |
| 12 | + * |
| 13 | + * A view declaring `rowActions: ['convert_lead']` used to dispatch the action |
| 14 | + * NAME in the runner's `type` slot — `{ type: 'convert_lead' }`. That matches |
| 15 | + * no built-in type and no registered handler, so it fell through the runner's |
| 16 | + * schema fallback: zero requests, then a green "Action completed successfully" |
| 17 | + * toast. And because an object action declaring `locations: ['list_item']` |
| 18 | + * also injects its own (working) entry, the same action could render twice — |
| 19 | + * one live, one dead. |
| 20 | + * |
| 21 | + * These drive the REAL ObjectGrid through a real ActionProvider and assert the |
| 22 | + * user-visible outcome: the click issues the action's request, and the menu |
| 23 | + * carries one entry per action rather than a working/dead pair. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 27 | +import { render, screen, waitFor, cleanup } from '@testing-library/react'; |
| 28 | +import userEvent from '@testing-library/user-event'; |
| 29 | +import '@testing-library/jest-dom'; |
| 30 | +import React from 'react'; |
| 31 | + |
| 32 | +vi.mock('@object-ui/permissions', () => ({ |
| 33 | + usePermissions: () => ({ isLoaded: false, checkField: () => true, getObjectApiOperations: () => undefined }), |
| 34 | +})); |
| 35 | + |
| 36 | +import { ObjectGrid } from '../ObjectGrid'; |
| 37 | +import { registerAllFields } from '@object-ui/fields'; |
| 38 | +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; |
| 39 | + |
| 40 | +registerAllFields(); |
| 41 | + |
| 42 | +const OBJECT = 'lead'; |
| 43 | +const ROWS = [{ id: '1', name: 'Alice' }]; |
| 44 | + |
| 45 | +/** |
| 46 | + * The object's own `convert_lead` action — an api call to a custom route. The |
| 47 | + * label is deliberately NOT the humanization of the name ("Convert Lead"), so |
| 48 | + * an assertion on it can tell the resolved def apart from the legacy path, |
| 49 | + * which could only ever render `formatActionLabel(name)`. |
| 50 | + */ |
| 51 | +const CONVERT_LEAD = { |
| 52 | + name: 'convert_lead', |
| 53 | + label: 'Convert to Account', |
| 54 | + type: 'api', |
| 55 | + target: '/api/v1/leads/convert', |
| 56 | + locations: ['record_header'], |
| 57 | +}; |
| 58 | + |
| 59 | +let fetchMock: ReturnType<typeof vi.fn>; |
| 60 | + |
| 61 | +function renderGrid(opts: { objectActions?: unknown[]; schema?: Record<string, unknown> }) { |
| 62 | + const dataSource: any = { |
| 63 | + getObjectSchema: async (name: string) => ({ |
| 64 | + name, |
| 65 | + fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' } }, |
| 66 | + ...(opts.objectActions ? { actions: opts.objectActions } : {}), |
| 67 | + }), |
| 68 | + }; |
| 69 | + const schema: any = { |
| 70 | + type: 'object-grid', |
| 71 | + objectName: OBJECT, |
| 72 | + columns: [{ field: 'name', label: 'Name' }], |
| 73 | + data: { provider: 'value', items: ROWS }, |
| 74 | + ...opts.schema, |
| 75 | + }; |
| 76 | + return render( |
| 77 | + <ActionProvider> |
| 78 | + <SchemaRendererProvider dataSource={dataSource}> |
| 79 | + <ObjectGrid schema={schema} dataSource={dataSource} /> |
| 80 | + </SchemaRendererProvider> |
| 81 | + </ActionProvider>, |
| 82 | + ); |
| 83 | +} |
| 84 | + |
| 85 | +/** Render, wait for the async schema fetch to settle, then open the row kebab. */ |
| 86 | +async function openRowMenu(opts: Parameters<typeof renderGrid>[0]) { |
| 87 | + renderGrid(opts); |
| 88 | + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); |
| 89 | + // The promotion depends on `getObjectSchema`, so an assertion taken before |
| 90 | + // it lands would read the pre-fetch (unresolved) state. |
| 91 | + await waitFor(() => expect(screen.getByTestId('row-action-trigger')).toBeInTheDocument()); |
| 92 | + await userEvent.click(screen.getByTestId('row-action-trigger')); |
| 93 | +} |
| 94 | + |
| 95 | +beforeEach(() => { |
| 96 | + fetchMock = vi.fn().mockResolvedValue({ |
| 97 | + ok: true, |
| 98 | + status: 200, |
| 99 | + statusText: 'OK', |
| 100 | + headers: { get: () => 'application/json' }, |
| 101 | + json: async () => ({ success: true }), |
| 102 | + }); |
| 103 | + vi.stubGlobal('fetch', fetchMock); |
| 104 | +}); |
| 105 | + |
| 106 | +afterEach(() => { |
| 107 | + cleanup(); |
| 108 | + vi.unstubAllGlobals(); |
| 109 | +}); |
| 110 | + |
| 111 | +describe('legacy string rowActions dispatch (objectui#2960)', () => { |
| 112 | + it('issues the resolved action request instead of no-opping', async () => { |
| 113 | + await openRowMenu({ |
| 114 | + objectActions: [CONVERT_LEAD], |
| 115 | + schema: { rowActions: ['convert_lead'] }, |
| 116 | + }); |
| 117 | + |
| 118 | + await userEvent.click(screen.getByTestId('row-action-convert_lead')); |
| 119 | + |
| 120 | + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); |
| 121 | + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/leads/convert'); |
| 122 | + }); |
| 123 | + |
| 124 | + it('renders the object action label, not the humanized name', async () => { |
| 125 | + await openRowMenu({ |
| 126 | + objectActions: [CONVERT_LEAD], |
| 127 | + schema: { rowActions: ['convert_lead'] }, |
| 128 | + }); |
| 129 | + |
| 130 | + const item = screen.getByTestId('row-action-convert_lead'); |
| 131 | + expect(item).toHaveTextContent('Convert to Account'); |
| 132 | + expect(item).not.toHaveTextContent('Convert Lead'); |
| 133 | + }); |
| 134 | + |
| 135 | + it('does not render a dead duplicate of a list_item action', async () => { |
| 136 | + const listItemDef = { ...CONVERT_LEAD, locations: ['list_item'], label: 'Convert' }; |
| 137 | + await openRowMenu({ |
| 138 | + objectActions: [listItemDef], |
| 139 | + // The app-shell derives `rowActionDefs` from `locations: ['list_item']`; |
| 140 | + // a view that ALSO names the action in legacy `rowActions` used to get |
| 141 | + // both a working entry and a dead twin. |
| 142 | + schema: { rowActions: ['convert_lead'], rowActionDefs: [listItemDef] }, |
| 143 | + }); |
| 144 | + |
| 145 | + expect(screen.getAllByTestId('row-action-convert_lead')).toHaveLength(1); |
| 146 | + }); |
| 147 | + |
| 148 | + it('leaves a name with no declared action to the by-name handler path', async () => { |
| 149 | + // Consumers may register a runner handler under exactly this name, so the |
| 150 | + // entry must still render and still dispatch. |
| 151 | + await openRowMenu({ |
| 152 | + objectActions: [CONVERT_LEAD], |
| 153 | + schema: { rowActions: ['crm_only_handler'] }, |
| 154 | + }); |
| 155 | + |
| 156 | + expect(screen.getByTestId('row-action-crm_only_handler')).toBeInTheDocument(); |
| 157 | + // Nothing resolvable to call — and with the runner's fallback now failing |
| 158 | + // loudly, no request and no success are reported. |
| 159 | + await userEvent.click(screen.getByTestId('row-action-crm_only_handler')); |
| 160 | + await waitFor(() => expect(fetchMock).not.toHaveBeenCalled()); |
| 161 | + }); |
| 162 | +}); |
0 commit comments