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

356: Add websocket sync provider #2973

Draft
wants to merge 1 commit into
base: release-139
Choose a base branch
from
Draft
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
3 changes: 2 additions & 1 deletion packages/tokens-studio-for-figma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"downshift": "^6.1.7",
"eventemitter3": "^4.0.7",
"expr-eval": "^2.0.2",
"fbp-client": "^0.4.3",
"file-saver": "^2.0.5",
"framer-motion": "^6.3.11",
"glob": "^10.3.1",
Expand Down Expand Up @@ -136,6 +137,7 @@
"zod": "^3.22.3"
},
"devDependencies": {
"0x": "^5.7.0",
"@babel/core": "^7.12.16",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-transform-export-namespace-from": "^7.24.1",
Expand Down Expand Up @@ -176,7 +178,6 @@
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"0x": "^5.7.0",
"babel-jest": "^26.6.3",
"babel-loader": "^8.2.2",
"changeset": "^0.2.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function pullTokensFactory(
StorageProviderType.URL,
StorageProviderType.SUPERNOVA,
StorageProviderType.TOKENS_STUDIO,
StorageProviderType.WEB_SOCKET,
].includes(storageType.provider);

if (isRemoteStorage) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { Button } from '@tokens-studio/ui';
import GitForm from './StorageItemForm/GitForm';
import ADOForm from './StorageItemForm/ADOForm';
import JSONBinForm from './StorageItemForm/JSONBinForm';
Expand All @@ -11,6 +12,7 @@ import { StorageTypeFormValues } from '@/types/StorageType';
import { StorageProviderType } from '@/constants/StorageProviderType';
import SupernovaForm from './StorageItemForm/SupernovaForm';
import TokensStudioForm from './StorageItemForm/TokensStudioForm';
import WebSocketForm from './StorageItemForm/WebSocketForm';

type Props = {
values: StorageTypeFormValues<true>;
Expand Down Expand Up @@ -125,6 +127,18 @@ export default function StorageItemForm({
/>
);
}
case StorageProviderType.WEB_SOCKET: {
return (
<WebSocketForm
onChange={onChange}
onSubmit={onSubmit}
onCancel={onCancel}
values={values}
hasErrored={hasErrored}
errorMessage={errorMessage}
/>
);
}
default: {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React from 'react';
import zod from 'zod';
import {
Box,
Button,
FormField,
IconButton,
Label,
Stack,
TextInput,
} from '@tokens-studio/ui';
import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import { StorageProviderType } from '@/constants/StorageProviderType';
import { StorageTypeFormValues } from '@/types/StorageType';
import { generateId } from '@/utils/generateId';
import { ChangeEventHandler } from './types';
import { ErrorMessage } from '../ErrorMessage';

type ValidatedFormValues = Extract<StorageTypeFormValues<false>, { provider: StorageProviderType.WEB_SOCKET }>;
type Props = {
values: Extract<StorageTypeFormValues<true>, { provider: StorageProviderType.WEB_SOCKET }>;
onChange: ChangeEventHandler;
onSubmit: (values: ValidatedFormValues) => void;
onCancel: () => void;
hasErrored?: boolean;
errorMessage?: string;
};

export default function WebSocketForm({
onChange, onSubmit, onCancel, values, hasErrored, errorMessage,
}: Props) {
const { t } = useTranslation(['storage']);
const [isMasked, setIsMasked] = React.useState(true);

const toggleMask = React.useCallback(() => {
setIsMasked((prev) => !prev);
}, []);

const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

const zodSchema = zod.object({
provider: zod.string(),
name: zod.string(),
id: zod.string(),
secret: zod.string(),
internalId: zod.string().optional(),
});
const validationResult = zodSchema.safeParse(values);
if (validationResult.success) {
const formFields = {
...validationResult.data,
internalId: validationResult.data.internalId || generateId(24),
} as ValidatedFormValues;
onSubmit(formFields);
}
},
[values, onSubmit],
);

return (
<Box>
<h1>Web socket</h1>
<form onSubmit={handleSubmit}>
<Stack direction="column" gap={5}>
<FormField>
<Label htmlFor="name">{t('providers.websocket.name')}</Label>
<TextInput name="name" id="name" value={values.name || ''} onChange={onChange} type="text" required />
</FormField>
<FormField>
<Label htmlFor="id">{t('providers.websocket.address')}</Label>
<TextInput
value={values.id || ''}
onChange={onChange}
type="text"
name="id"
id="id"
required
/>
</FormField>
<FormField>
<Label htmlFor="secret">{t('providers.websocket.secret')}</Label>
<TextInput
value={values.secret || ''}
onChange={onChange}
name="secret"
id="secret"
required
type={isMasked ? 'password' : 'text'}
trailingAction={
<IconButton variant="invisible" size="small" onClick={toggleMask} icon={isMasked ? <EyeClosedIcon /> : <EyeOpenIcon />} />
}
/>
</FormField>
<Stack direction="row" justify="end" gap={4}>
<Button variant="secondary" onClick={onCancel}>
{t('cancel')}
</Button>
<Button variant="primary" type="submit" disabled={!values.secret && !values.name}>
{t('save')}
</Button>
</Stack>
{hasErrored && (
<ErrorMessage data-testid="provider-modal-error">
{errorMessage}
</ErrorMessage>
)}
</Stack>
</form>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const SyncSettings = () => {
type: StorageProviderType.TOKENS_STUDIO,
beta: true,
},
{
text: 'WebSocket',
type: StorageProviderType.WEB_SOCKET,
beta: true,
},
], [t]);

const apiProviders = useSelector(apiProvidersSelector);
Expand Down
15 changes: 13 additions & 2 deletions packages/tokens-studio-for-figma/src/app/components/Tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { ToggleGroup, IconButton, Label } from '@tokens-studio/ui';
import {
ToggleGroup, IconButton, Label, Stack,
} from '@tokens-studio/ui';
import { mergeTokenGroups } from '@/utils/tokenHelpers';
import TokenListing from './TokenListing';
import TokensBottomBar from './TokensBottomBar';
Expand All @@ -20,7 +22,7 @@ import useTokens from '../store/useTokens';
import AttentionIcon from '@/icons/attention.svg';
import { TokensContext } from '@/context';
import {
activeTokenSetSelector, aliasBaseFontSizeSelector, manageThemesModalOpenSelector, scrollPositionSetSelector, showEditFormSelector, tokenFilterSelector, tokensSelector, tokenTypeSelector, usedTokenSetSelector,
activeTokenSetSelector, aliasBaseFontSizeSelector, apiProvidersSelector, manageThemesModalOpenSelector, scrollPositionSetSelector, showEditFormSelector, tokenFilterSelector, tokensSelector, tokenTypeSelector, usedTokenSetSelector,
} from '@/selectors';
import { ThemeSelector } from './ThemeSelector';
import { ManageThemesModal } from './ManageThemesModal';
Expand All @@ -32,6 +34,8 @@ import SidebarIcon from '@/icons/sidebar.svg';
import { defaultTokenResolver } from '@/utils/TokenResolver';
import { tokenFormatSelector } from '@/selectors/tokenFormatSelector';
import { IconJson } from '@/icons';
import { StorageProviderType } from '@/constants/StorageProviderType';
import { WebSocket } from './WebSocket';

const StatusToast = ({ open, error }: { open: boolean; error: string | null }) => {
const [isOpen, setOpen] = React.useState(open);
Expand Down Expand Up @@ -94,6 +98,7 @@ function Tokens({ isActive }: { isActive: boolean }) {
const scrollPositionSet = useSelector(scrollPositionSetSelector);
const tokenFilter = useSelector(tokenFilterSelector);
const aliasBaseFontSize = useSelector(aliasBaseFontSizeSelector);
const apiProviders = useSelector(apiProvidersSelector);
const dispatch = useDispatch<Dispatch>();
const [tokenSetsVisible, setTokenSetsVisible] = React.useState(true);
const { getStringTokens } = useTokens();
Expand Down Expand Up @@ -197,6 +202,12 @@ function Tokens({ isActive }: { isActive: boolean }) {

if (!isActive) return null;

const isWebSocket = apiProviders.some((provider) => provider.provider === StorageProviderType.WEB_SOCKET);

if (isWebSocket) {
return <WebSocket />;
}

return (
<TokensContext.Provider value={tokensContextValue}>
<Box
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Stack } from '@tokens-studio/ui';
import React from 'react';

export const WebSocket = () => {
const messages = [1, 2, 3, 4, 5];

return (
<Stack
direction="column"
gap={4}
align="center"
css={{
flexGrow: 1,
height: '100%',
width: '100%',
padding: '$4',
overflow: 'hidden',
}}
>
{messages.map((message) => (
<Stack
key={`message-${message}`}
direction="row"
align="center"
css={{
width: '60%',
height: '24px',
background: '$accentBg',
padding: '$2',
borderRadius: '$small',
}}
>
Message
{' '}
{message}
</Stack>
))}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './WebSocket';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './webSocket';
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
useCallback, useEffect, useRef, useState,
} from 'react';
import fbpClient from 'fbp-client';
import { AnyTokenList, SingleToken } from '@/types/tokens';
import { RemoteResponseData } from '@/types/RemoteResponseData';
import { StorageProviderType, StorageTypeCredentials, StorageTypeFormValues } from '@/types/StorageType';
import { AsyncMessageChannel } from '@/AsyncMessageChannel';
import { AsyncMessageTypes } from '@/types/AsyncMessages';
import { WebSocketTokenStorage } from '@/storage/WebSocketTokenStorage';

type WebSocketCredentials = Extract<StorageTypeCredentials, { provider: StorageProviderType.WEB_SOCKET }>;
type WebSocketFormValues = Extract<StorageTypeFormValues<false>, { provider: StorageProviderType.WEB_SOCKET }>;


export function useWebSocket() {
const [tokens, setTokens] = useState<Record<string, AnyTokenList>>({});
const clientRef = useRef<WebSocket | null>(null);

const storageClientFactory = useCallback((context: WebSocketCredentials) => {
const storageClient = new WebSocketTokenStorage(context.id, context.secret);
return storageClient;
}, []);

const connect = useCallback(async (context: WebSocketCredentials) => {
const storage = storageClientFactory(context);

await storage.connect();

const client = storage.client;
clientRef.current = client;

client.on('message', (message) => {
console.log('message:', message);
});

client.on('signal', (signal) => {
console.log('Signal:', signal);
});

client.on('runtime:packet', (packet) => {
console.log('Runtime packet:', packet);
});
}, []);


const closeConnection = () => {
if (clientRef.current) {
clientRef.current.close();
}
};

const addNewWebSocketCredentials = useCallback(
async (context: WebSocketFormValues): Promise<RemoteResponseData> => {

connect(context);

AsyncMessageChannel.ReactInstance.message({
type: AsyncMessageTypes.CREDENTIALS,
credential: context,
});

return {
status: 'success',
tokens,
themes: [],
};
}, [connect, tokens],
);


return {
connect, closeConnection, tokens, addNewWebSocketCredentials
};
}
Loading
Loading