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
9 changes: 9 additions & 0 deletions .changeset/22182-approval-approve-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/plugin-approvals': patch
---

fix(plugin-approvals): Approve asks its decision question in the dialog, as Reject does

Clause-②: no

`approval_approve` on `sys_approval_request` declares a `description`: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires." The console shows it as the parameter dialog's subtitle, where a generic "Please provide the required information to continue." showed before; `approval_reject` beside it already declared its question this way. The zh-CN, ja-JP and es-ES bundles carry a translation of it.
11 changes: 11 additions & 0 deletions .changeset/22182-invite-user-action-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/platform-objects': patch
---

fix(platform-objects): Invite User explains itself in its dialog and names the invitee in its success message

Clause-②: no

- **The parameter dialog.** `invite_user` on `sys_user`, `sys_invitation` and `sys_member` declares a `description`: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation." The console shows it as the dialog's subtitle, where a generic "Please provide the required information to continue." showed before.
- **The success toast.** `successMessage` is now `Invitation sent to ${result.email}`. `POST /api/v1/auth/organization/invite-member` answers the invitation row, and its top-level `email` is the invitee's address as stored (lowercased), so the toast reads "Invitation sent to ada@example.com" where it read "Invitation sent".
- **Translations.** The zh-CN, ja-JP and es-ES bundles carry the new description and a success message that keeps the `${result.email}` token, so every locale names the invitee.
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invite User",
successMessage: "Invitation sent"
description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.",
successMessage: "Invitation sent to ${result.email}"
},
ban_user: {
label: "Ban User",
Expand Down Expand Up @@ -633,7 +634,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invite User",
successMessage: "Invitation sent"
description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.",
successMessage: "Invitation sent to ${result.email}"
},
add_member: {
label: "Add Member",
Expand Down Expand Up @@ -734,7 +736,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invite User",
successMessage: "Invitation sent"
description: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation.",
successMessage: "Invitation sent to ${result.email}"
},
cancel_invitation: {
label: "Cancel Invitation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invitar usuario",
successMessage: "Invitación enviada"
description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.",
successMessage: "Invitación enviada a ${result.email}"
},
ban_user: {
label: "Bloquear usuario",
Expand Down Expand Up @@ -633,7 +634,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invitar usuario",
successMessage: "Invitación enviada"
description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.",
successMessage: "Invitación enviada a ${result.email}"
},
add_member: {
label: "Añadir miembro",
Expand Down Expand Up @@ -734,7 +736,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "Invitar usuario",
successMessage: "Invitación enviada"
description: "Invita a alguien por su dirección de correo electrónico. Se une a esta organización con el rol elegido cuando acepta la invitación.",
successMessage: "Invitación enviada a ${result.email}"
},
cancel_invitation: {
label: "Cancelar invitación",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "ユーザーを招待",
successMessage: "招待を送信しました"
description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。",
successMessage: "${result.email} に招待を送信しました"
},
ban_user: {
label: "利用停止",
Expand Down Expand Up @@ -633,7 +634,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "ユーザーを招待",
successMessage: "招待を送信しました"
description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。",
successMessage: "${result.email} に招待を送信しました"
},
add_member: {
label: "メンバーを追加",
Expand Down Expand Up @@ -734,7 +736,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "ユーザーを招待",
successMessage: "招待を送信しました"
description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。",
successMessage: "${result.email} に招待を送信しました"
},
cancel_invitation: {
label: "招待をキャンセル",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "邀请用户",
successMessage: "邀请已发送"
description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。",
successMessage: "已向 ${result.email} 发送邀请"
},
ban_user: {
label: "封禁用户",
Expand Down Expand Up @@ -633,7 +634,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "邀请用户",
successMessage: "邀请已发送"
description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。",
successMessage: "已向 ${result.email} 发送邀请"
},
add_member: {
label: "添加成员",
Expand Down Expand Up @@ -734,7 +736,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
_actions: {
invite_user: {
label: "邀请用户",
successMessage: "邀请已发送"
description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。",
successMessage: "已向 ${result.email} 发送邀请"
},
cancel_invitation: {
label: "取消邀请",
Expand Down
135 changes: 135 additions & 0 deletions packages/platform-objects/src/identity/invite-user-action-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `invite_user`'s user-facing copy — the parameter dialog's subtitle and the
// success toast — on all three declaration sites (sys_user, sys_invitation,
// sys_member).
//
// What the console does with each key, measured on the pinned objectui:
//
// - `description` is the parameter dialog's subtitle. With none declared the
// dialog falls back to a generic "Please provide the required information
// to continue.", which says nothing about what Confirm will do.
// - `successMessage` is the toast after Confirm. On a `type: 'api'` action the
// runner fills `${result.*}` from the answer (ActionSchema.successMessage).
// `/api/v1/auth/organization/invite-member` answers the bare invitation row,
// whose top-level `email` is the invitee's address, so `${result.email}`
// names the person invited. A copy without the token names nobody.
//
// The translated bundles are pinned as well, because the extractor cannot keep
// them honest by itself: `--fill=default` seeds a NEW key (the description)
// with the English source in every locale, and merge mode keeps every existing
// translated value forever, so a revised `successMessage` would go on serving
// the old, invitee-less sentence. Both states are "in sync" to `check:i18n`.
import { describe, expect, it } from 'vitest';
import { translateMetadataDocument } from '@objectstack/spec/system';
import { SetupAppTranslations } from '../apps/translations/setup.translation.js';
import { enObjects } from '../apps/translations/en.objects.generated.js';
import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js';
import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
import { SysInvitation } from './sys-invitation.object.js';
import { SysMember } from './sys-member.object.js';
import { SysUser } from './sys-user.object.js';

interface CopyAction {
name?: string;
description?: unknown;
successMessage?: unknown;
}

/** The token the console runner fills from the invite-member answer. */
const INVITEE = '${result.email}';

const MIRRORS: Array<[string, unknown]> = [
['sys_user', SysUser],
['sys_invitation', SysInvitation],
['sys_member', SysMember],
];

const TRANSLATED = [
['zh-CN', zhCNObjects],
['ja-JP', jaJPObjects],
['es-ES', esESObjects],
] as const;

function invite(object: unknown): CopyAction {
const found = ((object as { actions?: CopyAction[] }).actions ?? []).find(
(a) => a.name === 'invite_user',
);
expect(found, 'invite_user is declared').toBeDefined();
return found as CopyAction;
}

const bundleNode = (bundle: unknown, object: string): CopyAction | undefined =>
(bundle as Record<string, { _actions?: Record<string, CopyAction> }>)?.[object]?._actions
?.invite_user;

describe('invite_user — declared copy', () => {
it.each(MIRRORS)('%s declares a description for the parameter dialog', (_name, object) => {
const { description } = invite(object);
expect(typeof description).toBe('string');
expect((description as string).trim()).not.toBe('');
});

it.each(MIRRORS)('%s names the invitee in its success message', (_name, object) => {
expect(invite(object).successMessage).toContain(INVITEE);
});

it('the three mirrors say the same thing', () => {
// One action, three declaration sites: compared to EACH OTHER, so a mirror
// edited alone goes red without any literal copied into this file.
const [first, ...rest] = MIRRORS.map(([, object]) => invite(object));
for (const other of rest) {
expect(other.description).toBe(first.description);
expect(other.successMessage).toBe(first.successMessage);
}
});
});

describe('invite_user — translated bundles follow the source', () => {
it.each(MIRRORS)('en carries the source copy for %s', (name, object) => {
const node = bundleNode(enObjects, name);
expect(node?.description).toBe(invite(object).description);
expect(node?.successMessage).toBe(invite(object).successMessage);
});

for (const [locale, bundle] of TRANSLATED) {
it.each(MIRRORS)(`${locale} translates the description for %s`, (name, object) => {
const translated = bundleNode(bundle, name)?.description;
expect(translated, `${locale} ${name}.invite_user.description missing`).toBeTruthy();
expect(
translated,
`${locale} ${name}.invite_user.description is the English source the extractor seeds a new key with`,
).not.toBe(invite(object).description);
});

it.each(MIRRORS)(`${locale} keeps the invitee token in the success message for %s`, (name) => {
// The old translation ("invitation sent") survives every re-extract in
// merge mode; only the token proves the leaf was revised with the source.
expect(bundleNode(bundle, name)?.successMessage).toContain(INVITEE);
});
}
});

describe('invite_user — the served object metadata', () => {
// The object-metadata read the console uses localizes through
// `translateMetadataDocument('object', …)` over the i18n service's bundle;
// `SetupAppTranslations` is the bundle this package hands that service.
const served = (object: unknown, locale: string): CopyAction => {
const doc = translateMetadataDocument('object', object, SetupAppTranslations, { locale });
return invite(doc);
};

it.each(MIRRORS)('zh-CN serves the translated description for %s', (name, object) => {
const zh = served(object, 'zh-CN');
expect(zh.description).toBe(bundleNode(zhCNObjects, name)?.description);
expect(zh.description).not.toBe(invite(object).description);
expect(zh.successMessage).toContain(INVITEE);
});

it.each(MIRRORS)('en serves the declared copy for %s', (_name, object) => {
const en = served(object, 'en');
expect(en.description).toBe(invite(object).description);
expect(en.successMessage).toBe(invite(object).successMessage);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const SysInvitation = ObjectSchema.create({
{
name: 'invite_user',
label: 'Invite User',
// Same wording as the sys_user / sys_member mirrors.
description:
'Invite someone by email address. They join this organization with the chosen role when they accept the invitation.',
icon: 'user-plus',
variant: 'primary',
locations: ['list_toolbar'],
Expand All @@ -67,7 +70,9 @@ export const SysInvitation = ObjectSchema.create({
// unreachable in single-org anyway (no invitation rows exist).
requiresFeature: 'organization',
requiresMembershipReach: 'invite_member',
successMessage: 'Invitation sent',
// `${result.email}`: the invitee's address from the invitation row
// invite-member answers (see sys_user's mirror).
successMessage: 'Invitation sent to ${result.email}',
refreshAfter: true,
params: [
{ field: 'email', required: true },
Expand Down
7 changes: 6 additions & 1 deletion packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export const SysMember = ObjectSchema.create({
// BUILTIN_MEMBERSHIP_ROLE_OPTIONS constant sys_invitation reads.
name: 'invite_user',
label: 'Invite User',
// Same wording as the sys_user / sys_invitation mirrors.
description:
'Invite someone by email address. They join this organization with the chosen role when they accept the invitation.',
icon: 'user-plus',
variant: 'primary',
locations: ['list_toolbar'],
Expand All @@ -102,7 +105,9 @@ export const SysMember = ObjectSchema.create({
// ...and the grades that reach the endpoint (ADR-0108 D1): owner,
// admin and delegated_admin — a plain member is not offered it.
requiresMembershipReach: 'invite_member',
successMessage: 'Invitation sent',
// `${result.email}`: the invitee's address from the invitation row
// invite-member answers (see sys_user's mirror).
successMessage: 'Invitation sent to ${result.email}',
refreshAfter: true,
params: [
{ field: 'email', objectOverride: 'sys_invitation', required: true },
Expand Down
8 changes: 7 additions & 1 deletion packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const SysUser = ObjectSchema.create({
{
name: 'invite_user',
label: 'Invite User',
// The parameter dialog's subtitle. Same wording on all three mirrors
// (sys_member, sys_invitation) so one translation serves each.
description:
'Invite someone by email address. They join this organization with the chosen role when they accept the invitation.',
icon: 'user-plus',
variant: 'primary',
locations: ['list_toolbar'],
Expand All @@ -95,7 +99,9 @@ export const SysUser = ObjectSchema.create({
// sys_user CRUD.
requiresFeature: 'organization',
requiresMembershipReach: 'invite_member',
successMessage: 'Invitation sent',
// `${result.email}`: invite-member answers the bare invitation row,
// whose top-level `email` is the invitee's address as stored (lowercased).
successMessage: 'Invitation sent to ${result.email}',
refreshAfter: true,
params: [
{ field: 'email', required: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,13 @@ export const SysApprovalRequest = ObjectSchema.create({
{
name: 'approval_approve',
label: 'Approve',
// The param dialog's subtitle, phrased as the decision question the way
// `approval_reject` below is (top-level `description`, never
// `ai.description`). Whether one approval completes the step depends on
// the step's behavior (first response, unanimous, quorum, per group),
// hence "once this step has the approvals it requires".
description:
'Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires.',
icon: 'check-circle',
// Primary decision — the console renders this filled/highlighted so it
// stands out from the secondary levers in the drawer's action bar,
Expand Down
Loading
Loading