Skip to content

Commit

Permalink
Allow duplicate group names while validating no duplicate tokens (#2827)
Browse files Browse the repository at this point in the history
* detect and prevent overlap/duplicate tokens in rename/duplicate group modals

* fix duplicate group name comparison bug

* use locale id in TokenGroupHeading jest test instead of Rename

* rewrite overlapping token group name logic for duplicate/rename with tests and util function

* more tests and bug fixes for group rename/duplicate collisions

---------

Co-authored-by: macintoshhelper <6757532+macintoshhelper@users.noreply.github.com>
  • Loading branch information
macintoshhelper and macintoshhelper authored Jun 16, 2024
1 parent 9173fb8 commit 1070ab1
Show file tree
Hide file tree
Showing 7 changed files with 740 additions and 28 deletions.
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 @@ -91,7 +91,24 @@
},
"duplicate": "Duplicate",
"rename": "Rename",
"renameGroupError": "There's an existing token or group with that name",
"renameGroupModal": {
"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

0 comments on commit 1070ab1

Please sign in to comment.