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
15 changes: 15 additions & 0 deletions .changeset/27864-permission-set-form-label-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': patch
'@objectstack/platform-objects': patch
---

fix(spec): the permission-set form asks for the label first, and requires it

Clause-②: no
Metadata-protocol: none

The Identity section of `permissionForm` (the form `/meta/types` serves for the `permission` kind) listed `name`, the API name, before an optional `label`, so an administrator creating a permission set typed a machine name first and could save a set with no human label, while the Console's create form fills `name` from the label. The section now lists `label` first and required, then `name`, whose help says it is derived from the label when the set is created.

`required` on a form row is a layout flag the renderer reads: `PermissionSetSchema` is unchanged and still accepts a set with no label, and nothing changes in how `name` is derived.

`@objectstack/platform-objects` carries the regenerated metadata-form bundles: the `permission` form's `label` row now precedes `name` in every locale, the English `name` help is the new source text, and the zh-CN, ja-JP and es-ES `name` help is translated to match.
Original file line number Diff line number Diff line change
Expand Up @@ -2385,14 +2385,14 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
}
},
fields: {
name: {
label: "Name",
helpText: "Machine name (snake_case)"
},
label: {
label: "Label",
helpText: "Display label for admins"
},
name: {
label: "Name",
helpText: "Derived from the label when you create the set; snake_case"
},
description: {
label: "Description",
helpText: "Human-readable description shown in Setup."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2385,14 +2385,14 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
}
},
fields: {
name: {
label: "Nombre",
helpText: "Nombre de máquina (snake_case)"
},
label: {
label: "Etiqueta",
helpText: "Etiqueta mostrada para administradores"
},
name: {
label: "Nombre",
helpText: "Se deriva de la etiqueta al crear el conjunto; snake_case"
},
description: {
label: "Descripción",
helpText: "Descripción legible que se muestra en Setup."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2385,14 +2385,14 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
}
},
fields: {
name: {
label: "名前",
helpText: "マシン名(snake_case)"
},
label: {
label: "表示名",
helpText: "管理者向け表示ラベル"
},
name: {
label: "名前",
helpText: "作成時に表示名から自動生成されます。snake_case"
},
description: {
label: "説明",
helpText: "Setup に表示される説明文。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2385,14 +2385,14 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
}
},
fields: {
name: {
label: "名称",
helpText: "唯一标识符(snake_case)"
},
label: {
label: "显示名称",
helpText: "面向管理员的显示标签"
},
name: {
label: "名称",
helpText: "创建权限集时由显示名称自动生成;snake_case"
},
description: {
label: "描述",
helpText: "在 Setup 中展示的可读描述。"
Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/security/permission-form-label-first.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #27864 — the New Permission Set form asks for the Label first, and requires it.
*
* The form opened on Name (the API name) with an optional Label below it, so an
* administrator typed a machine name first and could save a set with no human
* label, while the Console's create form derives `name` from the label. The
* Identity section now lists `label` first and required, with `name` after it.
*
* `required` on a form row is a layout flag the renderer reads: the set's
* schema still accepts a set with no label, which the last case pins so that a
* later move of the accept set is a deliberate edit, not a form side effect.
*/

import { describe, expect, it } from 'vitest';

import { METADATA_FORM_REGISTRY } from '../system/metadata-form-registry';
import { permissionForm } from './permission.form';
import { PermissionSetSchema } from './permission.zod';

type Row = { field: string; required?: boolean; helpText?: string };

const sections = (permissionForm.sections ?? []) as Array<{ label?: string; fields?: Array<Row | string> }>;
const identity = sections.find((s) => s.label === 'Identity');
const rows = (identity?.fields ?? []).map((f) => (typeof f === 'string' ? { field: f } : f)) as Row[];
const row = (key: string) => rows.find((r) => r.field === key);

describe('permission-set form — Label first and required, Name after it', () => {
it('is the form `/meta/types` serves for the permission kind', () => {
expect(METADATA_FORM_REGISTRY.permission).toBe(permissionForm);
});

it('opens on the Identity section, whose first two rows are label then name', () => {
expect(sections[0]?.label).toBe('Identity');
expect(rows.slice(0, 2).map((r) => r.field)).toEqual(['label', 'name']);
});

it('lists label before name across the whole form', () => {
const order = sections.flatMap((s) => (s.fields ?? []).map((f) => (typeof f === 'string' ? f : f.field)));
expect(order.indexOf('label')).toBeGreaterThanOrEqual(0);
expect(order.indexOf('label')).toBeLessThan(order.indexOf('name'));
});

it('requires the label, and still requires the name', () => {
expect(row('label')?.required).toBe(true);
expect(row('name')?.required).toBe(true);
// CONTROL: a neighbouring optional row is not swept into `required`.
expect(row('description')?.required).toBeUndefined();
});

it("the name's help says it comes from the label", () => {
expect(row('name')?.helpText).toMatch(/derived from the label/i);
expect(row('name')?.helpText).toMatch(/snake_case/);
});

it('moves no accept set: the schema still parses a set with no label', () => {
const r = PermissionSetSchema.safeParse({ name: 'crm_sales_rep', objects: {} });
expect(r.success).toBe(true);
// CONTROL: the same parse with a label is also clean.
expect(PermissionSetSchema.safeParse({ name: 'crm_sales_rep', label: 'Sales Rep', objects: {} }).success).toBe(true);
});
});
8 changes: 6 additions & 2 deletions packages/spec/src/security/permission.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ export const permissionForm = defineForm({
'Permission sets are the only capability container: a user gets the union of every set they hold, so sets only ever add access. Positions distribute sets to people.',
columns: 2,
fields: [
{ field: 'name', required: true, colSpan: 1, helpText: 'Machine name (snake_case)' },
{ field: 'label', colSpan: 1, helpText: 'Display label for admins' },
// Label first and required: an admin names the set in words, and the
// Console's create form fills `name` from the label until `name` is
// edited. `required` on a row is a form-layout flag: `PermissionSetSchema`
// still accepts a set with no label, so no API accept set moves.
{ field: 'label', required: true, colSpan: 1, helpText: 'Display label for admins' },
{ field: 'name', required: true, colSpan: 1, helpText: 'Derived from the label when you create the set; snake_case' },
// #19331 — four declared keys had no control, so the only door to any of
// them was the Source tab's free-text JSON. `description` is persisted on
// sys_permission_set and shown in Setup; of the other three, `packageId`
Expand Down
Loading