Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/11820-studio-safe-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

Studio's new validation rules, hooks and actions start from safe defaults (objectui#11820).

- **Validation rules.** A new Script, Cross-field or Conditional rule is no longer saved as a rule that can never fire. It used to be written to the object draft at once with the condition `false` and no "Runs on" box checked. Now it is listed and open but not saved, marked "Not saved — needs a condition", with the same "Not saved yet" line Studio shows for other unfinished edits, until you give it a condition (for a Conditional rule, its Then rule's condition too). Leaving the Rules view before then discards it. Every new rule starts on Create + Update. An existing rule is edited as before.
- **Hooks.** A new hook still targets the object you create it from. Its object picker now lists this package's objects first, and puts every other object (other packages' objects and the platform's own `sys_*` tables) under "Other objects" with a line saying a hook there runs on that object's events wherever they happen. "All objects (*)" shows the same kind of warning. Choosing either deliberately still works. The metadata admin's hook editor, which knows no package, keeps its flat list.
- **Actions.** A new action starts as "Update fields on this record — no code" instead of a sandboxed-JS script, and you add the field values it writes.

Nothing is added to the package entry: no export, prop, type member or language-pack key.
16 changes: 12 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.hook.allObjects': 'All objects (*)',
'engine.inspector.hook.noObjects': 'No objects found — publish an object, then pick it here.',
'engine.inspector.hook.pickObject': 'Pick at least one object (or All objects).',
'engine.inspector.hook.outsideReach': 'These belong to other packages or to the platform (sys_…). A hook on one runs on that object’s events wherever they happen, not only in this package.',
'engine.inspector.hook.allObjectsReach': 'Runs on the events of every object — other packages’ objects and the platform’s own (sys_…) tables included.',
'engine.inspector.hook.events': 'Events',
'engine.inspector.hook.eventsHint': 'Which lifecycle events invoke this hook.',
'engine.inspector.hook.eventsWrite': 'Write',
Expand Down Expand Up @@ -2825,6 +2827,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.held.needsOptions': 'the field “{field}” needs at least one option',
'engine.studio.held.needsTarget': 'the field “{field}” needs the object it links to',
'engine.studio.held.needsInput': '{input} on the step “{step}” needs a value',
'engine.studio.held.needsCondition': 'the rule “{rule}” needs a condition',
'engine.studio.held.needsThenCondition': 'the “Then” rule of “{rule}” needs a condition',
'engine.studio.held.line': 'Not saved yet: {clause}. Your changes are kept here and saved once it is filled in.',
'engine.studio.held.publish': 'Not published: {clause}, so that change is not saved yet. Finish it, then publish.',
'engine.studio.held.inputHint': 'Required. Changes are saved once this has a value.',
Expand Down Expand Up @@ -2967,6 +2971,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.rules.none': 'No validation rules yet.',
'engine.studio.rules.explain': 'Rules run when a record is saved: when the condition is true, the save is rejected with the message.',
'engine.studio.rules.noMessage': '(no message)',
'engine.studio.rules.notSaved': 'Not saved — needs a condition',
'engine.studio.rules.pick': 'Select a rule on the left, or click “New” to create one.',
'engine.studio.rules.structured': '“{type}” rules carry structured configuration (state-machine transitions / format constraints etc.) and cannot be edited here yet — maintain them in the code package. Message: {message}',
'engine.studio.rules.none2': '(none)',
Expand All @@ -2976,8 +2981,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.rules.messagePlaceholder': 'e.g. Completion date is required when status is Done',
'engine.studio.rules.celPre': 'Fail condition (CEL) — when the condition is ',
'engine.studio.rules.celTrue': 'true',
'engine.studio.rules.celMid': ', the save is rejected with the message above; new rules default to ',
'engine.studio.rules.celPost': ' (never fires) — replace it with a real condition.',
'engine.studio.rules.celMid': ', the save is rejected with the message above.',
'engine.studio.rules.severity': 'Severity',
'engine.studio.rules.severityError': 'error (rejects the save)',
'engine.studio.rules.enabled': 'Enabled',
Expand Down Expand Up @@ -4084,6 +4088,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.hook.allObjects': '所有对象(*)',
'engine.inspector.hook.noObjects': '未找到对象 —— 请先发布一个对象,再在此处选择。',
'engine.inspector.hook.pickObject': '请至少选择一个对象(或「所有对象」)。',
'engine.inspector.hook.outsideReach': '这些对象属于其他软件包或平台(sys_…)。钩子挂在其中某个对象上时,该对象的事件无论在哪里发生都会触发它,而不只是在本软件包内。',
'engine.inspector.hook.allObjectsReach': '会在每个对象的事件上运行 —— 包括其他软件包的对象和平台自身的(sys_…)表。',
'engine.inspector.hook.events': '事件',
'engine.inspector.hook.eventsHint': '哪些生命周期事件会调用此钩子。',
'engine.inspector.hook.eventsWrite': '写入',
Expand Down Expand Up @@ -5866,6 +5872,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.held.needsOptions': '字段「{field}」至少需要一个选项',
'engine.studio.held.needsTarget': '字段「{field}」需要指定要关联的对象',
'engine.studio.held.needsInput': '步骤「{step}」的「{input}」需要填写',
'engine.studio.held.needsCondition': '规则「{rule}」需要一个条件',
'engine.studio.held.needsThenCondition': '规则「{rule}」的「Then」规则需要一个条件',
'engine.studio.held.line': '尚未保存:{clause}。更改已保留在此处,补全后会自动保存。',
'engine.studio.held.publish': '未发布:{clause},该更改尚未保存。请先补全,再发布。',
'engine.studio.held.inputHint': '必填。填写后才会保存更改。',
Expand Down Expand Up @@ -6005,6 +6013,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.rules.none': '还没有验证规则。',
'engine.studio.rules.explain': '规则在保存记录时执行:条件为真 ⇒ 拒绝保存并提示消息。',
'engine.studio.rules.noMessage': '(无消息)',
'engine.studio.rules.notSaved': '未保存 —— 需要条件',
'engine.studio.rules.pick': '选择左侧的规则进行编辑,或点「新增」创建一条。',
'engine.studio.rules.structured': '「{type}」类型的规则带有结构化配置(状态机转移表 / 格式约束等),暂不支持在此编辑 —— 请在代码包中维护。消息:{message}',
'engine.studio.rules.none2': '(无)',
Expand All @@ -6014,8 +6023,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.rules.messagePlaceholder': '例如:完成日期在状态为已完成时必填',
'engine.studio.rules.celPre': '失败条件(CEL)—— 条件为 ',
'engine.studio.rules.celTrue': '真',
'engine.studio.rules.celMid': ' 时,拒绝保存并显示上面的消息;新规则默认 ',
'engine.studio.rules.celPost': ' (永不触发),请改为真实条件。',
'engine.studio.rules.celMid': ' 时,拒绝保存并显示上面的消息。',
'engine.studio.rules.severity': '严重度',
'engine.studio.rules.severityError': 'error(拒绝保存)',
'engine.studio.rules.enabled': '启用',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11820 — the hook target picker says where a hook's reach widens.
*
* A hook on an object runs on that object's events wherever they happen. The
* picker listed every object — this package's, other packages', the
* platform's own `sys_*` tables — in one flat list, and "All objects (*)" with
* it, with no word about reach. Now, when the host says which objects belong
* to the hook's package (`HookTargetScopeContext`, which Studio's hooks panel
* provides), this package's objects come first under "This package" and every
* other object sits under "Other objects" with a one-line reach warning; "All
* objects" carries the same kind of warning.
*
* Membership is the package's own list, never a name prefix: here `sys_user`
* is outside because the package does not list it, and an object whose name
* merely looks like the package's is outside too.
*
* Controls: a host that knows no package (the metadata admin's editor) gets the
* flat list with no warning; ticking an outside object, and choosing "All
* objects" deliberately, still write what they wrote before.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';

const state = vi.hoisted(() => ({
metadataClient: {
get: vi.fn(async () => undefined),
list: vi.fn(
async () =>
[
{ name: 'crm_account', label: 'Account' },
{ name: 'crm_contact', label: 'Contact' },
{ name: 'crm_lookalike', label: 'Lookalike' },
{ name: 'hr_employee', label: 'Employee' },
{ name: 'sys_user', label: 'User', isSystem: true },
] as unknown[],
),
},
}));
vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { HookDefaultInspector, HookTargetScopeContext, type HookTargetScope } from './HookDefaultInspector';

afterEach(cleanup);

const SCOPE: HookTargetScope = { packageObjects: new Set(['crm_account', 'crm_contact']) };
const REACH = /These belong to other packages or to the platform/;

async function mount(opts: { object: unknown; scope: HookTargetScope | null }) {
const onPatch = vi.fn();
render(
<HookTargetScopeContext.Provider value={opts.scope}>
<HookDefaultInspector
type="hook"
name="audit_hook"
draft={{ name: 'audit_hook', object: opts.object, events: ['beforeInsert'] }}
onPatch={onPatch}
readOnly={false}
locale="en-US"
/>
</HookTargetScopeContext.Provider>,
);
// The catalog answered.
if (opts.object !== '*') await screen.findByRole('checkbox', { name: 'Account (crm_account)' });
return onPatch;
}

describe('hook target picker — reach (objectui#11820)', () => {
it('lists this package’s objects first, and every other object under its own heading with the warning', async () => {
await mount({ object: 'crm_account', scope: SCOPE });

const own = screen.getByRole('group', { name: 'This package' });
expect(within(own).getByRole('checkbox', { name: 'Account (crm_account)' })).toBeChecked();
expect(within(own).getByRole('checkbox', { name: 'Contact (crm_contact)' })).toBeInTheDocument();
expect(within(own).queryByRole('checkbox', { name: 'User (sys_user)' })).toBeNull();

const outside = screen.getByRole('group', { name: 'Other objects' });
expect(within(outside).getByTestId('hook-object-outside-reach')).toHaveTextContent(REACH);
for (const label of ['User (sys_user)', 'Employee (hr_employee)', 'Lookalike (crm_lookalike)']) {
expect(within(outside).getByRole('checkbox', { name: label })).toBeInTheDocument();
}
expect(within(outside).queryByRole('checkbox', { name: 'Account (crm_account)' })).toBeNull();
// The warning is said once, at the group — not on the package's objects.
expect(screen.getAllByTestId('hook-object-outside-reach')).toHaveLength(1);
});

it('control — ticking a platform object still writes it beside the package object', async () => {
const onPatch = await mount({ object: 'crm_account', scope: SCOPE });
const outside = screen.getByRole('group', { name: 'Other objects' });
fireEvent.click(within(outside).getByRole('checkbox', { name: 'User (sys_user)' }));
expect(onPatch).toHaveBeenLastCalledWith({ object: ['crm_account', 'sys_user'] });
});

it('"All objects" carries a reach warning, and choosing it deliberately still writes `*`', async () => {
const onPatch = await mount({ object: 'crm_account', scope: SCOPE });
expect(screen.queryByTestId('hook-all-objects-reach')).toBeNull();
fireEvent.click(screen.getByRole('checkbox', { name: 'All objects (*)' }));
expect(onPatch).toHaveBeenLastCalledWith({ object: '*' });
cleanup();

await mount({ object: '*', scope: SCOPE });
expect(screen.getByTestId('hook-all-objects-reach')).toHaveTextContent(
'Runs on the events of every object — other packages’ objects and the platform’s own (sys_…) tables included.',
);
});

it('control — with no package known, the list is one flat list with no headings and no warning', async () => {
await mount({ object: 'crm_account', scope: null });
expect(screen.queryByRole('group', { name: 'This package' })).toBeNull();
expect(screen.queryByRole('group', { name: 'Other objects' })).toBeNull();
expect(screen.queryByTestId('hook-object-outside-reach')).toBeNull();
expect(screen.getByRole('checkbox', { name: 'User (sys_user)' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Account (crm_account)' })).toBeChecked();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
* array. Any already-selected object not in the live catalog is preserved as a
* synthesized option so a value is never silently dropped; it is flagged as not
* published only once the catalog has answered (objectui#10585).
*
* Reach (objectui#11820). A hook on an object runs on that object's events
* wherever they happen, so the picker says so where the reach widens: when a
* host tells it which objects belong to the package the hook is authored in
* ({@link HookTargetScopeContext} — Studio's hooks panel does), the list splits
* into this package's objects and the rest, and the rest (other packages'
* objects and the platform's own) sit under their own heading with a one-line
* warning. "All objects" carries the same warning. A host that knows no package
* (the metadata admin's editor) gets the flat list, as before.
*/

import * as React from 'react';
Expand Down Expand Up @@ -68,6 +77,28 @@ const QUERY_EVENTS = [

const ALL_OBJECTS = '*';

/**
* objectui#11820 — what the host knows about where the hook is authored: the
* names of the objects of that package, published and draft. Membership is
* the package's own list (the server's package filter on `_packageId`, plus
* the package's pending drafts), never a name prefix; an object outside it
* belongs to another package, to the platform, or to no package, and each of
* those widens the hook's reach beyond the package.
*/
export interface HookTargetScope {
packageObjects: ReadonlySet<string>;
}

/**
* Provided by a host that knows the hook's package (Studio's hooks panel).
* `null` — no package known — keeps the picker one flat list.
*
* A context rather than a prop because the host renders this inspector through
* the default-inspector registry, whose props are every type's. Module-private:
* imported by that host, exported from no barrel.
*/
export const HookTargetScopeContext = React.createContext<HookTargetScope | null>(null);

/** Keys this inspector edits with its own controls — hidden from the fallback. */
const CURATED_FIELDS = [
'name', 'label', 'object', 'events', 'body', 'priority', 'async', 'condition',
Expand Down Expand Up @@ -156,6 +187,26 @@ export function HookDefaultInspector({
}, [objectOptions, objectNames, locale, rosterAnswered]);
const rosterFailure = roster.status === 'error' ? roster.message : undefined;

// objectui#11820 — with a known package, this package's objects first and
// every other object under its own heading and reach warning. A cheap
// partition, recomputed each render: nothing keys on its identity.
const scope = React.useContext(HookTargetScopeContext);
const groups = scope
? {
own: pickerOptions.filter((o) => scope.packageObjects.has(o.value)),
outside: pickerOptions.filter((o) => !scope.packageObjects.has(o.value)),
}
: null;
const renderObjectOption = (o: { value: string; label: string }) => (
<InspectorCheckboxField
key={o.value}
label={o.label}
value={objectNames.includes(o.value)}
onCommit={(on) => toggleObject(o.value, on)}
disabled={readOnly}
/>
);

const patchBody = (p: Record<string, unknown>) => onPatch({ body: { ...body, ...p } });

const toggleObject = (name: string, on: boolean) => {
Expand Down Expand Up @@ -224,6 +275,11 @@ export function HookDefaultInspector({
onCommit={(on) => onPatch({ object: on ? ALL_OBJECTS : writeObjects(false, objectNames) })}
disabled={readOnly}
/>
{allObjects && (
<p data-testid="hook-all-objects-reach" className="text-[11px] leading-snug text-amber-600 dark:text-amber-400">
{tr('engine.inspector.hook.allObjectsReach')}
</p>
)}
{/* The empty-list copy is a MEASUREMENT ("no objects found"), so only
an answered roster may print it; in flight it says it is loading.
A failed roster with nothing selected draws no list at all — the
Expand All @@ -236,16 +292,43 @@ export function HookDefaultInspector({
? tr('engine.inspector.hook.noObjects')
: tr('engine.form.loadingOptions')}
</p>
) : groups ? (
<>
{groups.own.length > 0 && (
<div
role="group"
aria-label={tr('engine.inspector.objectPicker.groupPackage')}
data-testid="hook-object-group-package"
className="space-y-1"
>
<div aria-hidden className="text-[11px] font-medium text-muted-foreground">
{tr('engine.inspector.objectPicker.groupPackage')}
</div>
{groups.own.map(renderObjectOption)}
</div>
)}
{groups.outside.length > 0 && (
<div
role="group"
aria-label={tr('engine.inspector.objectPicker.groupOther')}
data-testid="hook-object-group-outside"
className="space-y-1 pt-1"
>
<div aria-hidden className="text-[11px] font-medium text-muted-foreground">
{tr('engine.inspector.objectPicker.groupOther')}
</div>
<p
data-testid="hook-object-outside-reach"
className="text-[11px] leading-snug text-amber-600 dark:text-amber-400"
>
{tr('engine.inspector.hook.outsideReach')}
</p>
{groups.outside.map(renderObjectOption)}
</div>
)}
</>
) : (
pickerOptions.map((o) => (
<InspectorCheckboxField
key={o.value}
label={o.label}
value={objectNames.includes(o.value)}
onCommit={(on) => toggleObject(o.value, on)}
disabled={readOnly}
/>
))
pickerOptions.map(renderObjectOption)
)}
</div>
)}
Expand Down
Loading
Loading