Skip to content
Open
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
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
108 changes: 107 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,112 @@ 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('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,18 +1,18 @@
export const createOrganizationMessages = {
create: {
title: 'Create organization',
title: 'Create {organization}',
logoLabel: 'Logo',
logoDescription: 'Recommended size 1:1, up to 10MB.',
uploadLogo: 'Upload',
removeLogo: 'Remove',
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