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:
oliver
2025-03-07 09:03:57 -08:00
committed by GitHub
parent d1518764a8
commit fc287dac78
55 changed files with 2915 additions and 163 deletions

View File

@ -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}

View File

@ -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

View File

@ -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>

View File

@ -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>
);
};

View File

@ -145,7 +145,7 @@ export const useWebhookUpdateForm = ({
const deleteWebhook = async () => {
await deleteOneWebhook(webhookId);
navigate(SettingsPath.Developers);
navigate(SettingsPath.Webhooks);
};
useFindOneRecord({

View File

@ -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

View File

@ -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}`,
})}
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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)};
`;

View File

@ -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 />
</>
),
],
};

View File

@ -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: {},
};

View File

@ -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 />
</>
),
],
};

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const openAPIReferenceState = createState<any>({
key: 'OpenAPIReference',
defaultValue: null,
});

View File

@ -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()],
});

View File

@ -0,0 +1,4 @@
export enum PlaygroundSchemas {
METADATA = 'metadata',
CORE = 'core',
}

View File

@ -0,0 +1,4 @@
export enum PlaygroundTypes {
GRAPHQL = 'graphql',
REST = 'rest',
}

View File

@ -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',

View File

@ -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>
);
};

View File

@ -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],
};

View File

@ -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]);
};

View File

@ -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 ? (

View File

@ -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>
) : (