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
19 changes: 19 additions & 0 deletions .changeset/27521-studio-authoring-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
'@object-ui/console': patch
---

fix(studio): plainer authoring in Studio — option labels first, named validation rules, labelled condition fields, object labels on flow cards, honest field counts, a Create that says why it is off, and a publish toast that points to Access

Clause-②: no
Metadata-protocol: none

- **Picklist options** read Label first, and a new option's stored value is filled in from its label until the author types one, so three labelled options are three saved options. An authored option's value never moves when it is relabelled. The lifecycle kind (Open / Won / Lost) is offered only on the field the record path follows, or where an option already declares one.
- **Validation rules**: a new rule's API name follows its message (`Hours over 40` → `hours_over_40`) until the name is edited; an authored rule keeps its name. The condition builder lists fields by label, with the CEL spelling beside it, under "Pick a field". The rule type, severity and name labels are in plain words.
- **Flow canvas**: a node card names the object it works on by the object's label.
- **Object header**: counts the fields the canvas shows, and the platform's own columns apart (`2 fields (+8 system)`); "1 field" is singular.
- **Add field**: a Create that cannot run yet stays focusable and says what is missing.
- **Read-only inspectors**: disabled text areas take the muted disabled look.
- **Publish**: the "one atomic release" wording is plain ("together: all of them go live, or none do"), and a publish that put an app or object live says members need a permission set, with an "Open Access" action.
- **Studio access refusal** names who grants access (an administrator, in Setup → Permission Sets), in every language pack and in the Console's own fallback sentence. The publish confirm note's all-or-nothing wording is translated in every pack too.
2 changes: 1 addition & 1 deletion ui/apps/console/src/components/StudioRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const pathname = () => screen.getByTestId('pathname').textContent;
* answers. That the packs answer in the caller's language is the sibling
* file's pin.
*/
const NOTICE = 'You need Studio access to open Studio.';
const NOTICE = 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.';
const WAY_HOME = 'Back to home';

/**
Expand Down
2 changes: 1 addition & 1 deletion ui/apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function StudioAccessRequired({ home }: { home: string }) {
</div>
<EmptyTitle>
{t('console.studio.accessRequired', {
defaultValue: 'You need Studio access to open Studio.',
defaultValue: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.',
})}
</EmptyTitle>
<div className="mt-4">
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1669,7 +1669,7 @@ export function DraftChangesPanel({
? t('preview.changes.confirmNote', {
count: entries!.length,
defaultValue:
'Publishing releases all {{count}} pending drafts of this package atomically.',
'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.',
})
: t('preview.changes.confirmNoteSeparate', {
count: entries!.length,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
renderSheet('com.example.showcase');

await waitFor(async () =>
expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package atomically.'),
expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package: it goes live, or nothing changes.'),
);
});

Expand All @@ -111,6 +111,6 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
]);
renderSheet('com.example.showcase');

expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package atomically.');
expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package together: all of them go live, or none do.');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ConditionBuilder — a field subject reads by its label (objectstack#27521,
* Studio row 26).
*
* The Validations pillar's "Reject the save when…" listed `record.estimated_hours`
* under the placeholder "field / context", while the list-view filter in the
* same product showed labels. A field subject now reads by its label, with the
* spelling the CEL uses beside it; the compiled CEL is unchanged. A field with
* no label keeps its spelling, and the context subjects are as before. The
* label is the mount's choice (`labelSubjects`): a mount that lists spellings
* keeps them.
*/

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

const state = vi.hoisted(() => ({
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
}));
vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { ConditionBuilder } from './ConditionBuilder';
import { t } from '../i18n';

afterEach(cleanup);

const FIELDS = [
{ name: 'estimated_hours', label: 'Estimated Hours', type: 'number' },
{ name: 'code', type: 'text' },
];

function mount(includePrevious = false, labels = true) {
const onChange = vi.fn();
render(
<ConditionBuilder
value=""
onCommit={onChange}
fields={FIELDS}
subjects={includePrevious ? { includePrevious: true } : undefined}
labelSubjects={labels}
/>,
);
fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.condition.add', 'en-US') }));
return { onChange };
}

async function openSubjects() {
const trigger = screen.getAllByRole('combobox')[0];
expect(trigger).toHaveTextContent(t('engine.inspector.condition.subjectPlaceholder', 'en-US'));
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
return within(await screen.findByRole('listbox')).getAllByRole('option');
}

describe('ConditionBuilder · field subjects read by label (objectstack#27521)', () => {
it('a labelled field reads by its label with its spelling beside it; an unlabelled one by its spelling', async () => {
mount();
const options = await openSubjects();
const texts = options.map((o) => o.textContent);
expect(texts).toContain('Estimated Hoursrecord.estimated_hours');
expect(texts).toContain('record.code');
// The context subjects are unchanged.
expect(texts).toContain('user.id');
});

it('picking the labelled field still compiles to its CEL spelling', async () => {
const { onChange } = mount();
const options = await openSubjects();
fireEvent.click(options.find((o) => o.textContent?.startsWith('Estimated Hours'))!);
fireEvent.change(screen.getByPlaceholderText(t('engine.inspector.condition.valuePlaceholder', 'en-US')), {
target: { value: '40' },
});
expect(JSON.stringify(onChange.mock.calls.at(-1)?.[0])).toContain('record.estimated_hours');
});

it('a previous-value subject names the field and that it is the value before the change', async () => {
mount(true);
const texts = (await openSubjects()).map((o) => o.textContent);
expect(texts).toContain('Estimated Hours (before the change)previous.estimated_hours');
});

it('CONTROL — a mount that does not ask for labels lists the spellings', async () => {
mount(false, false);
const texts = (await openSubjects()).map((o) => o.textContent);
expect(texts).toContain('record.estimated_hours');
expect(texts.some((x) => x?.includes('Estimated Hours'))).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { BOOLEAN_VALUE_TYPES, expandEmptyOperator, type EmptyOperatorArm } from
import { useObjectFields } from '../previews/useObjectFields.js';
import { CelPredicateField } from '../CelPredicateField.js';
import type { CelLintIssue } from '../celAuthoring.js';
import { t, useMetadataLocale } from '../i18n.js';
import { t, tFormat, useMetadataLocale } from '../i18n.js';

type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy';

Expand Down Expand Up @@ -820,7 +820,7 @@ function initFrom(value: string, valueOf: SubjectValueOf): { rows: Row[]; join:
return { rows: [], join: '&&', raw: !!value };
}

export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue }: {
export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue, labelSubjects = false }: {
label?: string;
value: string;
onCommit: (cel: string) => void;
Expand Down Expand Up @@ -906,6 +906,11 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
* builder renders no verdict of its own, so it does not read this.
*/
scopeIssue?: boolean;
/**
* The subject dropdown names a field by its label, with the CEL spelling
* beside it. Off by default, so a mount that lists spellings keeps them.
*/
labelSubjects?: boolean;
}) {
const { fields: hookFields } = useObjectFields(objectName);
const fields = fieldsProp ?? hookFields;
Expand All @@ -921,27 +926,32 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
* advertisement and the placeholder teaches its whole example, both unchanged.
*/
const offeredRoots = roots ?? (scope === 'record' ? RECORD_CONDITION_ROOTS : undefined);
const locale = useMetadataLocale();
const subjectOptions = React.useMemo(() => {
const visible = fields.filter((f) => !f.hidden);
const fieldOpts = visible.map((f) => ({
value: `${fieldPrefix}${f.name}`,
label: `${fieldPrefix}${f.name}`,
}));
// With `labelSubjects`, a field reads by its label, its spelling beside it.
const named = (spelling: string, label: string | undefined) =>
labelSubjects && label ? { value: spelling, label, hint: spelling } : { value: spelling, label: spelling, hint: undefined };
const fieldOpts = visible.map((f) => named(`${fieldPrefix}${f.name}`, f.label));
// The whole-record `previous` comes first: `previous == null` is how an
// author branches on the create leg of a create-or-update trigger, and it
// reads as the head of the group rather than as one more field.
const previousOpts = includePrevious
? [
{ value: 'previous', label: 'previous' },
...visible.map((f) => ({ value: `previous.${f.name}`, label: `previous.${f.name}` })),
{ value: 'previous', label: 'previous', hint: undefined },
...visible.map((f) =>
named(
`previous.${f.name}`,
f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : undefined,
),
),
]
: [];
const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value }));
const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: undefined }));
return [...fieldOpts, ...previousOpts, ...contextOpts];
}, [fields, fieldPrefix, includePrevious, contextSubjects]);
}, [fields, fieldPrefix, includePrevious, contextSubjects, locale, labelSubjects]);
// The raw-expression editor's CEL assists (#1582): field-existence lint +
// autocomplete need the bare field-name catalog and a locale-bound `t`.
const locale = useMetadataLocale();
const tLocal = React.useCallback((k: string) => t(k, locale), [locale]);
const fieldNames = React.useMemo(
() => fields.filter((f) => !f.hidden).map((f) => f.name),
Expand Down Expand Up @@ -1145,7 +1155,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
<Select value={r.subject} onValueChange={(v) => update(rows.map((x, j) => j === i ? { ...x, subject: v } : x))} disabled={disabled}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={tLocal('engine.inspector.condition.subjectPlaceholder')} /></SelectTrigger>
<SelectContent>
{subjectOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
{subjectOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{o.hint && <span className="ml-1.5 font-mono text-[10px] text-muted-foreground">{o.hint}</span>}
</SelectItem>
))}
{r.subject && !subjectOptions.some((o) => o.value === r.subject) && (
<SelectItem value={r.subject}>{r.subject}</SelectItem>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
disabled={readOnly}
rows={6}
placeholder="{ }"
className="w-full rounded border bg-background px-2 py-1.5 font-mono text-xs"
className="w-full rounded border bg-background px-2 py-1.5 font-mono text-xs disabled:cursor-not-allowed disabled:opacity-50"
/>
{advError && <div className="text-xs text-destructive">{advError}</div>}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function FiltersEditor({
onChange={(e) => update(i, field, e.target.value)}
placeholder={t('engine.inspector.appNav.filtersValue', locale)}
disabled={disabled}
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs"
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`nav-filter-value-${field || 'new'}`}
/>
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27521 (Studio row 34) — on a read-only package the inspectors'
* text areas look disabled, the way their inputs do.
*
* Measured in a Studio browser pass on the read-only showcase package: the
* object's Description and a field's Description were `disabled` but kept the
* editable look beside greyed inputs. Those text areas are plain elements, so
* they carried none of the `disabled:` utilities the shared primitives wear.
* The expected look is read off the primitive, as the Validations panel's pin
* (objectui#11781) reads it.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { Textarea } from '@object-ui/components';

vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({
list: vi.fn().mockResolvedValue([]),
listDrafts: vi.fn().mockResolvedValue([]),
}),
}));

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { ObjectDefaultInspector } from './ObjectDefaultInspector';
import { ObjectFieldInspector } from './ObjectFieldInspector';

afterEach(cleanup);

/** The `disabled:` utilities the shared Textarea primitive wears. */
function primitiveDisabledLook(): string[] {
const { container, unmount } = render(<Textarea disabled />);
const tokens = (container.querySelector('textarea') as HTMLTextAreaElement).className
.split(/\s+/)
.filter((c) => c.startsWith('disabled:'));
unmount();
return tokens;
}

const draft = {
name: 'probe_widget',
label: 'Probe widget',
description: 'An object',
fields: { notes: { type: 'textarea', label: 'Notes', description: 'Free text' } },
};

function expectDisabledLook(container: HTMLElement) {
const look = primitiveDisabledLook();
expect(look.length).toBeGreaterThan(0);
const areas = [...container.querySelectorAll('textarea')];
// A vacuous pass is not one: the inspector draws at least one text area.
expect(areas.length).toBeGreaterThan(0);
for (const el of areas) {
expect(el).toBeDisabled();
for (const token of look) expect(el.className.split(/\s+/)).toContain(token);
}
}

describe('read-only inspector text areas look disabled (objectstack#27521)', () => {
it('the object inspector’s Description', () => {
const { container } = render(
<ObjectDefaultInspector type="object" name="probe_widget" draft={draft} onPatch={() => {}} readOnly locale="en-US" />,
);
expectDisabledLook(container);
});

it('the field inspector’s text areas', () => {
const { container } = render(
<ObjectFieldInspector
type="object"
name="probe_widget"
draft={draft}
selection={{ kind: 'field', id: 'notes' }}
onPatch={() => {}}
onClearSelection={() => {}}
onSelectionChange={() => {}}
readOnly
locale="en-US"
/>,
);
expectDisabledLook(container);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function ObjectDefaultInspector({
rows={2}
placeholder={tr('designer.object.descriptionPlaceholder')}
onChange={(e) => onPatch({ description: e.target.value || undefined })}
className="w-full text-sm rounded-md border bg-background px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary"
className="w-full text-sm rounded-md border bg-background px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</Section>
Expand Down
Loading
Loading