Skip to content
7 changes: 7 additions & 0 deletions .changeset/mosaic-organization-terms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/mosaic': minor
---

Add `terms` to Mosaic's localization catalog, so the word for an organization can be changed in one place. Set `terms.organization` to `{ one: 'workspace', other: 'workspaces' }` and every built-in string uses it, capitalized where needed. The default is still "organization", and a specific key override still wins over a term.

Custom strings can use `{organization}`, `{organizations}`, `{Organization}` and `{Organizations}`.
4 changes: 3 additions & 1 deletion packages/mosaic/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ helper takes the messages as a parameter rather than importing them.
```

`messages` is the catalog for `locale`, `overrides` the app's own changes on top, and
English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are resolved
English sits under both. `locale` only feeds `Intl.PluralRules`. The `terms` namespace holds
words shared across strings: `{organization}` in any message resolves from it after the
layers merge, so a view never passes a term to `fill`. Strings are resolved
with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split`
on the string:

Expand Down
116 changes: 115 additions & 1 deletion packages/mosaic/src/__tests__/localization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('resolveLocalization', () => {
const { messages } = resolveLocalization({ messages: { userButton: { trigger: { open: 'Menü für {name}' } } } });
expect(messages.userButton.trigger.open).toBe('Menü für {name}');
expect(messages.userButton.popup.label).toBe(mosaicMessages.userButton.popup.label);
expect(messages.userProfile).toBe(mosaicMessages.userProfile);
expect(messages.userProfile).toEqual(mosaicMessages.userProfile);
});

it('expands dot paths', () => {
Expand Down Expand Up @@ -70,6 +70,120 @@ describe('resolveLocalization', () => {
});
});

describe('terms', () => {
const workspace = { organization: { one: 'workspace', other: 'workspaces' } };

it('keeps the organization noun in English by default', () => {
const { messages } = resolveLocalization(undefined);
expect(messages.organizationProfile.label).toBe('Organization');
expect(messages.organizationSwitcher.popup.label).toBe('Organizations');
expect(messages.organizationSwitcher.organizations.loading).toBe('Loading organizations…');
expect(messages.organizationProfile.danger.leave.label).toBe('Leave organization');
});

it('derives all four placeholders from one term', () => {
const { messages } = resolveLocalization({ overrides: { terms: workspace } });
expect(messages.organizationProfile.label).toBe('Workspace');
expect(messages.organizationSwitcher.popup.label).toBe('Workspaces');
expect(messages.organizationSwitcher.organizations.loading).toBe('Loading workspaces…');
expect(messages.organizationProfile.danger.leave.label).toBe('Leave workspace');
});

it('leaves no organization noun behind in any built-in string', () => {
const { messages } = resolveLocalization({ overrides: { terms: workspace } });
const { terms: _terms, ...rest } = messages;
const strings: string[] = [];
const collect = (node: unknown) => {
if (typeof node === 'string') {
strings.push(node);
} else if (typeof node === 'object' && node !== null) {
Object.values(node).forEach(collect);
}
};
collect(rest);
expect(strings.filter(value => /\borganizations?\b/i.test(value))).toEqual([]);
});

it('accepts a term from a catalog, by dot path, with overrides winning', () => {
const fromCatalog = resolveLocalization({
locale: 'es-ES',
messages: { 'terms.organization': { one: 'organización', other: 'organizaciones' } },
});
expect(fromCatalog.messages.organizationProfile.label).toBe('Organización');

const overridden = resolveLocalization({
locale: 'es-ES',
messages: { terms: { organization: { one: 'organización' } } },
overrides: { terms: { organization: { one: 'espacio' } } },
});
expect(overridden.messages.organizationProfile.label).toBe('Espacio');
});

it('uses the form the locale picks for one', () => {
const { messages } = resolveLocalization({
locale: 'ja',
messages: { terms: { organization: { other: '組織' } } },
});
expect(messages.organizationProfile.label).toBe('組織');
expect(messages.organizationSwitcher.popup.label).toBe('組織');
});

it('capitalizes with the rules of the locale', () => {
const { messages } = resolveLocalization({
locale: 'tr',
messages: { terms: { organization: { one: 'işyeri', other: 'işyerleri' } } },
});
expect(messages.organizationProfile.label).toBe('İşyeri');
});

it('keeps the English singular when a catalog leaves the term untranslated', () => {
const { messages } = resolveLocalization({ locale: 'ja', messages: { 'userButton.popup.label': 'アカウント' } });
expect(messages.organizationProfile.danger.leave.label).toBe('Leave organization');
expect(messages.organizationProfile.label).toBe('Organization');
});

it('keeps the English singular when a catalog copies the English term', () => {
const { messages } = resolveLocalization({
locale: 'ja',
messages: { terms: { organization: { one: 'organization', other: 'organizations' } } },
});
expect(messages.organizationProfile.danger.leave.label).toBe('Leave organization');
});

it('capitalizes a first letter outside the basic multilingual plane', () => {
const { messages } = resolveLocalization({
locale: 'ff-Adlm',
messages: { terms: { organization: { one: '𞤢𞤣', other: '𞤢𞤣' } } },
});
expect(messages.organizationProfile.label).toBe('𞤀𞤣');
});

it('lets a specific key win over a term', () => {
const { messages } = resolveLocalization({
overrides: { terms: workspace, 'organizationProfile.label': 'Team' },
});
expect(messages.organizationProfile.label).toBe('Team');
expect(messages.organizationSwitcher.popup.label).toBe('Workspaces');
});

it('fills terms in custom strings, plural forms and error messages', () => {
const { messages } = resolveLocalization({
overrides: { terms: workspace, 'userButton.popup.label': 'Your {organization}' },
});
expect(messages.userButton.popup.label).toBe('Your workspace');
expect(messages.organizationProfile.danger.delete.dialogDescription.other).toContain('all workspace data');
expect(messages.errors.organization_changed).toBe('The active workspace changed. Please try again.');
});

it('keeps other placeholders and markup for later', () => {
const { messages } = resolveLocalization({
overrides: { terms: workspace, 'userButton.popup.label': '{#strong}{organization}{/strong} {name}' },
});
expect(messages.userButton.popup.label).toBe('{#strong}workspace{/strong} {name}');
expect(messages.organizationSwitcher.trigger.open).toBe('Open workspace menu for {name}');
});
});

describe('useMessages', () => {
it('returns the base namespace without a provider', () => {
const { result } = renderHook(() => useMessages('userButton'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const apiKeysTableMessages = {
noKeys: 'No API Keys created',
noKeysDescription: {
user: 'API keys allow apps and scripts to access your account without signing in.',
organization: 'API keys allow apps and scripts to access your organization without signing in.',
organization: 'API keys allow apps and scripts to access your {organization} without signing in.',
},
clearSearch: 'Clear search',
pagination: 'API keys pages',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const createOrganizationMessages = {
create: {
title: 'Create organization',
title: 'Create {organization}',
logo: {
label: 'Logo',
description: 'Recommended size 1:1, up to 10MB.',
Expand All @@ -10,13 +10,13 @@ export const createOrganizationMessages = {
remove: 'Remove logo',
},
nameLabel: 'Name',
namePlaceholder: 'Organization name',
namePlaceholder: '{Organization} name',
slugLabel: 'Slug',
slugPlaceholder: 'my-org',
slugInvalid: 'Use lowercase letters, numbers and hyphens only.',
submit: 'Create organization',
submit: 'Create {organization}',
cancel: 'Cancel',
error: 'Unable to create the organization. Please try again.',
error: 'Unable to create the {organization}. Please try again.',
},
invite: {
title: 'Invite new members',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('OrganizationList', () => {
it('leaves the personal account out with hidePersonal', async () => {
await renderOrganizationList(signedIn(), { hidePersonal: true });

expect(screen.getByRole('heading', { name: 'Choose an organization' })).toBeVisible();
expect(screen.getByRole('heading', { name: 'Choose your organization' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Acme' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull();
});
Expand All @@ -112,7 +112,7 @@ describe('OrganizationList', () => {
seed.environment = fapiEnvironment({ organization_settings: { force_organization_selection: true } });
await renderOrganizationList(seed);

expect(screen.getByRole('heading', { name: 'Choose an organization' })).toBeVisible();
expect(screen.getByRole('heading', { name: 'Choose your organization' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull();
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
export const organizationListMessages = {
title: 'Choose an account',
titleWithoutPersonal: 'Choose an organization',
titleWithoutPersonal: 'Choose your {organization}',
subtitle: 'to continue to {applicationName}',
personal: 'Personal account',
loading: 'Loading organizations…',
loading: 'Loading {organizations}…',
pending: 'pending',
acceptInvitation: 'Join',
acceptSuggestion: 'Request to join',
suggestionAccepted: 'Pending approval',
createOrganization: 'Create organization',
createOrganization: 'Create {organization}',
error: 'Something went wrong. Please try again.',
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ export const organizationProfileDangerSectionMessages = {
fieldLabel: 'Type “{phrase}” below to continue',
cancelLabel: 'Cancel',
leave: {
label: 'Leave organization',
description: 'You will lose access to this organization and its applications.',
actionLabel: 'Leave organization',
dialogTitle: 'Leave organization',
label: 'Leave {organization}',
description: 'You will lose access to this {organization} and its applications.',
actionLabel: 'Leave {organization}',
dialogTitle: 'Leave {organization}',
dialogDescription:
'Are you sure you want to leave this organization? You will lose access to this organization and its applications.',
'Are you sure you want to leave this {organization}? You will lose access to this {organization} and its applications.',
},
delete: {
label: 'Delete organization',
label: 'Delete {organization}',
description:
'Permanently delete this organization and all its data. This cannot be undone. All members will lose access.',
actionLabel: 'Delete organization',
dialogTitle: 'Delete organization',
'Permanently delete this {organization} and all its data. This cannot be undone. All members will lose access.',
actionLabel: 'Delete {organization}',
dialogTitle: 'Delete {organization}',
dialogDescription: {
one: 'Are you sure you want to delete {name}? This removes {count} member and permanently deletes all organization data. This can’t be undone.',
one: 'Are you sure you want to delete {name}? This removes {count} member and permanently deletes all {organization} data. This can’t be undone.',
other:
'Are you sure you want to delete {name}? This removes {count} members and permanently deletes all organization data. This can’t be undone.',
'Are you sure you want to delete {name}? This removes {count} members and permanently deletes all {organization} data. This can’t be undone.',
},
},
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const invitationsTableTabMessages = {
actions: 'Actions',
invite: 'Invite',
noInvitations: 'No pending invitations',
noInvitationsDescription: 'Invite people to add them to this organization.',
noInvitationsDescription: 'Invite people to add them to this {organization}.',
loading: 'Loading invitations',
loaded: 'Invitations loaded',
loadError: 'Unable to load invitations',
Expand All @@ -16,7 +16,7 @@ export const invitationsTableTabMessages = {
manage: 'Manage {name}',
revoke: 'Revoke invitation',
revokeTitle: 'Revoke {name}?',
revokeDescription: 'This invitation will no longer allow the recipient to join the organization.',
revokeDescription: 'This invitation will no longer allow the recipient to join the {organization}.',
revokeError: 'Unable to revoke this invitation. Please try again.',
cancel: 'Cancel',
pagination: 'Invitations pagination',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@ export const membersTableTabMessages = {
empty: 'No members found',
emptyDescription: 'Your search for "{query}" did not return any results.',
noMembers: 'No members yet',
noMembersDescription: 'Invite people to add them to this organization.',
noMembersDescription: 'Invite people to add them to this {organization}.',
loading: 'Loading members',
loaded: 'Members loaded',
loadError: 'Unable to load members',
retry: 'Try again',
roleChangeError: 'Unable to change this member’s role. Please try again.',
roleSetMigration: 'Roles cannot be changed while this organization’s role migration is in progress.',
roleSetMigration: 'Roles cannot be changed while this {organization}’s role migration is in progress.',
selectAll: 'Select all members on this page',
select: 'Select {name}',
manage: 'Manage {name}',
changeRole: 'Change role for {name}',
remove: 'Remove from organization',
remove: 'Remove from {organization}',
you: 'You',
deprovisioned: 'Deprovisioned',
banned: 'Banned',
removeTitle: 'Remove {name}?',
removeDescription: 'This member will lose access to the organization.',
removeDescription: 'This member will lose access to the {organization}.',
removeError: 'Unable to remove this member. Please try again.',
cancel: 'Cancel',
pagination: 'Members pagination',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const requestsTableTabMessages = {
declineRequest: 'Decline {name}',
invite: 'Invite',
noRequests: 'No pending requests',
noRequestsDescription: 'Requests to join this organization will show up here.',
noRequestsDescription: 'Requests to join this {organization} will show up here.',
loading: 'Loading requests',
loaded: 'Requests loaded',
loadError: 'Unable to load requests',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const organizationProfileProfileSectionMessages = {
sectionTitle: 'Organization details',
sectionTitle: '{Organization} details',
logo: {
label: 'Logo',
description: 'Recommended size 1:1, up to 10MB.',
Expand All @@ -24,7 +24,7 @@ export const organizationProfileProfileSectionMessages = {
copied: 'Copied',

dialogTitle: 'Edit slug',
dialogDescription: 'A unique identifier used in organization URLs. Changing it may break existing links.',
dialogDescription: 'A unique identifier used in {organization} URLs. Changing it may break existing links.',
fieldLabel: 'Slug',
cancel: 'Cancel',
save: 'Save changes',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const organizationProfileMessages = {
label: 'Organization',
label: '{Organization}',
pages: {
general: 'General',
members: 'Members',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
export const organizationSwitcherMessages = {
trigger: {
open: 'Open organization menu for {name}',
open: 'Open {organization} menu for {name}',
},
popup: {
label: 'Organizations',
label: '{Organizations}',
},
organizations: {
personal: 'Personal account',
notSelected: 'No organization selected',
loading: 'Loading organizations…',
notSelected: 'No {organization} selected',
loading: 'Loading {organizations}…',
members: { one: '{count} member', other: '{count} members' },
accept: 'Accept',
join: 'Join',
Expand All @@ -19,7 +19,7 @@ export const organizationSwitcherMessages = {
invite: 'Invite members',
settings: 'Settings',
profile: 'Manage profile',
organization: 'Manage organization',
createOrganization: 'Add organization',
organization: 'Manage {organization}',
createOrganization: 'Add {organization}',
},
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const userButtonMessages = {
},
organizations: {
personal: 'Personal account',
notSelected: 'No organization selected',
loading: 'Loading organizations…',
notSelected: 'No {organization} selected',
loading: 'Loading {organizations}…',
members: { one: '{count} member', other: '{count} members' },
accept: 'Accept',
join: 'Join',
Expand All @@ -26,7 +26,7 @@ export const userButtonMessages = {
invite: 'Invite members',
settings: 'Settings',
profile: 'Manage profile',
organization: 'Manage organization',
createOrganization: 'Add organization',
organization: 'Manage {organization}',
createOrganization: 'Add {organization}',
},
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const userProfileMessages = {
/** Labels the account's pages when the organization's are listed beside them. */
accountGroup: 'Account',
/** Labels the organization's pages, listed after the account's. */
organizationGroup: 'Organization',
organizationGroup: '{Organization}',
pages: {
account: 'Account',
security: 'Security',
Expand Down
Loading
Loading