Feat: API Playground (#10376)
/claim #10283 --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -7,6 +7,38 @@ import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
import { SettingsPermissions } from '~/generated/graphql';
|
||||
|
||||
const SettingsApiKeys = lazy(() =>
|
||||
import('~/pages/settings/developers/api-keys/SettingsApiKeys').then(
|
||||
(module) => ({
|
||||
default: module.SettingsApiKeys,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsGraphQLPlayground = lazy(() =>
|
||||
import(
|
||||
'~/pages/settings/developers/playground/SettingsGraphQLPlayground'
|
||||
).then((module) => ({
|
||||
default: module.SettingsGraphQLPlayground,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsRestPlayground = lazy(() =>
|
||||
import('~/pages/settings/developers/playground/SettingsRestPlayground').then(
|
||||
(module) => ({
|
||||
default: module.SettingsRestPlayground,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsWebhooks = lazy(() =>
|
||||
import(
|
||||
'~/pages/settings/developers/webhooks/components/SettingsWebhooks'
|
||||
).then((module) => ({
|
||||
default: module.SettingsWebhooks,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsAccountsCalendars = lazy(() =>
|
||||
import('~/pages/settings/accounts/SettingsAccountsCalendars').then(
|
||||
(module) => ({
|
||||
@ -137,12 +169,6 @@ const SettingsBilling = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsDevelopers = lazy(() =>
|
||||
import('~/pages/settings/developers/SettingsDevelopers').then((module) => ({
|
||||
default: module.SettingsDevelopers,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsIntegrations = lazy(() =>
|
||||
import('~/pages/settings/integrations/SettingsIntegrations').then(
|
||||
(module) => ({
|
||||
@ -376,9 +402,15 @@ export const SettingsRoutes = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.APIs} element={<SettingsApiKeys />} />
|
||||
<Route path={SettingsPath.Webhooks} element={<SettingsWebhooks />} />
|
||||
<Route
|
||||
path={SettingsPath.Developers}
|
||||
element={<SettingsDevelopers />}
|
||||
path={`${SettingsPath.GraphQLPlayground}`}
|
||||
element={<SettingsGraphQLPlayground />}
|
||||
/>
|
||||
<Route
|
||||
path={`${SettingsPath.RestPlayground}/*`}
|
||||
element={<SettingsRestPlayground />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.DevelopersNewApiKey}
|
||||
|
||||
@ -57,7 +57,9 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
getSelectedIndexForSubItems(subItems);
|
||||
|
||||
return (
|
||||
<NavigationDrawerItemGroup key={item.path}>
|
||||
<NavigationDrawerItemGroup
|
||||
key={item.path || `group-${index}`}
|
||||
>
|
||||
<SettingsNavigationDrawerItem
|
||||
item={item}
|
||||
subItemState={
|
||||
@ -72,7 +74,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
/>
|
||||
{subItems.map((subItem, subIndex) => (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={subItem.path}
|
||||
key={subItem.path || `subitem-${subIndex}`}
|
||||
item={subItem}
|
||||
subItemState={
|
||||
subItem.indentationLevel
|
||||
@ -90,7 +92,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
}
|
||||
return (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={item.path}
|
||||
key={item.path || `item-${index}`}
|
||||
item={item}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
|
||||
@ -4,13 +4,15 @@ import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/componen
|
||||
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
|
||||
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
|
||||
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledTableBody = styled(TableBody)`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
@ -53,7 +55,9 @@ export const SettingsApiKeysTable = () => {
|
||||
<SettingsApiKeysFieldItemTableRow
|
||||
key={fieldItem.id}
|
||||
fieldItem={fieldItem as ApiFieldItem}
|
||||
to={`/settings/developers/api-keys/${fieldItem.id}`}
|
||||
to={getSettingsPath(SettingsPath.DevelopersApiKeyDetail, {
|
||||
apiKeyId: fieldItem.id,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</StyledTableBody>
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Button, IconBook2 } from 'twenty-ui';
|
||||
|
||||
export const SettingsReadDocumentationButton = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={t`Read documentation`}
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
size="small"
|
||||
Icon={IconBook2}
|
||||
to={'https://docs.twenty.com'}
|
||||
target="_blank"
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
@ -145,7 +145,7 @@ export const useWebhookUpdateForm = ({
|
||||
|
||||
const deleteWebhook = async () => {
|
||||
await deleteOneWebhook(webhookId);
|
||||
navigate(SettingsPath.Developers);
|
||||
navigate(SettingsPath.Webhooks);
|
||||
};
|
||||
|
||||
useFindOneRecord({
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconAt,
|
||||
IconCalendarEvent,
|
||||
IconCode,
|
||||
IconColorSwatch,
|
||||
IconComponent,
|
||||
IconCurrencyDollar,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
IconSettings,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconWebhook,
|
||||
} from 'twenty-ui';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
@ -159,9 +160,16 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
isAdvanced: true,
|
||||
items: [
|
||||
{
|
||||
label: t`API & Webhooks`,
|
||||
path: SettingsPath.Developers,
|
||||
Icon: IconCode,
|
||||
label: t`APIs`,
|
||||
path: SettingsPath.APIs,
|
||||
Icon: IconApi,
|
||||
isAdvanced: true,
|
||||
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
||||
},
|
||||
{
|
||||
label: t`Webhooks`,
|
||||
path: SettingsPath.Webhooks,
|
||||
Icon: IconWebhook,
|
||||
isAdvanced: true,
|
||||
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@ -0,0 +1,55 @@
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { explorerPlugin } from '@graphiql/plugin-explorer';
|
||||
import '@graphiql/plugin-explorer/dist/style.css';
|
||||
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
||||
import { GraphiQL } from 'graphiql';
|
||||
import 'graphiql/graphiql.css';
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ThemeContext } from 'twenty-ui';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
type GraphQLPlaygroundProps = {
|
||||
onError(): void;
|
||||
schema: PlaygroundSchemas;
|
||||
};
|
||||
|
||||
export const schemaToPath = {
|
||||
[PlaygroundSchemas.CORE]: 'graphql',
|
||||
[PlaygroundSchemas.METADATA]: 'metadata',
|
||||
};
|
||||
|
||||
export const GraphQLPlayground = ({
|
||||
onError,
|
||||
schema,
|
||||
}: GraphQLPlaygroundProps) => {
|
||||
const playgroundApiKey = useRecoilValue(playgroundApiKeyState);
|
||||
const baseUrl = REACT_APP_SERVER_BASE_URL + '/' + schemaToPath[schema];
|
||||
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
if (!playgroundApiKey) {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
|
||||
const explorer = explorerPlugin({
|
||||
showAttribution: true,
|
||||
});
|
||||
|
||||
const fetcher = createGraphiQLFetcher({
|
||||
url: baseUrl,
|
||||
});
|
||||
|
||||
return (
|
||||
<GraphiQL
|
||||
forcedTheme={theme.name as 'light' | 'dark'}
|
||||
plugins={[explorer]}
|
||||
fetcher={fetcher}
|
||||
defaultHeaders={JSON.stringify({
|
||||
Authorization: `Bearer ${playgroundApiKey}`,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,193 @@
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import styled from '@emotion/styled';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
Button,
|
||||
IconApi,
|
||||
IconBracketsAngle,
|
||||
IconBrandGraphql,
|
||||
IconFolderRoot,
|
||||
} from 'twenty-ui';
|
||||
import { z } from 'zod';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
const playgroundSetupFormSchema = z.object({
|
||||
apiKeyForPlayground: z.string(),
|
||||
schema: z.nativeEnum(PlaygroundSchemas),
|
||||
playgroundType: z.nativeEnum(PlaygroundTypes),
|
||||
});
|
||||
|
||||
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr 0.5fr;
|
||||
align-items: end;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const PlaygroundSetupForm = () => {
|
||||
const { t } = useLingui();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const [playgroundApiKey, setPlaygroundApiKey] = useRecoilState(
|
||||
playgroundApiKeyState,
|
||||
);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setError,
|
||||
} = useForm<PlaygroundSetupFormValues>({
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(playgroundSetupFormSchema),
|
||||
defaultValues: {
|
||||
schema: PlaygroundSchemas.CORE,
|
||||
playgroundType: PlaygroundTypes.REST,
|
||||
apiKeyForPlayground: playgroundApiKey || '',
|
||||
},
|
||||
});
|
||||
|
||||
const validateApiKey = async (values: PlaygroundSetupFormValues) => {
|
||||
try {
|
||||
// Validate by fetching the schema (but not storing it)
|
||||
const response = await fetch(
|
||||
`${REACT_APP_SERVER_BASE_URL}/open-api/${values.schema}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const openAPIReference = await response.json();
|
||||
|
||||
if (!openAPIReference.tags) {
|
||||
throw new Error('Invalid API Key');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t`Failed to validate API key. Please check your API key and try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: PlaygroundSetupFormValues) => {
|
||||
try {
|
||||
await validateApiKey(values);
|
||||
|
||||
setPlaygroundApiKey(values.apiKeyForPlayground);
|
||||
|
||||
const path =
|
||||
values.playgroundType === PlaygroundTypes.GRAPHQL
|
||||
? SettingsPath.GraphQLPlayground
|
||||
: SettingsPath.RestPlayground;
|
||||
|
||||
navigateSettings(path, {
|
||||
schema: values.schema.toLowerCase(),
|
||||
});
|
||||
} catch (error) {
|
||||
setError('apiKeyForPlayground', {
|
||||
type: 'manual',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t`An unexpected error occurred`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="apiKeyForPlayground"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextInput
|
||||
label={t`API Key`}
|
||||
placeholder="Enter your API key"
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
setPlaygroundApiKey(newValue);
|
||||
}}
|
||||
error={error?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="schema"
|
||||
control={control}
|
||||
defaultValue={PlaygroundSchemas.CORE}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
dropdownId="schema"
|
||||
label={t`Schema`}
|
||||
options={[
|
||||
{
|
||||
value: PlaygroundSchemas.CORE,
|
||||
label: t`Core`,
|
||||
Icon: IconFolderRoot,
|
||||
},
|
||||
{
|
||||
value: PlaygroundSchemas.METADATA,
|
||||
label: t`Metadata`,
|
||||
Icon: IconBracketsAngle,
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="playgroundType"
|
||||
control={control}
|
||||
defaultValue={PlaygroundTypes.REST}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
dropdownId="apiPlaygroundType"
|
||||
label={t`API`}
|
||||
options={[
|
||||
{
|
||||
value: PlaygroundTypes.REST,
|
||||
label: t`REST`,
|
||||
Icon: IconApi,
|
||||
},
|
||||
{
|
||||
value: PlaygroundTypes.GRAPHQL,
|
||||
label: t`GraphQL`,
|
||||
Icon: IconBrandGraphql,
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
title={t`Launch`}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</StyledForm>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ApiReferenceReact = lazy(() =>
|
||||
import('@scalar/api-reference-react').then((module) => ({
|
||||
default: module.ApiReferenceReact,
|
||||
})),
|
||||
);
|
||||
|
||||
type RestPlaygroundProps = {
|
||||
onError(): void;
|
||||
schema: PlaygroundSchemas;
|
||||
};
|
||||
|
||||
export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||
const theme = useTheme();
|
||||
const playgroundApiKey = useRecoilValue(playgroundApiKeyState);
|
||||
|
||||
if (!playgroundApiKey) {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton width="100%" height="100%" />
|
||||
</SkeletonTheme>
|
||||
}
|
||||
>
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
spec: {
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/open-api/${schema}?token=${playgroundApiKey}`,
|
||||
},
|
||||
authentication: {
|
||||
http: {
|
||||
bearer: playgroundApiKey
|
||||
? { token: playgroundApiKey }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
|
||||
forceDarkModeState: theme.name === 'dark' ? 'dark' : 'light',
|
||||
hideClientButton: true,
|
||||
hideDarkModeToggle: true,
|
||||
pathRouting: {
|
||||
basePath: getSettingsPath(SettingsPath.RestPlayground, {
|
||||
schema,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Card } from 'twenty-ui';
|
||||
|
||||
import DarkCoverImage from '../assets/cover-dark.png';
|
||||
import LightCoverImage from '../assets/cover-light.png';
|
||||
|
||||
export const StyledSettingsApiPlaygroundCoverImage = styled(Card)`
|
||||
align-items: center;
|
||||
background-image: ${({ theme }) =>
|
||||
theme.name === 'light'
|
||||
? `url('${LightCoverImage.toString()}')`
|
||||
: `url('${DarkCoverImage.toString()}')`};
|
||||
background-size: cover;
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 153px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
@ -0,0 +1,57 @@
|
||||
import { GraphQLPlayground } from '@/settings/playground/components/GraphQLPlayground';
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
const PlaygroundApiKeySetterEffect = () => {
|
||||
const setPlaygroundApiKey = useSetRecoilState(playgroundApiKeyState);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaygroundApiKey('test-api-key-123');
|
||||
}, [setPlaygroundApiKey]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const meta: Meta<typeof GraphQLPlayground> = {
|
||||
title: 'Modules/Settings/Playground/GraphQLPlayground',
|
||||
component: GraphQLPlayground,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
I18nFrontDecorator,
|
||||
ComponentWithRouterDecorator,
|
||||
ComponentWithRecoilScopeDecorator,
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'GraphQLPlayground provides an interactive environment to test GraphQL queries with authentication.',
|
||||
},
|
||||
},
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof GraphQLPlayground>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onError: action('GraphQL Playground encountered unexpected error'),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<PlaygroundApiKeySetterEffect />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
|
||||
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { PlaygroundSetupForm } from '../PlaygroundSetupForm';
|
||||
|
||||
const meta: Meta<typeof PlaygroundSetupForm> = {
|
||||
title: 'Modules/Settings/Playground/PlaygroundSetupForm',
|
||||
component: PlaygroundSetupForm,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
RouterDecorator,
|
||||
I18nFrontDecorator,
|
||||
ComponentWithRecoilScopeDecorator,
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'A form component for setting up the API playground with API key, schema selection, and playground type.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PlaygroundSetupForm>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
@ -0,0 +1,132 @@
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { ComponentDecorator } from 'twenty-ui';
|
||||
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { RestPlayground } from '../RestPlayground';
|
||||
|
||||
const PlaygroundApiKeySetterEffect = () => {
|
||||
const setPlaygroundApiKey = useSetRecoilState(playgroundApiKeyState);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaygroundApiKey('test-api-key-123');
|
||||
}, [setPlaygroundApiKey]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const openApiSpec = {
|
||||
openapi: '3.1.1',
|
||||
info: {
|
||||
title: 'Twenty Api',
|
||||
description:
|
||||
'This is a **Twenty REST/API** playground based on the **OpenAPI 3.1 specification**.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3000',
|
||||
description: 'Local server',
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
'/companies': {
|
||||
get: {
|
||||
tags: ['Companies'],
|
||||
summary: 'List companies',
|
||||
operationId: 'listCompanies',
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
'List of companies': {
|
||||
value: [
|
||||
{ id: '1', name: 'Acme Corp' },
|
||||
{ id: '2', name: 'Globex Corporation' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof RestPlayground> = {
|
||||
title: 'Modules/Settings/Playground/RestPlayground',
|
||||
component: RestPlayground,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
I18nFrontDecorator,
|
||||
ComponentWithRecoilScopeDecorator,
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'RestPlayground provides an interactive environment to test REST API endpoints with authentication.',
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/open-api/*', () => {
|
||||
return HttpResponse.json(openApiSpec);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof RestPlayground>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onError: (...args) => {
|
||||
action('REST Playground encountered unexpected error')(...args);
|
||||
},
|
||||
schema: PlaygroundSchemas.CORE,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<PlaygroundApiKeySetterEffect />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const openAPIReferenceState = createState<any>({
|
||||
key: 'OpenAPIReference',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
import { localStorageEffect } from '~/utils/recoil-effects';
|
||||
|
||||
export const playgroundApiKeyState = atom<string | null>({
|
||||
key: 'playgroundApiKeyState',
|
||||
default: null,
|
||||
effects: [localStorageEffect()],
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
export enum PlaygroundSchemas {
|
||||
METADATA = 'metadata',
|
||||
CORE = 'core',
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export enum PlaygroundTypes {
|
||||
GRAPHQL = 'graphql',
|
||||
REST = 'rest',
|
||||
}
|
||||
@ -19,9 +19,11 @@ export enum SettingsPath {
|
||||
WorkspaceMembersPage = 'workspace-members',
|
||||
Workspace = 'workspace',
|
||||
Domain = 'domain',
|
||||
Developers = 'developers',
|
||||
DevelopersNewApiKey = 'developers/api-keys/new',
|
||||
DevelopersApiKeyDetail = 'developers/api-keys/:apiKeyId',
|
||||
APIs = 'api-keys',
|
||||
RestPlayground = 'playground/rest/:schema',
|
||||
GraphQLPlayground = 'playground/graphql/:schema',
|
||||
DevelopersNewApiKey = 'api-keys/new',
|
||||
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
|
||||
Integrations = 'integrations',
|
||||
IntegrationDatabase = 'integrations/:databaseKey',
|
||||
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbProps,
|
||||
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import '@scalar/api-reference-react/style.css';
|
||||
import { IconButton, IconX, useIsMobile } from 'twenty-ui';
|
||||
|
||||
type FullScreenContainerProps = {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
links: BreadcrumbProps['links'];
|
||||
exitFullScreen(): void;
|
||||
};
|
||||
|
||||
const StyledFullScreen = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
background: ${({ theme }) => theme.background.noisy};
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled.div`
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const FullScreenContainer = ({
|
||||
children,
|
||||
links,
|
||||
exitFullScreen,
|
||||
}: FullScreenContainerProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useLingui();
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
exitFullScreen();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledFullScreen>
|
||||
<PageHeader title={<Breadcrumb links={links} />}>
|
||||
<IconButton
|
||||
Icon={IconX}
|
||||
dataTestId="close-button"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
onClick={handleExitFullScreen}
|
||||
ariaLabel={t`Exit Full Screen`}
|
||||
/>
|
||||
</PageHeader>
|
||||
<StyledMainContainer>{children}</StyledMainContainer>
|
||||
</StyledFullScreen>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||
import styled from '@emotion/styled';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
|
||||
const meta: Meta<typeof FullScreenContainer> = {
|
||||
title: 'UI/Layout/FullScreenContainer',
|
||||
component: FullScreenContainer,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
I18nFrontDecorator,
|
||||
ComponentWithRouterDecorator,
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FullScreenContainer>;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: <StyledContainer>This is full-screen content</StyledContainer>,
|
||||
links: [
|
||||
{
|
||||
children: 'Layout',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
children: 'FullScreen',
|
||||
href: '/',
|
||||
},
|
||||
],
|
||||
exitFullScreen: () => {},
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
|
||||
export const useShowFullscreen = () => {
|
||||
const { isMatchingLocation } = useIsMatchingLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
if (
|
||||
isMatchingLocation('settings/' + SettingsPath.RestPlayground + '/*') ||
|
||||
isMatchingLocation('settings/' + SettingsPath.GraphQLPlayground)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isMatchingLocation]);
|
||||
};
|
||||
@ -8,6 +8,7 @@ import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
|
||||
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
|
||||
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
|
||||
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
||||
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
@ -69,6 +70,7 @@ export const DefaultLayout = () => {
|
||||
const theme = useTheme();
|
||||
const windowsWidth = useScreenSize().width;
|
||||
const showAuthModal = useShowAuthModal();
|
||||
const useShowFullScreen = useShowFullscreen();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -90,7 +92,7 @@ export const DefaultLayout = () => {
|
||||
<StyledPageContainer
|
||||
animate={{
|
||||
marginLeft:
|
||||
isSettingsPage && !isMobile
|
||||
isSettingsPage && !isMobile && !useShowFullScreen
|
||||
? (windowsWidth -
|
||||
(OBJECT_SETTINGS_WIDTH +
|
||||
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
|
||||
@ -102,7 +104,7 @@ export const DefaultLayout = () => {
|
||||
>
|
||||
{showAuthModal ? (
|
||||
<StyledAppNavigationDrawerMock />
|
||||
) : (
|
||||
) : useShowFullScreen ? null : (
|
||||
<StyledAppNavigationDrawer />
|
||||
)}
|
||||
{showAuthModal ? (
|
||||
|
||||
@ -1,15 +1,44 @@
|
||||
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
const ActivityRichTextEditor = lazy(() =>
|
||||
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
|
||||
default: module.ActivityRichTextEditor,
|
||||
})),
|
||||
);
|
||||
|
||||
const StyledShowPageActivityContainer = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(6)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSkeletonContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const LoadingSkeleton = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledSkeletonContainer>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={theme.border.radius.sm}
|
||||
>
|
||||
<Skeleton height={200} />
|
||||
</SkeletonTheme>
|
||||
</StyledSkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowPageActivityContainer = ({
|
||||
targetableObject,
|
||||
}: {
|
||||
@ -28,14 +57,16 @@ export const ShowPageActivityContainer = ({
|
||||
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
|
||||
>
|
||||
<StyledShowPageActivityContainer>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={
|
||||
targetableObject.targetObjectNameSingular as
|
||||
| CoreObjectNameSingular.Note
|
||||
| CoreObjectNameSingular.Task
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={
|
||||
targetableObject.targetObjectNameSingular as
|
||||
| CoreObjectNameSingular.Note
|
||||
| CoreObjectNameSingular.Task
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</StyledShowPageActivityContainer>
|
||||
</ScrollWrapper>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user