diff --git a/.changeset/22182-approval-approve-description.md b/.changeset/22182-approval-approve-description.md new file mode 100644 index 00000000000..11eac3fd1fe --- /dev/null +++ b/.changeset/22182-approval-approve-description.md @@ -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. diff --git a/.changeset/22182-invite-user-action-copy.md b/.changeset/22182-invite-user-action-copy.md new file mode 100644 index 00000000000..60b3e7472b4 --- /dev/null +++ b/.changeset/22182-invite-user-action-copy.md @@ -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. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 8761a43162b..414481856a0 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -141,7 +141,8 @@ export const enObjects: NonNullable = { _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", @@ -633,7 +634,8 @@ export const enObjects: NonNullable = { _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", @@ -734,7 +736,8 @@ export const enObjects: NonNullable = { _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", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 371b48793ec..1c83e9ce83a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -141,7 +141,8 @@ export const esESObjects: NonNullable = { _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", @@ -633,7 +634,8 @@ export const esESObjects: NonNullable = { _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", @@ -734,7 +736,8 @@ export const esESObjects: NonNullable = { _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", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 24024be4d33..fbaf0610fa3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -141,7 +141,8 @@ export const jaJPObjects: NonNullable = { _actions: { invite_user: { label: "ユーザーを招待", - successMessage: "招待を送信しました" + description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", + successMessage: "${result.email} に招待を送信しました" }, ban_user: { label: "利用停止", @@ -633,7 +634,8 @@ export const jaJPObjects: NonNullable = { _actions: { invite_user: { label: "ユーザーを招待", - successMessage: "招待を送信しました" + description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", + successMessage: "${result.email} に招待を送信しました" }, add_member: { label: "メンバーを追加", @@ -734,7 +736,8 @@ export const jaJPObjects: NonNullable = { _actions: { invite_user: { label: "ユーザーを招待", - successMessage: "招待を送信しました" + description: "メールアドレスを指定して招待します。招待が承諾されると、選択したロールでこの組織に参加します。", + successMessage: "${result.email} に招待を送信しました" }, cancel_invitation: { label: "招待をキャンセル", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 5e96b3926aa..1df4ac7c983 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -141,7 +141,8 @@ export const zhCNObjects: NonNullable = { _actions: { invite_user: { label: "邀请用户", - successMessage: "邀请已发送" + description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", + successMessage: "已向 ${result.email} 发送邀请" }, ban_user: { label: "封禁用户", @@ -633,7 +634,8 @@ export const zhCNObjects: NonNullable = { _actions: { invite_user: { label: "邀请用户", - successMessage: "邀请已发送" + description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", + successMessage: "已向 ${result.email} 发送邀请" }, add_member: { label: "添加成员", @@ -734,7 +736,8 @@ export const zhCNObjects: NonNullable = { _actions: { invite_user: { label: "邀请用户", - successMessage: "邀请已发送" + description: "按电子邮件地址邀请他人。对方接受邀请后,即以所选角色加入此组织。", + successMessage: "已向 ${result.email} 发送邀请" }, cancel_invitation: { label: "取消邀请", diff --git a/packages/platform-objects/src/identity/invite-user-action-copy.test.ts b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts new file mode 100644 index 00000000000..f1d8b8a9ca1 --- /dev/null +++ b/packages/platform-objects/src/identity/invite-user-action-copy.test.ts @@ -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 }>)?.[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); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 7ebcdb8370b..a3af6685409 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -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'], @@ -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 }, diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 5da60848ae9..15377513b98 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -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'], @@ -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 }, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 2fb2100164a..7fb8bb5497f 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -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'], @@ -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 }, diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 17a36501e7f..1cd4e332f9d 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -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, diff --git a/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts b/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts new file mode 100644 index 00000000000..304a49c4edb --- /dev/null +++ b/packages/plugins/plugin-approvals/src/translations/approve-decision-question.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `approval_approve` declares its decision question on the top-level +// `description`, as `approval_reject` beside it does. +// +// The console's parameter dialog shows an action's `description` as its +// subtitle and falls back to a generic "Please provide the required +// information to continue." when none is declared — so Approve opened a dialog +// that said nothing about what Confirm does, while Reject's said exactly that. +// +// The translated bundles are pinned with it: `os i18n extract --fill=default` +// seeds a NEW key with the English source in every locale, and that state is +// "in sync" to `check:i18n`. Every assertion on what is SERVED goes through the +// real resolver over the real object and this plugin's bundle, so a bundle +// that stopped being consulted shows up as English in the zh-CN case. + +import { describe, it, expect } from 'vitest'; +import { translateMetadataDocument } from '@objectstack/spec/system'; +import { ApprovalsTranslations } from './index.js'; +import { enObjects } from './en.objects.generated.js'; +import { zhCNObjects } from './zh-CN.objects.generated.js'; +import { jaJPObjects } from './ja-JP.objects.generated.js'; +import { esESObjects } from './es-ES.objects.generated.js'; +import { SysApprovalRequest } from '../sys-approval-request.object.js'; + +interface CopyAction { + name?: string; + description?: unknown; +} + +const action = (doc: unknown, name: string): CopyAction => { + const found = ((doc as { actions?: CopyAction[] }).actions ?? []).find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as CopyAction; +}; + +const bundleDescription = (bundle: unknown, name: string): unknown => + (bundle as any)?.sys_approval_request?._actions?.[name]?.description; + +const TRANSLATED = [ + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +describe('approval_approve — the decision question', () => { + it('declares a top-level description, as approval_reject does', () => { + for (const name of ['approval_approve', 'approval_reject']) { + const { description } = action(SysApprovalRequest, name); + expect(typeof description, `${name}.description`).toBe('string'); + expect((description as string).trim(), `${name}.description`).not.toBe(''); + } + }); + + it('the en bundle carries the declared question', () => { + expect(bundleDescription(enObjects, 'approval_approve')).toBe( + action(SysApprovalRequest, 'approval_approve').description, + ); + }); + + it.each(TRANSLATED)('%s translates it instead of echoing the English source', (locale, bundle) => { + const translated = bundleDescription(bundle, 'approval_approve'); + expect(translated, `${locale} approval_approve.description missing`).toBeTruthy(); + expect( + translated, + `${locale} approval_approve.description is the English source the extractor seeds a new key with`, + ).not.toBe(action(SysApprovalRequest, 'approval_approve').description); + }); + + it('the served object metadata carries the zh-CN question', () => { + // The object-metadata read the console uses localizes through + // `translateMetadataDocument('object', …)`; this plugin hands the i18n + // service `ApprovalsTranslations`. + const zh = translateMetadataDocument('object', SysApprovalRequest, ApprovalsTranslations, { + locale: 'zh-CN', + }); + const served = action(zh, 'approval_approve').description; + expect(served).toBe(bundleDescription(zhCNObjects, 'approval_approve')); + expect(served).not.toBe(action(SysApprovalRequest, 'approval_approve').description); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 0781daf014b..b58f9e07ec3 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -124,6 +124,7 @@ export const enObjects: NonNullable = { _actions: { approval_approve: { label: "Approve", + description: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires.", successMessage: "Approved.", params: { comment: { diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index f8ba11fa049..728f4a38640 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -124,6 +124,7 @@ export const esESObjects: NonNullable = { _actions: { approval_approve: { label: "Aprobar", + description: "¿Aprobar esta solicitud? Su aprobación queda registrada y la solicitud avanza cuando este paso reúne las aprobaciones que requiere.", successMessage: "Aprobada.", params: { comment: { diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 80c6a7cd636..9be2952a771 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -124,6 +124,7 @@ export const jaJPObjects: NonNullable = { _actions: { approval_approve: { label: "承認", + description: "このリクエストを承認しますか?あなたの承認が記録され、このステップに必要な承認がそろうとリクエストは次に進みます。", successMessage: "承認しました。", params: { comment: { diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 942c1e1b589..fb8f00c1ba0 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -124,6 +124,7 @@ export const zhCNObjects: NonNullable = { _actions: { approval_approve: { label: "通过", + description: "通过该请求?你的审批将被记录,此步骤获得所需的审批后,请求即继续流转。", successMessage: "已通过。", params: { comment: {