diff --git a/package.json b/package.json index b8d411fac..01f96bd4e 100644 --- a/package.json +++ b/package.json @@ -360,7 +360,8 @@ "graphql": "16.8.0", "type-fest": "4.10.1", "typescript": "5.3.3", - "prosemirror-model": "1.23.0" + "prosemirror-model": "1.23.0", + "yjs": "13.6.18" }, "version": "0.2.1", "nx": {}, diff --git a/packages/twenty-front/.storybook/main.ts b/packages/twenty-front/.storybook/main.ts index c320d9750..376b3f4b1 100644 --- a/packages/twenty-front/.storybook/main.ts +++ b/packages/twenty-front/.storybook/main.ts @@ -37,7 +37,7 @@ const config: StorybookConfig = { '@storybook/addon-docs', '@storybook/addon-essentials/docs', ], - } + }, }, addons: [ '@storybook/addon-links', diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 44e33f61c..c88a46e28 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -38,6 +38,7 @@ "@nivo/core": "^0.87.0", "@nivo/line": "^0.87.0", "@react-pdf/renderer": "^4.1.6", + "@scalar/api-reference-react": "^0.4.36", "@tiptap/core": "^2.10.4", "@tiptap/extension-document": "^2.10.4", "@tiptap/extension-hard-break": "^2.10.4", diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 270655a7b..0e72485df 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -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 = ({ /> } > + } /> + } /> } + path={`${SettingsPath.GraphQLPlayground}`} + element={} + /> + } /> { getSelectedIndexForSubItems(subItems); return ( - + { /> {subItems.map((subItem, subIndex) => ( { } return ( theme.border.color.light}; @@ -53,7 +55,9 @@ export const SettingsApiKeysTable = () => { ))} diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsReadDocumentationButton.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsReadDocumentationButton.tsx deleted file mode 100644 index 8fa1fe924..000000000 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsReadDocumentationButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useLingui } from '@lingui/react/macro'; -import { Button, IconBook2 } from 'twenty-ui'; - -export const SettingsReadDocumentationButton = () => { - const { t } = useLingui(); - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts index d11e6f7bf..b05fcbd12 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts +++ b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts @@ -145,7 +145,7 @@ export const useWebhookUpdateForm = ({ const deleteWebhook = async () => { await deleteOneWebhook(webhookId); - navigate(SettingsPath.Developers); + navigate(SettingsPath.Webhooks); }; useFindOneRecord({ diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index 1b03d7b3e..b03b16634 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -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], }, diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png new file mode 100644 index 000000000..069ca9d30 Binary files /dev/null and b/packages/twenty-front/src/modules/settings/playground/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png new file mode 100644 index 000000000..dadd89963 Binary files /dev/null and b/packages/twenty-front/src/modules/settings/playground/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx b/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx new file mode 100644 index 000000000..bd1a002fe --- /dev/null +++ b/packages/twenty-front/src/modules/settings/playground/components/GraphQLPlayground.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx new file mode 100644 index 000000000..7dda40470 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/playground/components/PlaygroundSetupForm.tsx @@ -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; + +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({ + 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 ( + + ( + { + onChange(newValue); + setPlaygroundApiKey(newValue); + }} + error={error?.message} + required + /> + )} + /> + ( + + )} + /> +