From 924d10505be6bde9fda2539d5741bf4db21354c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:28:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(app-shell):=20packaged=20actions=20on/off?= =?UTF-8?q?=20beside=20the=20packaged=20flows=20(ADR-0126=20=C2=A78=20item?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup › Packaged automation gains a packaged ACTIONS section beside its flows one — the surface half of ADR-0126 §8 item 2 (amendment ruling 3). The engine, the ledger rows and both dispatch doors landed in objectstack PR 12348; objectui was deliberately out of that PR's scope, so this is the other half of one card. Per packaged action the section does exactly ONE thing: on/off for this scope. That is all the sys_metadata_activation ledger knows about an action. - State comes from the ledger's `metadata_type: 'action'` rows, read through the generic data API list the object itself sanctions for exactly this (`apiMethods: ['get', 'list']`). Absence of a row means active. - Discovery mirrors the runtime's own collectActionDeclarations: embedded `actions[]` from GET /meta/object plus standalone items from GET /meta/action, object-embedded winning an : key clash. - Flips invoke POST /actions/_activation/:object/:action with exactly the one key its body declares; `global` is the object segment for an object-less action, the spelling both dispatch doors take. NO clone for actions — ruling 3 charters the switch and nothing else, and the clone half stays pre-chartered. NO drift or ancestry surface (§9); the absence is pinned against a response that smuggles clonedFrom/baseVersion in, so it is enforced at the renderer and not merely by the wire. Refusals reach the operator verbatim, no softening and no retry loop: the §5 posture gate (403 PERMISSION_DENIED), the ambiguous-name refusal (409 RESOURCE_CONFLICT, naming the objects) and the no-ledger outage (503 SERVICE_UNAVAILABLE). A `hasMore` on the ledger read is a load failure rather than a rendered list — a dropped row reads as "active", so a partial ledger would show a switched-off action as armed. The flows section is unchanged in behaviour. Nine new packagedAutomation.* keys land in `en` and in all nine other packs as real translations; the page subtitle moved in all ten together. Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh Co-authored-by: Claude --- .../6412-packaged-actions-activation.md | 56 +++ .../setup/PackagedActionsSection.test.tsx | 472 ++++++++++++++++++ .../views/setup/PackagedActionsSection.tsx | 409 +++++++++++++++ .../setup/PackagedAutomationPage.test.tsx | 19 + .../views/setup/PackagedAutomationPage.tsx | 25 +- .../src/views/setup/packagedActions.test.ts | 266 ++++++++++ .../src/views/setup/packagedActions.ts | 286 +++++++++++ packages/i18n/src/locales/ar.ts | 11 +- packages/i18n/src/locales/de.ts | 11 +- packages/i18n/src/locales/en.ts | 25 +- packages/i18n/src/locales/es.ts | 11 +- packages/i18n/src/locales/fr.ts | 11 +- packages/i18n/src/locales/ja.ts | 11 +- packages/i18n/src/locales/ko.ts | 11 +- packages/i18n/src/locales/pt.ts | 11 +- packages/i18n/src/locales/ru.ts | 11 +- packages/i18n/src/locales/zh.ts | 11 +- 17 files changed, 1644 insertions(+), 13 deletions(-) create mode 100644 .changeset/6412-packaged-actions-activation.md create mode 100644 packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx create mode 100644 packages/app-shell/src/views/setup/PackagedActionsSection.tsx create mode 100644 packages/app-shell/src/views/setup/packagedActions.test.ts create mode 100644 packages/app-shell/src/views/setup/packagedActions.ts diff --git a/.changeset/6412-packaged-actions-activation.md b/.changeset/6412-packaged-actions-activation.md new file mode 100644 index 000000000..cf50e3c65 --- /dev/null +++ b/.changeset/6412-packaged-actions-activation.md @@ -0,0 +1,56 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +Setup › **Packaged automation** gains a packaged **actions** section beside its flows one — +the surface half of ADR-0126 §8 item 2 (objectui#6412; the engine, ledger and both dispatch +doors landed in objectstack#12348). The maintainer's pull, verbatim and untranslated: +「动作 可能是需要开关的,因为有的 action 我不想启用。」 + +Per packaged action the section does exactly **one** thing: **on/off for this scope**. That +is all the `sys_metadata_activation` ledger knows about an action, and the section claims +nothing more. + +- **State** comes from the ledger's `metadata_type: 'action'` rows, read through the generic + data API list the object itself sanctions for exactly this + (`apiMethods: ['get', 'list']` — *"Reads stay open so operability surfaces can answer + 'what is disabled here?'"*). **Absence of a row means active**, so a stock boot shows + everything armed. +- **Discovery** mirrors the runtime's own `collectActionDeclarations`: object-embedded + `actions[]` from `GET /meta/object` **plus** standalone items from `GET /meta/action`, with + the object-embedded declaration winning a `:` key clash. Listing only the + first source would leave an administrator with no off-switch for a packaged standalone + action. +- **Flips** invoke the L6 write door `POST /actions/_activation/:object/:action` with exactly + the one key its body declares (`{ enabled }`); `global` is the object segment for an + object-less action, the same spelling both dispatch doors take. + +**⛔ No clone for actions.** The flows section keeps its own (§7.1); amendment ruling 3 +charters the *switch* for actions and nothing else, and §8 keeps the clone half pre-chartered +until real pull appears. A clone control here would advertise machinery that does not exist — +which is also why the server's §5 refusal for actions recommends the platform operator and an +ordinary sibling action where the flow refusal recommends a clone. + +**⛔ No drift or ancestry surface** (§9): no "customized" badge, no diff-vs-base, no +base-moved notice. The platform tracks no such lineage, so a surface showing it would be +showing something it had to invent. The absence is pinned against a response that smuggles +`clonedFrom` / `baseVersion` in, so it is enforced at the renderer and not merely by the wire. + +Server refusals reach the operator **verbatim** — no client-side softening, no retry loop. +Three shapes are reachable in tests, each transcribed character-for-character from the +runtime's own message builders: the §5 posture gate (403 `PERMISSION_DENIED`, naming the +posture *and* the sanctioned path), the ambiguous-name refusal (409 `RESOURCE_CONFLICT`, +naming the objects a machine name collides across — a list nothing on the client could +reconstruct) and the no-ledger outage (503 `SERVICE_UNAVAILABLE`). + +One further refusal is the section's own and it points the same way: a `hasMore` on the +ledger read is treated as a **load failure** rather than rendered. A dropped row reads as +"active", so a partial ledger would show a switched-off action as armed — the one direction +this section must not fail in. + +The flows section is unchanged in behaviour. It gains a heading beside the new one, and the +page subtitle now says "Flows and actions" — that string moved in all ten packs together. +Nine new `packagedAutomation.*` keys land in `en` **and in all nine other packs** as real +translations; the two toggle-failure keys are artifact-neutral by wording and are reused +rather than duplicated. diff --git a/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx b/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx new file mode 100644 index 000000000..a3cb60e1e --- /dev/null +++ b/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx @@ -0,0 +1,472 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup › Packaged automation, the packaged-ACTIONS section (ADR-0126 §8 + * item 2, amendment ruling 3) — behaviour. + * + * Five properties, in the order the section exercises them: + * + * 1. **state render** — a packaged action is ON unless the ledger says + * otherwise, and absence of a row is the ordinary stock-boot state rather + * than a missing fact. The scoping and ledger rules themselves live in + * `packagedActions.ts` and are measured directly in `packagedActions.test.ts`; + * what is checked here is that the rendered section applies them. + * 2. **round-trip** — flipping the switch calls the L6 activation door with + * the requested state, at the `/` path, with EXACTLY the + * one key the body declares — and the shown state follows the SERVER's + * answer, not the click. + * 3. **refusals, verbatim** — the three shapes this section must relay: the + * §5 operator gate (403 `PERMISSION_DENIED`), the ambiguous-name refusal + * (409 `RESOURCE_CONFLICT`, which NAMES the objects) and the no-ledger + * outage (503 `SERVICE_UNAVAILABLE`). Each is asserted as the exact + * server string; all three are transcribed character-for-character from + * the runtime's own message builders, and what is under test is that the + * section renders what it was sent, unedited. + * 4. **no clone** — ruling 3 charters the switch and nothing else, so the + * section offers no clone control at all. The flows section beside it + * keeps its own; this asserts the ABSENCE here, which is the half a + * "make the two sections consistent" change would quietly undo. + * 5. **no ancestry** — the withdrawn §9 shapes are absent, including when a + * response carries one. Pinning the rule at the RENDERER and not only at + * the wire is the point: a response field is the cheapest place for + * ancestry to reappear. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; + +import { PackagedActionsSection } from './PackagedActionsSection'; + +/* -------------------------------------------------------------------------- */ +/* The server's own refusal strings */ +/* -------------------------------------------------------------------------- */ + +/** + * `refuseUngrantedActivationWrite` with `ACTION_ACTIVATION_SUBJECT` + * (`@objectstack/runtime`, `domains/activation-gate.ts`) for the `group` + * posture. Transcribed exactly: the clause naming the posture AND the one + * naming the sanctioned path are both load-bearing. + * + * ⚠️ The remedy clause is the ACTION one, and it deliberately does not say + * "clone": the gate carries a per-door remedy sentence precisely because + * action-clone is unchartered, and recommending one would advertise machinery + * that does not exist. A client that reworded this would put it back. + */ +const POSTURE_REFUSAL = + 'Enabling or disabling a packaged action writes an INSTALL-WIDE activation row, and this deployment runs ' + + "the 'group' tenancy posture, where that reaches every organization. It requires the platform operator " + + '(ADR-0126 §5) — an organization administrator cannot flip an install-wide switch. To switch a packaged ' + + 'action off for this installation, ask your platform operator; authoring your own action alongside it ' + + 'stays open to you.'; + +/** + * `refuseAmbiguousActionActivation` (`@objectstack/runtime`, `domains/actions.ts`). + * The OBJECT NAMES are the whole value of this refusal — nothing on the client + * can reconstruct which objects a machine name collides across. + */ +const AMBIGUITY_REFUSAL = + "Action 'export' is declared on 2 objects ('account', 'contact'), and the activation ledger addresses an " + + 'action by its machine name (ADR-0126 §4) — one row would switch every one of them, not just the one on ' + + "'account'. Refusing rather than changing artifacts you did not name. Give the actions distinct machine " + + 'names, or leave them armed.'; + +/** + * The engine's own 503, relayed by the door's `catch` arm: the activation + * ledger is not attached on this deployment, so nothing can be made durable. + * An outage is not a verdict about the action, and the sentence is the + * server's to phrase. + */ +const NO_LEDGER_REFUSAL = + 'No activation ledger is attached to this engine, so a packaged action cannot be switched off durably ' + + '(ADR-0126 §4). Refusing rather than reporting a switch that would not survive a restart.'; + +/* -------------------------------------------------------------------------- */ +/* Fake server */ +/* -------------------------------------------------------------------------- */ + +interface Fixture { + /** `GET /meta/object` items. */ + objects: Array>; + /** `GET /meta/action` items. */ + standalone: Array>; + /** `sys_metadata_activation` rows. */ + ledger: Array>; + /** Extra keys spliced into the ledger list response (e.g. `hasMore`). */ + ledgerExtra: Record; + /** Queued answers for `POST /actions/_activation/…`, in call order. */ + flip: Array<{ status: number; body: unknown }>; +} + +let fixture: Fixture; +let calls: Array<{ url: string; method: string; body?: unknown }>; + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +/** ADR-0112 error envelope, as the runtime's `deps.error` puts it on the wire. */ +function errorEnvelope(code: string, message: string) { + return { success: false, error: { code, message } }; +} + +/** A packaged object with embedded action declarations. */ +function packagedObject(name: string, actions: Array>) { + return { name, label: name, actions, _packageId: 'com.objectstack.crm', _provenance: 'package' }; +} + +/** One install-level `sys_metadata_activation` row. */ +function ledgerRow(name: string, active: boolean) { + return { + metadata_type: 'action', + name, + package_id: 'com.objectstack.crm', + organization_id: null, + active, + }; +} + +beforeEach(() => { + calls = []; + fixture = { + objects: [ + packagedObject('account', [ + { name: 'send_invoice', label: 'Send invoice' }, + { name: 'archive_account', label: 'Archive account' }, + ]), + ], + standalone: [], + ledger: [], + ledgerExtra: {}, + flip: [], + }; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, method, body }); + + if (url.includes('/actions/_activation/')) { + const next = fixture.flip.shift(); + if (next) return response(next.status, next.body); + const enabled = (body as { enabled?: boolean } | undefined)?.enabled ?? true; + const [objectName, name] = url.split('/actions/_activation/')[1].split('/').map(decodeURIComponent); + // The server is the authority on what the state became — mirror it. + return response(200, { success: true, data: { name, objectName, enabled } }); + } + if (url.includes('/meta/object')) { + return response(200, { items: fixture.objects }); + } + if (url.includes('/meta/action')) { + return response(200, { items: fixture.standalone }); + } + if (url.includes('/data/sys_metadata_activation')) { + return response(200, { + success: true, + data: { + object: 'sys_metadata_activation', + records: fixture.ledger, + ...fixture.ledgerExtra, + }, + }); + } + throw new Error(`unexpected fetch: ${method} ${url}`); + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** Render and wait for the first list paint. */ +async function renderSection() { + render(); + await screen.findByText('Send invoice'); +} + +const flipCalls = () => calls.filter((c) => c.url.includes('/actions/_activation/')); + +/* -------------------------------------------------------------------------- */ +/* 1) State render */ +/* -------------------------------------------------------------------------- */ + +describe('activation state as the ledger reports it', () => { + it('shows a packaged action as ON when the ledger holds no row for it', async () => { + await renderSection(); + expect(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })).toBeChecked(); + expect( + screen.getByRole('switch', { name: 'Activation for Archive account on account' }), + ).toBeChecked(); + }); + + it('shows an action the ledger disabled as OFF, without any further ceremony', async () => { + fixture.ledger = [ledgerRow('send_invoice', false)]; + await renderSection(); + + expect( + screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), + ).not.toBeChecked(); + // Its sibling, which has no row, is unaffected. + expect( + screen.getByRole('switch', { name: 'Activation for Archive account on account' }), + ).toBeChecked(); + }); + + it('leaves a TENANT-authored action off the section', async () => { + // A tenant overlay bound to a package carries a real package id, so only + // the provenance clause keeps it out (the cloud#970 counterexample). + fixture.objects = [ + ...fixture.objects, + { + name: 'my_object', + label: 'Mine', + _packageId: 'app.crm', + _provenance: 'org', + actions: [{ name: 'my_action', label: 'My action' }], + }, + ]; + await renderSection(); + expect(screen.queryByText('My action')).toBeNull(); + }); + + it('refuses to render a TRUNCATED ledger page rather than show a stale ON', async () => { + // A dropped row reads as "active", so a partial ledger would show a + // switched-off action as armed — the one direction this section must not + // fail in. + fixture.ledger = [ledgerRow('send_invoice', false)]; + fixture.ledgerExtra = { hasMore: true }; + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/refusing to show a partial ledger/i); + expect(screen.queryByRole('switch')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 2) Activation round-trip */ +/* -------------------------------------------------------------------------- */ + +describe('activation round-trip', () => { + it('disables and re-enables a packaged action, showing what the server reports', async () => { + await renderSection(); + + const invoice = screen.getByRole('switch', { name: 'Activation for Send invoice on account' }); + expect(invoice).toBeChecked(); + + fireEvent.click(invoice); + await waitFor(() => expect(flipCalls()).toHaveLength(1)); + expect(flipCalls()[0].method).toBe('POST'); + expect(flipCalls()[0].url).toContain('/actions/_activation/account/send_invoice'); + // ⛔ EXACTLY the one key the activation body declares — the door refuses + // unknown keys by name, and `{"enable": false}` (one letter off) is the + // #3899 shape that ENABLED an artifact and answered 200. + expect(flipCalls()[0].body).toEqual({ enabled: false }); + await waitFor(() => + expect( + screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), + ).not.toBeChecked(), + ); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await waitFor(() => expect(flipCalls()).toHaveLength(2)); + expect(flipCalls()[1].body).toEqual({ enabled: true }); + await waitFor(() => + expect( + screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), + ).toBeChecked(), + ); + }); + + it('moves only the flipped row, even when a sibling shares the object', async () => { + await renderSection(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await waitFor(() => + expect( + screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), + ).not.toBeChecked(), + ); + expect( + screen.getByRole('switch', { name: 'Activation for Archive account on account' }), + ).toBeChecked(); + }); + + it('addresses an object-less action through the `global` segment', async () => { + fixture.objects = []; + fixture.standalone = [ + { name: 'nightly_sync', label: 'Nightly sync', _packageId: 'com.objectstack.crm', _provenance: 'package' }, + ]; + render(); + await screen.findByText('Nightly sync'); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Nightly sync on global' })); + await waitFor(() => expect(flipCalls()).toHaveLength(1)); + expect(flipCalls()[0].url).toContain('/actions/_activation/global/nightly_sync'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 3) Refusals, verbatim */ +/* -------------------------------------------------------------------------- */ + +describe('server refusals reach the operator verbatim', () => { + it('renders the §5 posture gate refusal (403 PERMISSION_DENIED) unedited', async () => { + fixture.flip = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; + await renderSection(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + + const shown = await screen.findByText(POSTURE_REFUSAL); + expect(shown).toBeInTheDocument(); + // The half a summarising client would drop: what the refused admin can do. + expect(shown.textContent).toContain('ask your platform operator'); + // The refused flip did not move the shown state. + expect(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })).toBeChecked(); + // ⛔ And it was not retried. + expect(flipCalls()).toHaveLength(1); + }); + + it('renders the ambiguous-name refusal (409 RESOURCE_CONFLICT) with its named objects', async () => { + fixture.objects = [ + packagedObject('account', [{ name: 'export', label: 'Export' }]), + packagedObject('contact', [{ name: 'export', label: 'Export' }]), + ]; + fixture.flip = [{ status: 409, body: errorEnvelope('RESOURCE_CONFLICT', AMBIGUITY_REFUSAL) }]; + render(); + await screen.findByRole('switch', { name: 'Activation for Export on account' }); + + // Both rows are listed — the section does not pre-empt the server's + // conflict with a client-side guess about which one is "real". + expect(screen.getByRole('switch', { name: 'Activation for Export on contact' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Export on account' })); + + const shown = await screen.findByText(AMBIGUITY_REFUSAL); + expect(shown).toBeInTheDocument(); + // The object names survive — nothing on the client could reconstruct them. + expect(shown.textContent).toContain("('account', 'contact')"); + }); + + it('renders the no-ledger outage (503 SERVICE_UNAVAILABLE) unedited', async () => { + fixture.flip = [{ status: 503, body: errorEnvelope('SERVICE_UNAVAILABLE', NO_LEDGER_REFUSAL) }]; + await renderSection(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + + expect(await screen.findByText(NO_LEDGER_REFUSAL)).toBeInTheDocument(); + // An outage is not a verdict, and it is not a hiccup to paper over either: + // one attempt, no retry loop, and the state stays where the server left it. + expect(flipCalls()).toHaveLength(1); + expect(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })).toBeChecked(); + }); + + it('keeps each row\'s refusal on its own row', async () => { + fixture.flip = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; + await renderSection(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await screen.findByText(POSTURE_REFUSAL); + + const invoiceRow = screen.getByTestId('packaged-action-account-send_invoice'); + const archiveRow = screen.getByTestId('packaged-action-account-archive_account'); + expect(within(invoiceRow).getByRole('alert')).toHaveTextContent('platform operator'); + expect(within(archiveRow).queryByRole('alert')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 4) No clone (ADR-0126 §8 item 2 — not chartered) */ +/* -------------------------------------------------------------------------- */ + +describe('no clone for actions', () => { + it('offers the switch and nothing else — no clone control anywhere', async () => { + await renderSection(); + + const section = screen.getByTestId('packaged-actions-section'); + expect(within(section).queryByRole('button', { name: /clone/i })).toBeNull(); + expect(within(section).queryByText(/clone/i)).toBeNull(); + expect(within(section).queryByText(/duplicate/i)).toBeNull(); + expect(within(section).queryByText(/copy/i)).toBeNull(); + // The switches ARE there — this asserts an absence beside a presence, so a + // section that failed to render could not pass it by rendering nothing. + expect(within(section).getAllByRole('switch')).toHaveLength(2); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 5) No ancestry or drift surface (ADR-0126 §9) */ +/* -------------------------------------------------------------------------- */ + +describe('no ancestry or drift surface', () => { + const WITHDRAWN = [ + /based on/i, + /customized/i, + /diff/i, + /compare/i, + /ancestry/i, + /lineage/i, + /out of date/i, + /base (has )?moved/i, + /upstream chang/i, + ]; + + it('shows a packaged action with no drift badge, no diff-vs-base and no base-moved notice', async () => { + await renderSection(); + for (const shape of WITHDRAWN) { + expect(screen.queryByText(shape)).toBeNull(); + } + expect(screen.queryByText(/based on v\d/i)).toBeNull(); + expect(screen.queryByText(/cloned from/i)).toBeNull(); + }); + + it('does not display ancestry even when the metadata and the flip response carry it', async () => { + // The platform tracks no such lineage, so these fields cannot arrive from + // a real server — feeding them anyway proves the ABSENCE is enforced by + // this section and not merely by the wire. + fixture.objects = [ + packagedObject('account', [ + { name: 'send_invoice', label: 'Send invoice', clonedFrom: 'base_send_invoice', baseVersion: 'v3' }, + ]), + ]; + fixture.flip = [ + { + status: 200, + body: { + success: true, + data: { + name: 'send_invoice', + objectName: 'account', + enabled: false, + clonedFrom: 'base_send_invoice', + baseVersion: 'v3', + }, + }, + }, + ]; + await renderSection(); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await waitFor(() => + expect( + screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), + ).not.toBeChecked(), + ); + + for (const shape of WITHDRAWN) { + expect(screen.queryByText(shape)).toBeNull(); + } + const section = screen.getByTestId('packaged-actions-section'); + expect(section.textContent).not.toContain('base_send_invoice'); + expect(section.textContent).not.toContain('v3'); + }); +}); diff --git a/packages/app-shell/src/views/setup/PackagedActionsSection.tsx b/packages/app-shell/src/views/setup/PackagedActionsSection.tsx new file mode 100644 index 000000000..585738fb7 --- /dev/null +++ b/packages/app-shell/src/views/setup/PackagedActionsSection.tsx @@ -0,0 +1,409 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PackagedActionsSection — the packaged-ACTIONS half of Setup › Packaged + * automation (ADR-0126 §7.4 page, §8 item 2 charter, amendment ruling 3). + * + * The maintainer's pull, verbatim and untranslated: + * + * > 「动作 可能是需要开关的,因为有的 action 我不想启用。」 + * + * ## What this section is, and what it deliberately is not + * + * Per packaged action it does exactly ONE thing: **on/off for this scope**. + * That is all the `sys_metadata_activation` ledger knows about an action, and + * the section claims nothing more. + * + * ⛔ **No clone.** The flows section beside it clones (ADR-0126 §7.1); + * ruling 3 charters the SWITCH for actions and nothing else, and §8 item 2 + * keeps the clone half pre-chartered until real pull appears. A clone + * button here would advertise machinery that does not exist — which is + * also why the server's own §5 refusal for actions recommends the operator + * and an ordinary sibling action, where the flow refusal recommends a + * clone. + * ⛔ **No drift or ancestry surface** (§9): no "customized" badge, no + * diff-vs-base, no base-moved notice, no link from anything to a base. + * The platform does not track that lineage, so a surface showing it would + * be showing something it had to invent. `PackagedActionsSection.test.tsx` + * pins the absence, including against a response that smuggles such a + * field in, so a future "helpful" addition fails loudly. + * + * ## Where the two facts on a row come from + * + * `packagedActions.ts` holds the reading rules and the citation for each; the + * short version is that the artifacts come from the two declaration sources the + * runtime itself reads (`GET /meta/object` embedded `actions[]`, plus standalone + * `GET /meta/action` items) and the state comes from the `sys_metadata_activation` + * ledger, whose rows this page may list by the object's own declaration + * (`apiMethods: ['get', 'list']` — "Reads stay open so operability surfaces can + * answer 'what is disabled here?'"). Absence of a row means active. + * + * ## Server refusals reach the operator VERBATIM + * + * Three refusals are expected here and all three are the server's words, + * rendered as sent — ⛔ no client-side softening, shortening or re-wording, and + * ⛔ no retry loop (every one of the three is a verdict, not a hiccup): + * + * - **403 `PERMISSION_DENIED`** — the ADR-0126 §5 posture gate. In a + * `group` / `isolated` deployment the install-wide activation row requires + * the platform operator; the message names the posture AND the sanctioned + * path. Rewording it would drop the half that says what to do instead. + * - **409 `RESOURCE_CONFLICT`** — two objects declare this action name, and + * the ledger addresses an action by NAME (§4), so one row would switch + * every one of them. The message NAMES the objects, and that list is the + * entire value of the refusal — only the server can build it. + * - **503 `SERVICE_UNAVAILABLE`** — no activation ledger is attached to this + * deployment's engine, so nothing can be made durable. An outage is not a + * verdict about the action, and the message is the server's to phrase. + * + * `actionErrorDetail` reads the ADR-0112 envelope's message; the local fallback + * string is used ONLY when the response carried no message at all, never in + * place of one. + */ + +import * as React from 'react'; +import { + Badge, + Empty, + EmptyDescription, + EmptyTitle, + Skeleton, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { actionErrorDetail } from '@object-ui/core'; + +// The LEAF module, for the reason its own header gives: this section joins the +// page's graph, and the page is imported eagerly by `services/builtinComponents`. +import { apiBase } from '../../utils/apiBase.js'; +import { envelopeData, envelopeRefused } from './packagedFlows.js'; +import { + collectPackagedActions, + ledgerPageTruncated, + readActionActivation, + readDataRecords, + readMetadataItems, + type PackagedActionRow, +} from './packagedActions.js'; + +/* -------------------------------------------------------------------------- */ +/* Fetch helpers */ +/* -------------------------------------------------------------------------- */ + +const JSON_HEADERS = { Accept: 'application/json' } as const; + +/** + * How many ledger rows one page asks for. + * + * The ledger holds a row only for an artifact whose state was CHANGED from the + * packaged default, so this is orders of magnitude above any real installation + * — and the section does not rely on that being true: a response that reports + * `hasMore` is treated as a load failure rather than rendered, because a + * dropped row reads as "active" and would show a switched-off action as armed. + */ +const LEDGER_PAGE_SIZE = 1000; + +async function readJson(res: Response): Promise { + return res.json().catch(() => null); +} + +async function getJson(path: string, signal: AbortSignal): Promise { + return fetch(`${apiBase()}${path}`, { + credentials: 'include', + headers: JSON_HEADERS, + cache: 'no-store', + signal, + }); +} + +/** The `object` metadata list — every object, for its embedded `actions[]`. */ +async function fetchObjectMetadata(signal: AbortSignal): Promise { + const res = await getJson('/meta/object', signal); + if (!res.ok) throw new Error(`object metadata HTTP ${res.status}`); + return readMetadataItems(await readJson(res)); +} + +/** + * The standalone `action` metadata items. + * + * Tolerated as EMPTY when the read fails, and that is a transcription rather + * than a softening: the runtime's own `collectActionDeclarations` wraps this + * exact source in `try { … } catch { standalone = []; }` with the reason spelled + * out — *"no standalone-item source on this metadata service"*. A deployment + * whose metadata service cannot list standalone actions still has embedded ones + * to switch off, and failing the whole section there would take the off-switch + * away from every action to report the absence of an optional source. ⛔ The + * other two reads are NOT tolerated this way: an unreadable object list or an + * unreadable ledger means this section cannot say what it exists to say. + */ +async function fetchStandaloneActionMetadata(signal: AbortSignal): Promise { + try { + const res = await getJson('/meta/action', signal); + if (!res.ok) return []; + return readMetadataItems(await readJson(res)); + } catch (e) { + if ((e as { name?: string } | null)?.name === 'AbortError') throw e; + return []; + } +} + +/** The `sys_metadata_activation` rows — the activation EXCEPTIONS. */ +async function fetchActivationLedger(signal: AbortSignal): Promise> { + const res = await getJson(`/data/sys_metadata_activation?top=${LEDGER_PAGE_SIZE}`, signal); + if (!res.ok) throw new Error(`activation ledger HTTP ${res.status}`); + const json = await readJson(res); + if (ledgerPageTruncated(json)) { + // Refusing to render beats rendering the wrong direction: see + // `ledgerPageTruncated`. Untranslated on purpose — the same posture the + // flows half's `HTTP ` load errors take, which are the server's + // diagnostics and not operator copy. + throw new Error( + `activation ledger returned more than ${LEDGER_PAGE_SIZE} rows; refusing to show a partial ledger ` + + 'because a missing row reads as "active"', + ); + } + return readActionActivation(readDataRecords(json)); +} + +/* -------------------------------------------------------------------------- */ +/* Section */ +/* -------------------------------------------------------------------------- */ + +export interface PackagedActionsSectionProps { + /** + * Bumped by the page's Refresh control. One button refreshes both halves of + * the page — the alternative, a second Refresh beside this table, would let + * the two halves show states read at different moments with nothing saying so. + */ + nonce?: number; +} + +/** Row identity for busy/refusal bookkeeping: the pair the flip addresses. */ +function rowKey(row: Pick): string { + return `${row.objectName}:${row.name}`; +} + +export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProps) { + const { t } = useObjectTranslation(); + + const [rows, setRows] = React.useState(null); + const [loadError, setLoadError] = React.useState(null); + /** Key of the action whose flip is in flight. */ + const [busyKey, setBusyKey] = React.useState(null); + /** Per-action server refusal, keyed by `:`. Verbatim. */ + const [refusals, setRefusals] = React.useState>({}); + + React.useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + setLoadError(null); + (async () => { + try { + const [objects, standalone, activation] = await Promise.all([ + fetchObjectMetadata(controller.signal), + fetchStandaloneActionMetadata(controller.signal), + fetchActivationLedger(controller.signal), + ]); + if (cancelled) return; + setRows(collectPackagedActions(objects, standalone, activation)); + } catch (e) { + if (cancelled || controller.signal.aborted) return; + setRows([]); + setLoadError( + e instanceof Error && e.message + ? e.message + : t('packagedAutomation.actionsLoadFailed', { + defaultValue: 'Could not load packaged actions.', + }), + ); + } + })(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [nonce, t]); + + const clearRefusal = React.useCallback((key: string) => { + setRefusals((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); + + async function onToggle(row: PackagedActionRow, enabled: boolean) { + const key = rowKey(row); + setBusyKey(key); + clearRefusal(key); + try { + const res = await fetch( + `${apiBase()}/actions/_activation/${encodeURIComponent(row.objectName)}/${encodeURIComponent(row.name)}`, + { + method: 'POST', + credentials: 'include', + headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, + // ⛔ EXACTLY the one key the activation body declares. The door + // refuses unknown keys by name, and it is right to: the flow toggle's + // #3899 lesson is that an unchecked `{"enable": false}` — one letter + // off — ENABLED the artifact and answered 200. + body: JSON.stringify({ enabled }), + }, + ); + const json = await readJson(res); + if (!res.ok || envelopeRefused(json)) { + // VERBATIM. All three expected refusals carry the whole of what the + // administrator needs in this string, and two of them carry something + // no client could reconstruct: the objects a name collides across, and + // the reason this deployment cannot make the switch durable. + setRefusals((prev) => ({ + ...prev, + [key]: actionErrorDetail( + json, + // A SEPARATE key from the catch arm's below: this one has a status + // to name and that one does not, and one key cannot carry a hole + // only half its call sites can fill. + t('packagedAutomation.toggleFailedHttp', { + defaultValue: 'Could not change activation (HTTP {{status}}).', + status: res.status, + }), + ), + })); + return; + } + const reported = envelopeData(json).enabled; + const next = typeof reported === 'boolean' ? reported : enabled; + setRows( + (prev) => prev?.map((r) => (rowKey(r) === key ? { ...r, enabled: next } : r)) ?? prev, + ); + } catch (e) { + setRefusals((prev) => ({ + ...prev, + [key]: + e instanceof Error && e.message + ? e.message + : t('packagedAutomation.toggleFailed', { defaultValue: 'Could not change activation.' }), + })); + } finally { + setBusyKey(null); + } + } + + return ( +
+
+

+ {t('packagedAutomation.actionsHeading', { defaultValue: 'Packaged actions' })} +

+

+ {t('packagedAutomation.actionsSubtitle', { + defaultValue: + 'Actions shipped by installed packages. Turn one off for this deployment and it stops running everywhere it is offered. Authoring your own action alongside it stays open in Studio.', + })} +

+
+ + {loadError && ( +
+ {loadError} +
+ )} + + {rows === null && ( +
+ + +
+ )} + + {rows !== null && rows.length === 0 && !loadError && ( + + + {t('packagedAutomation.actionsEmptyTitle', { defaultValue: 'No packaged actions' })} + + + {t('packagedAutomation.actionsEmptyBody', { + defaultValue: + 'No installed package declares an action on this deployment. Actions you author yourself live in Studio.', + })} + + + )} + + {rows !== null && rows.length > 0 && ( + + + + {t('packagedAutomation.colAction', { defaultValue: 'Action' })} + + {t('packagedAutomation.colObject', { defaultValue: 'Object' })} + + + {t('packagedAutomation.colActivation', { defaultValue: 'Activation' })} + + + + + {rows.map((row) => { + const key = rowKey(row); + const refusal = refusals[key]; + return ( + + +
{row.label}
+ {row.name} + {refusal && ( + // VERBATIM server refusal, next to the control that + // caused it. `role="alert"` so it is announced. +

+ {refusal} +

+ )} +
+ + {/* The machine name, which is also the `:object` segment + the flip addresses — `global` for an object-less + action, the platform's own spelling. */} + {row.objectName} + + +
+ void onToggle(row, next)} + // The object is part of the accessible name because it + // is part of the identity: two objects may declare the + // same action name, and a label without the object + // would name two switches identically. + aria-label={t('packagedAutomation.actionToggleLabel', { + defaultValue: 'Activation for {{label}} on {{object}}', + label: row.label, + object: row.objectName, + })} + /> + + {row.enabled + ? t('packagedAutomation.on', { defaultValue: 'On' }) + : t('packagedAutomation.off', { defaultValue: 'Off' })} + +
+
+
+ ); + })} +
+
+ )} +
+ ); +} + +export default PackagedActionsSection; diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx index 6973b0410..af5c471c7 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx @@ -148,6 +148,25 @@ beforeEach(() => { if (row) row.enabled = enabled; return response(200, { success: true, data: { name, enabled } }); } + // The packaged-ACTIONS section (ADR-0126 §8 item 2) shares this page and + // issues its own three reads. Answered EMPTY here on purpose: this file + // owns the flows half, and its assertions must keep measuring that half + // and not the sibling section — whose own behaviour, refusals and §9 + // absences are pinned in `PackagedActionsSection.test.tsx`. Leaving them + // unanswered would not be neutral: the stub below throws, so every test + // in this file would render a section-level load error. + if (url.includes('/meta/object')) { + return response(200, { items: [] }); + } + if (url.includes('/meta/action')) { + return response(200, { items: [] }); + } + if (url.includes('/data/sys_metadata_activation')) { + return response(200, { + success: true, + data: { object: 'sys_metadata_activation', records: [] }, + }); + } if (url.includes('/clone')) { const next = fixture.clone.shift(); if (next) return response(next.status, next.body); diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx index 1cba6b413..28ebf8178 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -6,8 +6,13 @@ * ## What this page is, and what it deliberately is not * * Automation AUTHORING lives in Studio and stays there. This page is the - * OPERATIONAL surface for the flows a code package shipped: per packaged flow - * it does exactly two things, and claims nothing beyond them — + * OPERATIONAL surface for the automation a code package shipped, in two + * sections: the packaged FLOWS below, and the packaged ACTIONS in + * `PackagedActionsSection.tsx` (ADR-0126 §8 item 2, amendment ruling 3 — its + * own header carries that charter and the reasons its section has no clone). + * + * Per packaged flow this half does exactly two things, and claims nothing + * beyond them — * * 1. **on/off for this scope** — reads the activation state the engine * reports (`GET /api/v1/automation/_status`, backed by the ADR-0126 §7.2 @@ -106,6 +111,7 @@ import { type FlowRuntimeStateRow, type PackagedFlowRow, } from './packagedFlows.js'; +import { PackagedActionsSection } from './PackagedActionsSection.js'; /* -------------------------------------------------------------------------- */ /* Fetch helpers */ @@ -328,7 +334,7 @@ export function PackagedAutomationPage() {

{t('packagedAutomation.subtitle', { defaultValue: - 'Flows shipped by installed packages. Turn one off for this deployment, or clone it under a new name to customize it. Editing happens in Studio.', + 'Flows and actions shipped by installed packages. Turn one off for this deployment, or clone a flow under a new name to customize it. Editing happens in Studio.', })}

@@ -343,6 +349,13 @@ export function PackagedAutomationPage() { + {/* Two sections, each named: the page carries flows AND actions since + ADR-0126 §8 item 2, and an unlabelled first table beside a labelled + second one reads as though the second were an afterthought. */} +

+ {t('packagedAutomation.flowsHeading', { defaultValue: 'Packaged flows' })} +

+ {cloneResult && (

@@ -453,6 +466,12 @@ export function PackagedAutomationPage() { )} + {/* The packaged-ACTIONS half (ADR-0126 §8 item 2). It reads and writes + through its own doors and keeps its own state, so the flows half above + is untouched by it; the page's one Refresh control drives both, which + is why the nonce is handed down rather than duplicated. */} + +

!open && setClone(null)}> diff --git a/packages/app-shell/src/views/setup/packagedActions.test.ts b/packages/app-shell/src/views/setup/packagedActions.test.ts new file mode 100644 index 000000000..d8de4de1a --- /dev/null +++ b/packages/app-shell/src/views/setup/packagedActions.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which actions the packaged-ACTIONS section lists, and how it reads the two + * responses behind a row (ADR-0126 §8 item 2). + * + * The rules worth measuring here are the ones a DOM render cannot show cheaply + * and that a wrong answer would make the page lie about, in this order of + * consequence: + * + * 1. an activation row read from the wrong ledger slice, or from a driver + * that spells booleans 0/1, shows a switched-OFF action as armed; + * 2. the provenance question is asked of the wrong ITEM for an embedded + * action, which either hides an action from its only off-switch or offers + * an install-wide switch for something a tenant authored; + * 3. the `:` dedup runs the wrong way and the row's label and + * provenance come from a declaration the server would not have resolved. + */ + +import { describe, expect, it } from 'vitest'; + +import { + GLOBAL_ACTION_OBJECT, + collectPackagedActions, + ledgerPageTruncated, + packagedActionSource, + readActionActivation, + readDataRecords, + standaloneActionObjectName, +} from './packagedActions'; + +/** A packaged object carrying embedded action declarations. */ +function packagedObject(name: string, actions: Array>) { + return { name, label: name, actions, _packageId: 'com.objectstack.crm', _provenance: 'package' }; +} + +/** One `sys_metadata_activation` row as the data API answers it. */ +function ledgerRow(over: Record) { + return { + metadata_type: 'action', + name: 'x', + package_id: 'com.objectstack.crm', + organization_id: null, + active: true, + ...over, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Response readers */ +/* -------------------------------------------------------------------------- */ + +describe('readDataRecords', () => { + it('reads the wrapped and the bare form of one FindDataResponse', () => { + expect(readDataRecords({ success: true, data: { object: 'x', records: [{ name: 'a' }] } })).toEqual([ + { name: 'a' }, + ]); + expect(readDataRecords({ object: 'x', records: [{ name: 'b' }] })).toEqual([{ name: 'b' }]); + }); + + it('answers empty for anything that is not a record list', () => { + expect(readDataRecords(null)).toEqual([]); + expect(readDataRecords({ data: { records: 'nope' } })).toEqual([]); + expect(readDataRecords(undefined)).toEqual([]); + }); +}); + +describe('ledgerPageTruncated', () => { + it('is true only on an explicit hasMore', () => { + expect(ledgerPageTruncated({ data: { hasMore: true } })).toBe(true); + expect(ledgerPageTruncated({ hasMore: true })).toBe(true); + }); + + it('does not invent truncation from an absent or falsy field', () => { + // A backend that omits `hasMore` is asserting nothing — `FindDataResponse` + // declares it optional — and treating silence as truncation would blank + // the section on every such deployment. + expect(ledgerPageTruncated({ data: { records: [] } })).toBe(false); + expect(ledgerPageTruncated({ data: { hasMore: false } })).toBe(false); + expect(ledgerPageTruncated(null)).toBe(false); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The ledger slice */ +/* -------------------------------------------------------------------------- */ + +describe('readActionActivation', () => { + it('takes only the action rows — the ledger is shared with flows', () => { + const activation = readActionActivation([ + ledgerRow({ name: 'send_invoice', active: false }), + ledgerRow({ metadata_type: 'flow', name: 'pkg_notify', active: false }), + ]); + expect(activation.get('send_invoice')).toBe(false); + // A flow's row must not answer for an action of the same name, and must + // not answer at all here. + expect(activation.has('pkg_notify')).toBe(false); + }); + + it('skips a row scoped to an organization instead of merging it', () => { + // §5 keeps the per-org dimension reserved and unwritten on this line, so a + // row carrying one was not written by this door — reading it as + // install-level would show one organization's choice as everyone's. + const activation = readActionActivation([ + ledgerRow({ name: 'send_invoice', active: false, organization_id: 'org_1' }), + ]); + expect(activation.has('send_invoice')).toBe(false); + }); + + it('reads a 0/1 driver boolean the way the engine store does', () => { + // SQLite/libsql round-trip booleans as 0/1. A `0` read as truthy would + // show a disabled action as armed — the direction that matters. + const activation = readActionActivation([ + ledgerRow({ name: 'zero', active: 0 }), + ledgerRow({ name: 'one', active: 1 }), + ledgerRow({ name: 'yes', active: true }), + ]); + expect(activation.get('zero')).toBe(false); + expect(activation.get('one')).toBe(true); + expect(activation.get('yes')).toBe(true); + }); + + it('ignores a row with no usable name', () => { + expect(readActionActivation([ledgerRow({ name: '' }), ledgerRow({ name: 42 })]).size).toBe(0); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Owning object + provenance source */ +/* -------------------------------------------------------------------------- */ + +describe('standaloneActionObjectName', () => { + it('follows the engine registration key: objectName, then object, then global', () => { + expect(standaloneActionObjectName({ objectName: 'account' })).toBe('account'); + expect(standaloneActionObjectName({ object: 'contact' })).toBe('contact'); + expect(standaloneActionObjectName({})).toBe(GLOBAL_ACTION_OBJECT); + expect(standaloneActionObjectName({ objectName: '' })).toBe(GLOBAL_ACTION_OBJECT); + }); + + it('spells the object-less case exactly as the activation door takes it', () => { + // The `:object` segment is mandatory on `POST /actions/_activation/…`, and + // `global` is the spelling both dispatch doors use. A different string + // here would address a declaration the server cannot resolve. + expect(GLOBAL_ACTION_OBJECT).toBe('global'); + }); +}); + +describe('packagedActionSource', () => { + const obj = { name: 'account', _packageId: 'com.objectstack.crm', _provenance: 'package' }; + + it('falls through to the owning object for an embedded action', () => { + // The registry stamps provenance on the item it registers — the object — + // so an embedded action carries none and cannot answer for itself. + expect(packagedActionSource({ name: 'send_invoice' }, obj)).toBe(obj); + }); + + it('lets a standalone item answer for itself, in both directions', () => { + const packaged = { name: 'a', _packageId: 'com.objectstack.crm' }; + expect(packagedActionSource(packaged, obj)).toBe(packaged); + // The case the fall-through would get wrong: a tenant-authored action + // sitting on a PACKAGED object. It carries its own provenance, so it + // answers, and the answer is "not packaged". + const tenant = { name: 'a', _provenance: 'org' }; + expect(packagedActionSource(tenant, obj)).toBe(tenant); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The join */ +/* -------------------------------------------------------------------------- */ + +describe('collectPackagedActions', () => { + const objects = [ + packagedObject('account', [ + { name: 'send_invoice', label: 'Send invoice' }, + { name: 'archive', label: 'Archive' }, + ]), + // A tenant-authored object overlay BOUND to a package: it carries a real + // package id, so only the provenance clause keeps its actions off the page. + { name: 'my_object', label: 'Mine', _packageId: 'app.crm', _provenance: 'org', actions: [{ name: 'mine', label: 'Mine' }] }, + ]; + + it('lists the packaged embedded actions and leaves a tenant object\'s out', () => { + const rows = collectPackagedActions(objects, [], new Map()); + expect(rows.map((r) => r.name)).toEqual(['archive', 'send_invoice']); + expect(rows.every((r) => r.objectName === 'account')).toBe(true); + }); + + it('defaults to active and applies the ledger exceptions by name', () => { + const rows = collectPackagedActions(objects, [], new Map([['send_invoice', false]])); + // Absence of a row means the packaged default — active. + expect(rows.find((r) => r.name === 'archive')?.enabled).toBe(true); + expect(rows.find((r) => r.name === 'send_invoice')?.enabled).toBe(false); + }); + + it('includes a packaged STANDALONE action under its declared object', () => { + const rows = collectPackagedActions( + objects, + [{ name: 'reindex', label: 'Reindex', objectName: 'account', _packageId: 'com.objectstack.crm', _provenance: 'package' }], + new Map(), + ); + expect(rows.find((r) => r.name === 'reindex')).toMatchObject({ + objectName: 'account', + label: 'Reindex', + enabled: true, + }); + }); + + it('lists an object-less standalone action under `global`', () => { + const rows = collectPackagedActions( + [], + [{ name: 'nightly', label: 'Nightly', _packageId: 'com.objectstack.crm', _provenance: 'package' }], + new Map(), + ); + expect(rows).toEqual([ + { name: 'nightly', objectName: GLOBAL_ACTION_OBJECT, label: 'Nightly', enabled: true }, + ]); + }); + + it('lets the OBJECT-EMBEDDED declaration win a key clash', () => { + // `collectActionDeclarations`' rule, mirroring the execution layer's + // artifact-wins precedence. Backwards, the row would carry the label and + // provenance of a declaration the server would never resolve or flip. + const rows = collectPackagedActions( + objects, + [{ name: 'send_invoice', label: 'STANDALONE COPY', objectName: 'account', _packageId: 'com.objectstack.crm' }], + new Map(), + ); + const hits = rows.filter((r) => r.name === 'send_invoice'); + expect(hits).toHaveLength(1); + expect(hits[0].label).toBe('Send invoice'); + }); + + it('keeps BOTH rows when two objects declare the same action name', () => { + // The ledger addresses an action by name alone (§4), so one row reaches + // both — and the write door refuses the flip with a 409 that NAMES the + // objects. Hiding a row here would replace that explanation with a guess. + const rows = collectPackagedActions( + [ + packagedObject('account', [{ name: 'export', label: 'Export' }]), + packagedObject('contact', [{ name: 'export', label: 'Export' }]), + ], + [], + new Map([['export', false]]), + ); + expect(rows.map((r) => r.objectName)).toEqual(['account', 'contact']); + // One ledger row, so both rows read the same state. That IS what the + // ledger says, and the page reports it rather than inventing per-object + // state the platform does not keep. + expect(rows.every((r) => r.enabled === false)).toBe(true); + }); + + it('falls back to the machine name when a declaration has no label', () => { + const rows = collectPackagedActions([packagedObject('account', [{ name: 'raw' }])], [], new Map()); + expect(rows[0].label).toBe('raw'); + }); + + it('skips a declaration with no usable name', () => { + const rows = collectPackagedActions( + [packagedObject('account', [{ label: 'nameless' }, { name: '' }])], + [], + new Map(), + ); + expect(rows).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/setup/packagedActions.ts b/packages/app-shell/src/views/setup/packagedActions.ts new file mode 100644 index 000000000..1858b4a68 --- /dev/null +++ b/packages/app-shell/src/views/setup/packagedActions.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The reading rules behind the packaged-ACTIONS half of Setup › Packaged + * automation (ADR-0126 §7.4 page, §8 item 2 charter) — which actions the + * section lists, and how it reads the two responses it depends on. + * + * Separate from `PackagedActionsSection.tsx` for the same reason + * `packagedFlows.ts` is separate from the page: these are not components, the + * scoping rule is the part most worth testing directly, and reaching it + * through the DOM costs a full render. It runs in the `unit` project from here. + * + * ## Where each of the two facts comes from + * + * A row needs two things the platform keeps in two different places, and the + * flows half's shape is mirrored exactly — one read for the ARTIFACTS, one for + * the ACTIVATION STATE, joined here: + * + * 1. **Which actions exist, on which object, and did a package ship them.** + * Actions are declared in two places and the runtime reads BOTH + * (`collectActionDeclarations`, `@objectstack/runtime`'s + * `action-execution.ts`): embedded in an object's `actions[]`, and as + * standalone `action` metadata items. The two HTTP reads are `GET + * /meta/object` and `GET /meta/action`. Listing only the first would leave + * an administrator with no off-switch for a packaged standalone action — + * the same defect `packagedFlows.ts` names for a filtered-out flow. + * 2. **Whether it is switched off here.** The `sys_metadata_activation` + * ledger, rows carrying `metadata_type: 'action'` (ADR-0126 §4). That + * object declares `apiMethods: ['get', 'list']` with the reason stated in + * its own source — *"Reads stay open so operability surfaces can answer + * 'what is disabled here?'"* — so the generic data API list IS the + * sanctioned read for this page. ⛔ There is no `_status`-style read door + * for actions to mirror the flows half with, and this page does not invent + * one: the write door (objectstack#12348) shipped the flip only. + * + * ## Absence of a row means ACTIVE + * + * Nothing writes a row to say "active by default", so an empty ledger is the + * ordinary stock-boot state and every action reads armed. That is the ledger's + * own contract (ADR-0126 §4) and it is why {@link collectPackagedActions} takes + * a map of the exceptions rather than a state per action. + * + * ## What this module deliberately does NOT compute (ADR-0126 §9) + * + * ⛔ No clone target — the action-clone half is unchartered (§8 item 2), so + * nothing here designates, copies or links an artifact to another one. ⛔ No + * drift, ancestry or base-version fact: the platform does not track that + * lineage, so a field claiming it would be invented here. + */ + +// The provenance predicate is imported, ⛔ not transcribed a second time. Its +// clauses (`_packageId` present, not the `sys_metadata` sentinel, `_provenance` +// not `'org'`) are about a REGISTRY ITEM's origin — they read the two keys the +// registry stamps on every metadata item, and say nothing about flows. Its +// header carries the whole argument for each clause and the cloud#970 +// measurement behind the middle one; one definition, cited from both halves of +// this page, cannot drift the way two agreeing copies can. +import { isPackagedFlowItem, readMetadataItems } from './packagedFlows.js'; + +/** + * The object segment an OBJECT-LESS action takes, on the wire and on this page. + * + * `GLOBAL_ACTION_OBJECT_KEY` in `@objectstack/objectql`'s `action-governance.ts` + * (a server package, so the value is transcribed rather than imported). The + * activation door's path is `POST /actions/_activation/:object/:action` with the + * object segment MANDATORY — *"use `global` for an object-less action"*, the + * same spelling the invocation door takes — so this is the literal string that + * goes in the URL, and showing it in the object column is showing what the flip + * will actually address. + */ +export const GLOBAL_ACTION_OBJECT = 'global'; + +/** The `sys_metadata_activation` discriminator this section reads. */ +export const ACTION_METADATA_TYPE = 'action'; + +/** A packaged action as the section renders it. */ +export interface PackagedActionRow { + /** + * The declarative machine name (ADR-0110 D1) — the ledger's row key AND the + * `:action` segment of the activation door. + */ + name: string; + /** + * The owning object's machine name, or {@link GLOBAL_ACTION_OBJECT}. The + * `:object` segment of the activation door. + */ + objectName: string; + /** Display name; falls back to the machine name when the item has none. */ + label: string; + /** Activation as the ledger reports it — `true` when no row says otherwise. */ + enabled: boolean; +} + +/** + * The record list out of `GET /data/:object`, wrapped or bare. + * + * `FindDataResponse = { object, records, total?, hasMore? }` inside the + * transport envelope's `data`. The two spellings are the wrapped and bare forms + * of one response, exactly as `readRuntimeStates` reads the flow half's + * `_status` — ⛔ not two dialects, and ⛔ not a tolerant alias for a shape the + * server does not send. + */ +export function readDataRecords(payload: unknown): Array> { + const p = payload as + | { data?: { records?: unknown }; records?: unknown } + | null + | undefined; + const list = p?.data?.records ?? p?.records ?? []; + return Array.isArray(list) ? (list as Array>) : []; +} + +/** + * Did the ledger answer with a TRUNCATED page? + * + * This matters more here than pagination usually does, because of which way the + * lie points: a missing row reads as ACTIVE, so a dropped page would show an + * action an administrator switched OFF as armed — a page that quietly + * contradicts the deployment on the one fact it exists to report. The section + * therefore treats a truncated read as a load failure and shows nothing, rather + * than rendering a list it already knows is wrong. + * + * Only an explicit `hasMore === true` counts. The field is optional in + * `FindDataResponse`; a backend that omits it is not asserting truncation, and + * inventing one from a row count would need a page size this page does not know. + */ +export function ledgerPageTruncated(payload: unknown): boolean { + const p = payload as { data?: { hasMore?: unknown }; hasMore?: unknown } | null | undefined; + return (p?.data?.hasMore ?? p?.hasMore) === true; +} + +/** + * The activation EXCEPTIONS — action name → armed — read from + * `sys_metadata_activation` rows. + * + * A clause-for-clause transcription of `ObjectStoreActionActivationStore.list()` + * (`@objectstack/objectql`'s `action-activation.ts`), which cannot be imported + * here (a server package). Each clause is the store's, and each one changes + * what this page shows: + * + * - **`metadata_type === 'action'`.** The ledger is generic (ADR-0126 §4) and + * flow rows share the table today. Reading a flow's row as an action's + * would switch the wrong artifact's badge. + * - **rows carrying an `organization_id` are SKIPPED, not merged.** The + * per-org dimension is reserved and unwritten on this line (§5), so a row + * with one set was not written by this door. Reading it as install-level + * would show one organization's choice as the whole installation's — the + * store's own stated reason, arrived at from the read side. + * - **`active` is TRUE unless it is explicitly `false` or `0`.** The column + * defaults to true, and a driver that round-trips booleans as 0/1 + * (SQLite/libsql) must not have its `0` read as "armed". + */ +export function readActionActivation( + records: readonly Record[], +): Map { + const activation = new Map(); + for (const row of records) { + if (row?.metadata_type !== ACTION_METADATA_TYPE) continue; + if (row?.organization_id != null) continue; + const name = row?.name; + if (typeof name !== 'string' || !name) continue; + const active = row?.active; + activation.set(name, !(active === false || active === 0)); + } + return activation; +} + +/** + * Owning object of a STANDALONE `action` item. + * + * Transcribed from `standaloneActionObjectName` (`@objectstack/runtime`), which + * is itself kept in lockstep with the engine's registration key: spec + * `objectName`, bundle-collector `object`, else the `global` wildcard. It has to + * agree, because the string it returns becomes the `:object` segment of the + * flip — a different answer here would address a declaration the server cannot + * resolve, and the door would 404 on an action the page had just listed. + */ +export function standaloneActionObjectName(action: unknown): string { + const a = action as { objectName?: unknown; object?: unknown } | null | undefined; + if (typeof a?.objectName === 'string' && a.objectName.length > 0) return a.objectName; + if (typeof a?.object === 'string' && a.object.length > 0) return a.object; + return GLOBAL_ACTION_OBJECT; +} + +/** + * Which item answers the "did a package ship this?" question for one action. + * + * An action EMBEDDED in an object's `actions[]` is a plain sub-object: the + * registry stamps `_packageId` / `_provenance` on the ITEM it registers, which + * is the object, so an embedded action carries neither and its owning object is + * the only thing that can answer. A STANDALONE `action` item is registered in + * its own right and answers for itself. + * + * This is the read-side twin of how the activation door derives the package it + * writes into the row — `declaration.action._packageId ?? declaration.obj._packageId` + * — so the page classifies an action the same way the server does. The test is + * "does the action carry provenance marks of its own", not "is `_packageId` + * truthy": an action that carries `_provenance: 'org'` and no package id has + * answered, and falling through to its packaged owning object would list a + * tenant-authored action as packaged. + */ +export function packagedActionSource(action: unknown, obj: unknown): unknown { + const a = action as { _packageId?: unknown; _provenance?: unknown } | null | undefined; + if (typeof a?._packageId === 'string' || typeof a?._provenance === 'string') return action; + return obj; +} + +/** + * Join the two declaration sources with the activation ledger, scoped to the + * PACKAGED actions. + * + * The collection order and the dedup rule are transcribed from + * `collectActionDeclarations` (`@objectstack/runtime`), including the one that + * is easy to miss: on a `:` key clash the OBJECT-EMBEDDED + * declaration wins, mirroring the execution layer's artifact-wins rule. Getting + * that backwards would not just reorder the list — it would show a row whose + * label and provenance came from a declaration that is not the one the server + * would resolve and flip. + * + * ⚠️ Two objects may declare the SAME action name, and both rows are listed. + * That is not a bug being papered over: the ledger addresses an action by its + * machine name alone (§4), so one row would reach both, and the write door + * refuses the flip with a `409 RESOURCE_CONFLICT` that NAMES the objects. + * Hiding one row here, or disabling its switch, would replace the server's + * explanation with a client-side guess about a conflict only the server can + * describe. + */ +export function collectPackagedActions( + objectItems: readonly unknown[], + standaloneItems: readonly unknown[], + activation: ReadonlyMap, +): PackagedActionRow[] { + const rows: PackagedActionRow[] = []; + const seen = new Set(); + + const push = (action: unknown, objectName: string, obj: unknown) => { + const a = action as { name?: unknown; label?: unknown } | null; + const name = a?.name; + if (typeof name !== 'string' || !name) return; + const key = `${objectName}:${name}`; + if (seen.has(key)) return; + seen.add(key); + if (!isPackagedFlowItem(packagedActionSource(action, obj))) return; + const label = a?.label; + rows.push({ + name, + objectName, + label: typeof label === 'string' && label ? label : name, + enabled: activation.get(name) ?? true, + }); + }; + + const objByName = new Map(); + for (const raw of objectItems) { + const obj = raw as { name?: unknown } | null; + if (typeof obj?.name === 'string' && obj.name) objByName.set(obj.name, raw); + } + + // 1. Object-embedded declarations first — they win the key clash below. + for (const raw of objectItems) { + const obj = raw as { name?: unknown; actions?: unknown } | null; + const objectName = obj?.name; + if (typeof objectName !== 'string' || !objectName) continue; + for (const action of Array.isArray(obj?.actions) ? obj.actions : []) { + push(action, objectName, raw); + } + } + + // 2. Standalone `action` items, skipped where an embedded declaration of the + // same `:` key already claimed the slot. + for (const action of standaloneItems) { + const objectName = standaloneActionObjectName(action); + push(action, objectName, objByName.get(objectName)); + } + + return rows.sort( + (a, b) => + a.label.localeCompare(b.label) || + a.objectName.localeCompare(b.objectName) || + a.name.localeCompare(b.name), + ); +} + +// Re-exported so the section reads its two metadata responses through the SAME +// reader the flows half uses for `GET /meta/flow` — one answer to "bare array or +// `{ items }`?", for every `/meta` list this page reads. +export { readMetadataItems }; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 4ddcc0183..cc043bab8 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3421,8 +3421,10 @@ const ar = { // المجموعة (§9). packagedAutomation: { title: "أتمتة الحزم", - subtitle: "تدفقات تأتي مع الحزم المثبّتة. يمكنك إيقاف أحدها في هذا النشر، أو استنساخه باسم جديد لتخصيصه. التحرير يتم في Studio.", + subtitle: "تدفقات وإجراءات تأتي مع الحزم المثبّتة. يمكنك إيقاف أي منها في هذا النشر، أو استنساخ تدفق باسم جديد لتخصيصه. التحرير يتم في Studio.", refresh: "تحديث", + flowsHeading: "تدفقات الحزم", + actionsHeading: "إجراءات الحزم", colFlow: "التدفق", colActivation: "التفعيل", colActions: "إجراءات", @@ -3444,6 +3446,13 @@ const ar = { toggleFailed: "تعذّر تغيير التفعيل.", cloneFailedHttp: "تعذّر استنساخ هذا التدفق (HTTP {{status}}).", cloneFailed: "تعذّر استنساخ هذا التدفق.", + actionsSubtitle: "إجراءات تأتي مع الحزم المثبّتة. أوقف أحدها في هذا النشر فيتوقف عن العمل في كل مكان يُعرض فيه. ويبقى بإمكانك إنشاء إجراء خاص بك إلى جانبه في Studio.", + colAction: "الإجراء", + colObject: "الكائن", + actionToggleLabel: "تفعيل {{label}} على {{object}}", + actionsEmptyTitle: "لا توجد إجراءات من الحزم", + actionsEmptyBody: "لا توجد حزمة مثبّتة تعلن عن إجراء في هذا النشر. الإجراءات التي تنشئها بنفسك موجودة في Studio.", + actionsLoadFailed: "تعذّر تحميل إجراءات الحزم.", }, }; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3f01acac1..96b689a85 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3414,8 +3414,10 @@ const de = { // in dieser Gruppe (§9). packagedAutomation: { title: "Paketautomatisierung", - subtitle: "Flows aus installierten Paketen. Schalten Sie einen für diese Installation ab oder klonen Sie ihn unter einem neuen Namen, um ihn anzupassen. Bearbeitet wird im Studio.", + subtitle: "Flows und Aktionen aus installierten Paketen. Schalten Sie eines für diese Installation ab oder klonen Sie einen Flow unter einem neuen Namen, um ihn anzupassen. Bearbeitet wird im Studio.", refresh: "Aktualisieren", + flowsHeading: "Paket-Flows", + actionsHeading: "Paket-Aktionen", colFlow: "Flow", colActivation: "Aktivierung", colActions: "Aktionen", @@ -3437,6 +3439,13 @@ const de = { toggleFailed: "Aktivierung konnte nicht geändert werden.", cloneFailedHttp: "Dieser Flow konnte nicht geklont werden (HTTP {{status}}).", cloneFailed: "Dieser Flow konnte nicht geklont werden.", + actionsSubtitle: "Aktionen aus installierten Paketen. Schalten Sie eine für diese Installation ab, und sie läuft überall dort nicht mehr, wo sie angeboten wird. Eine eigene Aktion daneben zu erstellen, bleibt Ihnen im Studio offen.", + colAction: "Aktion", + colObject: "Objekt", + actionToggleLabel: "Aktivierung für {{label}} auf {{object}}", + actionsEmptyTitle: "Keine Paket-Aktionen", + actionsEmptyBody: "In dieser Installation deklariert kein installiertes Paket eine Aktion. Selbst erstellte Aktionen finden Sie im Studio.", + actionsLoadFailed: "Paket-Aktionen konnten nicht geladen werden.", }, }; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9f5bfda13..6505a3c40 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3767,9 +3767,14 @@ const en = { // last-resort fallbacks for a response that carried no message at all. packagedAutomation: { title: 'Packaged automation', + // Covers BOTH sections since ADR-0126 §8 item 2 put packaged actions on + // this page: only flows can be cloned, so the clone clause names them. subtitle: - 'Flows shipped by installed packages. Turn one off for this deployment, or clone it under a new name to customize it. Editing happens in Studio.', + 'Flows and actions shipped by installed packages. Turn one off for this deployment, or clone a flow under a new name to customize it. Editing happens in Studio.', refresh: 'Refresh', + // Section headings — the page carries two tables. + flowsHeading: 'Packaged flows', + actionsHeading: 'Packaged actions', colFlow: 'Flow', colActivation: 'Activation', colActions: 'Actions', @@ -3793,10 +3798,28 @@ const en = { // Two keys per action, not one: the response arm has an HTTP status to // name and the transport-exception arm does not, and a single key cannot // carry a hole only half its call sites can fill. + // Artifact-neutral by wording, and shared by BOTH sections' toggles: the + // sentence says nothing about flows, so the actions half reuses it rather + // than defining a second key with the same English in ten packs. toggleFailedHttp: 'Could not change activation (HTTP {{status}}).', toggleFailed: 'Could not change activation.', cloneFailedHttp: 'Could not clone this flow (HTTP {{status}}).', cloneFailed: 'Could not clone this flow.', + + // ── Packaged ACTIONS section (ADR-0126 §8 item 2, ruling 3) ──────────── + // ⛔ No clone key here on purpose: the action-clone half is unchartered, + // and a string is the cheapest way for an unchartered surface to appear. + actionsSubtitle: + 'Actions shipped by installed packages. Turn one off for this deployment and it stops running everywhere it is offered. Authoring your own action alongside it stays open in Studio.', + colAction: 'Action', + colObject: 'Object', + // The object is part of the accessible name because it is part of the + // identity — two objects may declare the same action name. + actionToggleLabel: 'Activation for {{label}} on {{object}}', + actionsEmptyTitle: 'No packaged actions', + actionsEmptyBody: + 'No installed package declares an action on this deployment. Actions you author yourself live in Studio.', + actionsLoadFailed: 'Could not load packaged actions.', }, } as const; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 400e1327e..83a15ce37 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3418,8 +3418,10 @@ const es = { // linaje o desviación en este grupo (§9). packagedAutomation: { title: "Automatización de paquetes", - subtitle: "Flujos incluidos en los paquetes instalados. Desactiva uno en esta implementación o clónalo con un nombre nuevo para personalizarlo. La edición se hace en Studio.", + subtitle: "Flujos y acciones incluidos en los paquetes instalados. Desactiva uno en esta implementación o clona un flujo con un nombre nuevo para personalizarlo. La edición se hace en Studio.", refresh: "Actualizar", + flowsHeading: "Flujos de paquete", + actionsHeading: "Acciones de paquete", colFlow: "Flujo", colActivation: "Activación", colActions: "Acciones", @@ -3441,6 +3443,13 @@ const es = { toggleFailed: "No se pudo cambiar la activación.", cloneFailedHttp: "No se pudo clonar este flujo (HTTP {{status}}).", cloneFailed: "No se pudo clonar este flujo.", + actionsSubtitle: "Acciones incluidas en los paquetes instalados. Desactiva una en esta implementación y dejará de ejecutarse en todos los lugares donde se ofrece. Crear tu propia acción junto a ella sigue estando disponible en Studio.", + colAction: "Acción", + colObject: "Objeto", + actionToggleLabel: "Activación de {{label}} en {{object}}", + actionsEmptyTitle: "Sin acciones de paquete", + actionsEmptyBody: "Ningún paquete instalado declara una acción en esta implementación. Las acciones que creas tú están en Studio.", + actionsLoadFailed: "No se pudieron cargar las acciones de paquete.", }, }; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index bbeef56b8..cc84cfb10 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3416,8 +3416,10 @@ const fr = { // d'ascendance ou de dérive dans ce groupe (§9). packagedAutomation: { title: "Automatisation des packages", - subtitle: "Flux fournis par les packages installés. Désactivez-en un pour ce déploiement, ou clonez-le sous un nouveau nom pour le personnaliser. La modification se fait dans Studio.", + subtitle: "Flux et actions fournis par les packages installés. Désactivez-en un pour ce déploiement, ou clonez un flux sous un nouveau nom pour le personnaliser. La modification se fait dans Studio.", refresh: "Actualiser", + flowsHeading: "Flux des packages", + actionsHeading: "Actions des packages", colFlow: "Flux", colActivation: "Activation", colActions: "Actions", @@ -3439,6 +3441,13 @@ const fr = { toggleFailed: "Impossible de modifier l'activation.", cloneFailedHttp: "Impossible de cloner ce flux (HTTP {{status}}).", cloneFailed: "Impossible de cloner ce flux.", + actionsSubtitle: "Actions fournies par les packages installés. Désactivez-en une pour ce déploiement et elle cesse de s'exécuter partout où elle est proposée. Vous pouvez toujours créer votre propre action à côté, dans Studio.", + colAction: "Action", + colObject: "Objet", + actionToggleLabel: "Activation de {{label}} sur {{object}}", + actionsEmptyTitle: "Aucune action de package", + actionsEmptyBody: "Aucun package installé ne déclare d'action sur ce déploiement. Les actions que vous créez vous-même se trouvent dans Studio.", + actionsLoadFailed: "Impossible de charger les actions des packages.", }, }; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ce2ee4b10..f172a2f40 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3415,8 +3415,10 @@ const ja = { // フロー、引用符は「」。⛔ 系譜・ドリフトを示す語はこのグループに置きません (§9)。 packagedAutomation: { title: "パッケージ自動化", - subtitle: "インストール済みパッケージが提供するフローです。このデプロイでオフにするか、新しい名前でクローンしてカスタマイズできます。編集は Studio で行います。", + subtitle: "インストール済みパッケージが提供するフローとアクションです。このデプロイでオフにできます。フローは新しい名前でクローンしてカスタマイズすることもできます。編集は Studio で行います。", refresh: "更新", + flowsHeading: "パッケージフロー", + actionsHeading: "パッケージアクション", colFlow: "フロー", colActivation: "有効状態", colActions: "操作", @@ -3438,6 +3440,13 @@ const ja = { toggleFailed: "有効状態を変更できませんでした。", cloneFailedHttp: "このフローをクローンできませんでした (HTTP {{status}})。", cloneFailed: "このフローをクローンできませんでした。", + actionsSubtitle: "インストール済みパッケージが提供するアクションです。このデプロイでオフにすると、提供されているすべての場所で実行されなくなります。自分のアクションを別途 Studio で作成することは引き続き可能です。", + colAction: "アクション", + colObject: "オブジェクト", + actionToggleLabel: "{{object}} の {{label}} の有効状態", + actionsEmptyTitle: "パッケージアクションはありません", + actionsEmptyBody: "このデプロイには、アクションを宣言するインストール済みパッケージがありません。自分で作成したアクションは Studio にあります。", + actionsLoadFailed: "パッケージアクションを読み込めませんでした。", }, }; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c4cf04d2e..18b821f44 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3412,8 +3412,10 @@ const ko = { // 인용부호는 「」. ⛔ 계보·드리프트를 나타내는 표현은 이 그룹에 두지 않습니다 (§9). packagedAutomation: { title: "패키지 자동화", - subtitle: "설치된 패키지가 제공하는 플로우입니다. 이 배포에서 끄거나, 새 이름으로 복제해 사용자 지정할 수 있습니다. 편집은 Studio에서 합니다.", + subtitle: "설치된 패키지가 제공하는 플로우와 액션입니다. 이 배포에서 끌 수 있으며, 플로우는 새 이름으로 복제해 사용자 지정할 수도 있습니다. 편집은 Studio에서 합니다.", refresh: "새로고침", + flowsHeading: "패키지 플로우", + actionsHeading: "패키지 액션", colFlow: "플로우", colActivation: "활성화 상태", colActions: "작업", @@ -3435,6 +3437,13 @@ const ko = { toggleFailed: "활성화 상태를 변경하지 못했습니다.", cloneFailedHttp: "이 플로우를 복제하지 못했습니다 (HTTP {{status}}).", cloneFailed: "이 플로우를 복제하지 못했습니다.", + actionsSubtitle: "설치된 패키지가 제공하는 액션입니다. 이 배포에서 끄면 액션이 제공되는 모든 곳에서 실행되지 않습니다. Studio에서 직접 액션을 따로 작성하는 것은 계속 가능합니다.", + colAction: "액션", + colObject: "오브젝트", + actionToggleLabel: "{{object}}의 {{label}} 활성화 상태", + actionsEmptyTitle: "패키지 액션 없음", + actionsEmptyBody: "이 배포에는 액션을 선언하는 설치된 패키지가 없습니다. 직접 작성한 액션은 Studio에 있습니다.", + actionsLoadFailed: "패키지 액션을 불러오지 못했습니다.", }, }; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 92feb548e..acf658799 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3413,8 +3413,10 @@ const pt = { // grupo (§9). packagedAutomation: { title: "Automação de pacotes", - subtitle: "Fluxos fornecidos pelos pacotes instalados. Desligue um nesta implantação ou clone-o com um novo nome para personalizá-lo. A edição acontece no Studio.", + subtitle: "Fluxos e ações fornecidos pelos pacotes instalados. Desligue um nesta implantação ou clone um fluxo com um novo nome para personalizá-lo. A edição acontece no Studio.", refresh: "Atualizar", + flowsHeading: "Fluxos de pacote", + actionsHeading: "Ações de pacote", colFlow: "Fluxo", colActivation: "Ativação", colActions: "Ações", @@ -3436,6 +3438,13 @@ const pt = { toggleFailed: "Não foi possível alterar a ativação.", cloneFailedHttp: "Não foi possível clonar este fluxo (HTTP {{status}}).", cloneFailed: "Não foi possível clonar este fluxo.", + actionsSubtitle: "Ações fornecidas pelos pacotes instalados. Desligue uma nesta implantação e ela deixa de ser executada em todos os lugares onde é oferecida. Criar sua própria ação ao lado dela continua disponível no Studio.", + colAction: "Ação", + colObject: "Objeto", + actionToggleLabel: "Ativação de {{label}} em {{object}}", + actionsEmptyTitle: "Nenhuma ação de pacote", + actionsEmptyBody: "Nenhum pacote instalado declara uma ação nesta implantação. As ações que você mesmo cria ficam no Studio.", + actionsLoadFailed: "Não foi possível carregar as ações de pacote.", }, }; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index d95db0709..d49a7c197 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3427,8 +3427,10 @@ const ru = { // расхождении в этой группе (§9). packagedAutomation: { title: "Автоматизация из пакетов", - subtitle: "Потоки, поставляемые установленными пакетами. Отключите поток в этой установке или клонируйте его под новым именем, чтобы настроить. Редактирование — в Studio.", + subtitle: "Потоки и действия, поставляемые установленными пакетами. Отключите любой из них в этой установке или клонируйте поток под новым именем, чтобы настроить. Редактирование — в Studio.", refresh: "Обновить", + flowsHeading: "Потоки из пакетов", + actionsHeading: "Действия из пакетов", colFlow: "Поток", colActivation: "Активация", colActions: "Действия", @@ -3450,6 +3452,13 @@ const ru = { toggleFailed: "Не удалось изменить активацию.", cloneFailedHttp: "Не удалось клонировать этот поток (HTTP {{status}}).", cloneFailed: "Не удалось клонировать этот поток.", + actionsSubtitle: "Действия, поставляемые установленными пакетами. Отключите действие в этой установке — и оно перестанет выполняться везде, где предлагается. Создать рядом собственное действие по-прежнему можно в Studio.", + colAction: "Действие", + colObject: "Объект", + actionToggleLabel: "Активация для {{label}} на {{object}}", + actionsEmptyTitle: "Нет действий из пакетов", + actionsEmptyBody: "В этой установке ни один установленный пакет не объявляет действие. Действия, которые вы создаёте сами, находятся в Studio.", + actionsLoadFailed: "Не удалось загрузить действия из пакетов.", }, }; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 25ecb7816..f92796f37 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3472,8 +3472,10 @@ const zh = { // 不是一次副本粘贴。⛔ 本组不含任何血缘/漂移措辞(§9)。 packagedAutomation: { title: '打包自动化', - subtitle: '由已安装软件包提供的流程。可在本部署中关闭某个流程,或以新名称克隆后再自定义。编辑在 Studio 中进行。', + subtitle: '由已安装软件包提供的流程与动作。可在本部署中关闭其中之一,流程还可以新名称克隆后再自定义。编辑在 Studio 中进行。', refresh: '刷新', + flowsHeading: '打包流程', + actionsHeading: '打包动作', colFlow: '流程', colActivation: '启用状态', colActions: '操作', @@ -3495,6 +3497,13 @@ const zh = { toggleFailed: '无法更改启用状态。', cloneFailedHttp: '无法克隆此流程(HTTP {{status}})。', cloneFailed: '无法克隆此流程。', + actionsSubtitle: '由已安装软件包提供的动作。在本部署中关闭后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', + colAction: '动作', + colObject: '对象', + actionToggleLabel: '{{object}} 上 {{label}} 的启用状态', + actionsEmptyTitle: '没有打包动作', + actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', + actionsLoadFailed: '无法加载打包动作。', }, } as const;