Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow duplicate group names while validating no duplicate tokens #2827

Merged
merged 7 commits into from
Jun 16, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('TokenGroupHeading', () => {
await fireEvent.contextMenu(getByText('color'));
await fireEvent.click(getByText('rename'));

expect(getByText('Rename color.slate')).toBeInTheDocument();
expect(getByText('rename color.slate')).toBeInTheDocument();
});

it('should render duplicate token group modal', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function TokenGroupHeading({
onClose={handleRenameTokenGroupModalClose}
handleRenameTokenGroupSubmit={handleRenameTokenGroupSubmit}
handleNewTokenGroupNameChange={handleNewTokenGroupNameChange}
type={type}
/>

<DuplicateTokenGroupModal
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, TextInput, Stack } from '@tokens-studio/ui';
import {
Button, TextInput, Stack, Text,
Tooltip,
} from '@tokens-studio/ui';
import Modal from '../Modal';
import { MultiSelectDropdown } from '../MultiSelectDropdown';
import { ErrorMessage } from '../ErrorMessage';
import { activeTokenSetSelector, tokensSelector } from '@/selectors';
import useManageTokens from '@/app/store/useManageTokens';
import { StyledTokenButton, StyledTokenButtonText } from '../TokenButton/StyledTokenButton';
import { validateDuplicateGroupName, ErrorType } from '@/utils/validateGroupName';

type Props = {
isOpen: boolean;
Expand Down Expand Up @@ -38,15 +43,21 @@ export default function DuplicateTokenGroupModal({
onClose();
}, [duplicateGroup, oldName, newName, selectedTokenSets, type, onClose]);

const canDuplicate = React.useMemo(() => {
const isDuplicated = Object.entries(tokens).some(([tokenSetKey, tokenList]) => {
if (selectedTokenSets.includes(tokenSetKey)) {
return tokenList.some((token) => token.name.startsWith(`${newName}.`) || token.name === newName);
}
return false;
});
return !isDuplicated;
}, [tokens, newName, selectedTokenSets]);
const error = useMemo(() => {
if (newName === oldName && selectedTokenSets.includes(activeTokenSet)) {
return {
type: ErrorType.ExistingGroup,
};
}
if (selectedTokenSets.length === 0) {
return {
type: ErrorType.NoSetSelected,
};
}
return validateDuplicateGroupName(tokens, selectedTokenSets, activeTokenSet, type, oldName, newName);
}, [activeTokenSet, newName, oldName, selectedTokenSets, tokens, type]);

const canDuplicate = !error;

return (
<Modal
Expand Down Expand Up @@ -78,9 +89,63 @@ export default function DuplicateTokenGroupModal({
required
css={{ width: '100%' }}
/>
{!canDuplicate && <ErrorMessage css={{ width: '100%' }}> {t('duplicateGroupError')} </ErrorMessage>}
{!canDuplicate && error?.type && (
<ErrorMessage css={{ width: '100%', maxHeight: 150, overflow: 'scroll' }}>
{{
[ErrorType.NoSetSelected]: t('duplicateGroupModal.errors.noSetSelected'),
[ErrorType.EmptyGroupName]: t('duplicateGroupModal.errors.emptyGroupName'),
[ErrorType.ExistingGroup]: t('duplicateGroupModal.errors.existingGroup'),
[ErrorType.OverlappingToken]: error.foundOverlappingTokens && (
<>
{t('duplicateGroupModal.errors.overlappingToken', {
tokenSets: Object.keys(error.foundOverlappingTokens).map((n) => `“${n}”`).join(', '),
})}
{Object.entries(error.foundOverlappingTokens).map(([selectedSet, overlappingTokens]) => (
<>
<Tooltip label="Set" side="right">
<Text css={{ marginTop: '$2', marginBottom: '$2', fontWeight: '$bold' }}>
{selectedSet}
</Text>
</Tooltip>
<Stack direction="row" gap={2}>
{overlappingTokens.map((t) => (
<StyledTokenButton as="div" css={{ display: 'inline-flex', borderRadius: '$small', margin: 0 }}>
<StyledTokenButtonText css={{ wordBreak: 'break-word' }}><span>{t.name}</span></StyledTokenButtonText>
</StyledTokenButton>
))}
</Stack>
</>
))}
</>
),
[ErrorType.OverlappingGroup]: (
<>
{t('duplicateGroupModal.errors.overlappingGroup', {
groupName: newName, tokenSets: error.possibleDuplicates && Object.keys(error.possibleDuplicates).map((n) => `“${n}”`).join(', '),
})}
{error.possibleDuplicates && Object.entries(error.possibleDuplicates).map(([selectedSet, overlappingTokens]) => (
<>
<Tooltip label="Set" side="right">
<Text css={{ marginTop: '$2', marginBottom: '$2', fontWeight: '$bold' }}>
{selectedSet}
</Text>
</Tooltip>
<Stack direction="row" wrap css={{ marginTop: '$2' }}>
{overlappingTokens.map(({ name }) => (
<StyledTokenButton as="div" css={{ borderRadius: '$small' }}>
<StyledTokenButtonText css={{ wordBreak: 'break-word' }}><span>{name}</span></StyledTokenButtonText>
</StyledTokenButton>
))}
</Stack>
</>
))}
</>
),
}[error.type]}
</ErrorMessage>
)}
<MultiSelectDropdown menuItems={Object.keys(tokens)} selectedItems={selectedTokenSets} handleSelectedItemChange={handleSelectedItemChange} />
</Stack>
</Modal>
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button } from '@tokens-studio/ui';
Expand All @@ -8,6 +8,8 @@ import Modal from '../Modal';
import Stack from '../Stack';
import Input from '../Input';
import Text from '../Text';
import { StyledTokenButton, StyledTokenButtonText } from '../TokenButton/StyledTokenButton';
import { validateRenameGroupName, ErrorType } from '@/utils/validateGroupName';

type Props = {
isOpen: boolean
Expand All @@ -16,33 +18,39 @@ type Props = {
onClose: () => void;
handleRenameTokenGroupSubmit: (e: React.FormEvent<HTMLFormElement>) => void
handleNewTokenGroupNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
type: string
};

export default function RenameTokenGroupModal({
isOpen, newName, oldName, onClose, handleRenameTokenGroupSubmit, handleNewTokenGroupNameChange,
isOpen, newName, oldName, onClose, handleRenameTokenGroupSubmit, handleNewTokenGroupNameChange, type,
}: Props) {
const tokens = useSelector(tokensSelector);
const activeTokenSet = useSelector(activeTokenSetSelector);
const { t } = useTranslation(['tokens']);
const { t } = useTranslation(['tokens', 'general']);

const canRename = React.useMemo(() => {
const isDuplicated = tokens[activeTokenSet].some((token) => token.name.startsWith(`${newName}.`) || token.name === newName );
return !isDuplicated;
}, [tokens, newName, activeTokenSet]);
const error = useMemo(() => {
if (newName === oldName) {
return null;
}

return validateRenameGroupName(tokens[activeTokenSet], type, oldName, newName);
}, [activeTokenSet, newName, oldName, tokens, type]);

const canRename = !(newName === oldName || error);

return (
<Modal
title={`Rename ${oldName}`}
title={`${t('rename')} ${oldName}`}
isOpen={isOpen}
close={onClose}
footer={(
<form id="renameTokenGroup" onSubmit={handleRenameTokenGroupSubmit}>
<Stack direction="row" justify="end" gap={4}>
<Button variant="secondary" onClick={onClose}>
Cancel
{t('cancel')}
</Button>
<Button type="submit" variant="primary" disabled={(newName === oldName) || !canRename}>
Change
<Button type="submit" variant="primary" disabled={!canRename}>
{t('change')}
</Button>
</Stack>
</form>
Expand All @@ -57,8 +65,46 @@ export default function RenameTokenGroupModal({
autofocus
required
/>
{!canRename && <ErrorMessage css={{ width: '100%' }}>{t('renameGroupError')}</ErrorMessage>}
<Text muted>Renaming only affects tokens of the same type</Text>
{!canRename && error && (
<ErrorMessage css={{ width: '100%', maxHeight: 150, overflow: 'scroll' }}>
{{
[ErrorType.EmptyGroupName]: t('duplicateGroupModal.errors.emptyGroupName'),
[ErrorType.OverlappingToken]: error.foundOverlappingTokens?.length > 0 && (
<>
{t('renameGroupModal.errors.overlappingToken', {
tokenSet: activeTokenSet,
})}
{error.foundOverlappingTokens?.map((t) => (
<StyledTokenButton
as="div"
css={{
display: 'inline-flex', borderRadius: '$small', margin: 0, marginLeft: '$2',
}}
>
<StyledTokenButtonText key={t.name} css={{ wordBreak: 'break-word' }}><span>{t.name}</span></StyledTokenButtonText>
</StyledTokenButton>
))}
</>
),
[ErrorType.OverlappingGroup]: (
<>
{t('renameGroupModal.errors.overlappingGroup', {
groupName: newName,
tokenSet: activeTokenSet,
})}
<Stack direction="row" wrap css={{ marginTop: '$2' }}>
{error.possibleDuplicates?.map(({ name }) => (
<StyledTokenButton as="div" css={{ borderRadius: '$small' }}>
<StyledTokenButtonText css={{ wordBreak: 'break-word' }}><span>{name}</span></StyledTokenButtonText>
</StyledTokenButton>
))}
</Stack>
</>
),
}[error.type]}
</ErrorMessage>
)}
<Text muted>{t('renameGroupModal.infoSameType')}</Text>
</Stack>
</Modal>
);
Expand Down
19 changes: 18 additions & 1 deletion packages/tokens-studio-for-figma/src/i18n/lang/en/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,24 @@
},
"duplicate": "Duplicate",
"rename": "Rename",
"renameGroupError": "There's an existing token or group with that name",
"renameGroupModal": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add the translations to these for all the other languages?

"errors": {
"emptyGroupName": "The group name cannot be empty, please enter at least one character.",
"overlappingToken": "A token already exists with the same name in the token set “{{tokenSet}}”. Please choose a unique group name, or change these individual token names and try again:",
"overlappingGroup": "We can’t apply the group name “{{groupName}}” as there are tokens with the same name in the “{{tokenSet}}” token set. Please choose a different group name, or change these individual token names and try again:"
},
"infoSameType": "Renaming only affects tokens of the same type"
},
"duplicateGroupModal": {
"errors": {
"noSetSelected": "Please select at least one set.",
"emptyGroupName": "The group name cannot be empty, please enter at least one character.",
"existingGroup": "Please change the name to something different from the original group.",
"uniqueToken": "A token already exists with the same name in the {{tokenSets}} token set(s). Please choose a unique group name, or rename this token:",
"overlappingGroup": "We can’t apply the group name “{{groupName}}” as there are tokens with the same name in the {{tokenSets}} token set(s). Please choose a different group name, or change these individual token names and try again:",
"overlappingToken": "A token already exists with the same name in the token set(s) {{tokenSets}}. Please choose a different group name, or change these individual token names and try again:"
}
},
"emptyGroups": "empty groups",
"duplicateGroup": "Duplicate Group",
"duplicateGroupError": "There's an existing token or group with that name",
Expand Down
Loading
Loading