Website changes docs playground (#10413)

From #10376 (extracting website related changes to deploy them
separately, later)

---------

Co-authored-by: oliver <8559757+oliverqx@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2025-06-10 15:01:24 +02:00
committed by GitHub
parent b74f6901b4
commit 02bd15d61f
18 changed files with 128 additions and 663 deletions

View File

@ -1,13 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
const GraphQlPlayground = dynamic(
() => import('@/app/_components/playground/graphql-playground'),
{ ssr: false },
);
const CoreGraphql = () => {
return <GraphQlPlayground subDoc={'core'} />;
};
export default CoreGraphql;

View File

@ -1,13 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
const GraphQlPlayground = dynamic(
() => import('@/app/_components/playground/graphql-playground'),
{ ssr: false },
);
const MetadataGraphql = () => {
return <GraphQlPlayground subDoc={'metadata'} />;
};
export default MetadataGraphql;

View File

@ -1,33 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Playground from '@/app/_components/playground/playground';
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
const RestApi = () => {
const [openApiJson, setOpenApiJson] = useState({});
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
const children = <RestApiWrapper openApiJson={openApiJson} />;
return (
<div style={{ width: '100vw' }}>
<Playground
children={children}
setOpenApiJson={setOpenApiJson}
subDoc="core"
/>
</div>
);
};
export default RestApi;

View File

@ -1,30 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import Playground from '@/app/_components/playground/playground';
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
const restApi = () => {
const [openApiJson, setOpenApiJson] = useState({});
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
const children = <RestApiWrapper openApiJson={openApiJson} />;
return (
<Playground
children={children}
setOpenApiJson={setOpenApiJson}
subDoc="metadata"
/>
);
};
export default restApi;

View File

@ -1,71 +0,0 @@
'use client';
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { explorerPlugin } from '@graphiql/plugin-explorer';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { GraphiQL } from 'graphiql';
import { SubDoc } from '@/app/_components/playground/token-form';
import Playground from './playground';
import 'graphiql/graphiql.css';
import '@graphiql/plugin-explorer/dist/style.css';
const StyledContainer = styled.div`
height: 100%;
`;
const SubDocToPath = {
core: 'graphql',
metadata: 'metadata',
};
const GraphQlComponent = ({ token, baseUrl, path }: any) => {
const explorer = explorerPlugin({
showAttribution: true,
});
const fetcher = createGraphiQLFetcher({
url: baseUrl + '/' + path,
});
if (!baseUrl || !token) {
return <></>;
}
return (
<StyledContainer>
<GraphiQL
plugins={[explorer]}
fetcher={fetcher}
defaultHeaders={JSON.stringify({ Authorization: `Bearer ${token}` })}
/>
</StyledContainer>
);
};
const GraphQlPlayground = ({ subDoc }: { subDoc: SubDoc }) => {
const [token, setToken] = useState<string>();
const [baseUrl, setBaseUrl] = useState<string>();
const children = (
<GraphQlComponent
token={token}
baseUrl={baseUrl}
path={SubDocToPath[subDoc]}
/>
);
return (
<div style={{ height: '100vh', width: '100vw' }}>
<Playground
children={children}
setToken={setToken}
setBaseUrl={setBaseUrl}
subDoc={subDoc}
/>
</div>
);
};
export default GraphQlPlayground;

View File

@ -1,80 +0,0 @@
'use client';
import React, { useState } from 'react';
import { TbLoader2 } from 'react-icons/tb';
import TokenForm, { TokenFormProps } from './token-form';
const Playground = ({
children,
setOpenApiJson,
setToken,
setBaseUrl,
subDoc,
}: Partial<React.PropsWithChildren> &
Omit<
TokenFormProps,
'isTokenValid' | 'setIsTokenValid' | 'setLoadingState'
>) => {
const [isTokenValid, setIsTokenValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
return (
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
paddingTop: 15,
}}
>
<TokenForm
setOpenApiJson={setOpenApiJson}
setToken={setToken}
setBaseUrl={setBaseUrl}
isTokenValid={isTokenValid}
setIsTokenValid={setIsTokenValid}
subDoc={subDoc}
setLoadingState={setIsLoading}
/>
{!isTokenValid && (
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
display: 'flex',
flexFlow: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
background: 'rgba(23,23,23, 0.2)',
}}
>
<div
style={{
width: '50%',
background: 'rgba(23,23,23, 0.8)',
color: 'white',
padding: '16px',
borderRadius: '8px',
}}
>
A token is required as APIs are dynamically generated for each
workspace based on their unique metadata. <br /> Generate your token
under{' '}
<a className="link" href="https://app.twenty.com/settings/apis">
Settings &gt; APIs
</a>
</div>
{isLoading && (
<div className="loader-container">
<TbLoader2 className="loader" />
</div>
)}
</div>
)}
{children}
</div>
);
};
export default Playground;

View File

@ -1,33 +0,0 @@
import React, { useEffect } from 'react';
// @ts-expect-error Migration loader as text not passing warnings
import { API } from '@stoplight/elements';
// @ts-expect-error Migration loader as text not passing warnings
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
export const RestApiWrapper = ({ openApiJson }: { openApiJson: any }) => {
// We load spotlightTheme style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = spotlightTheme.toString();
document.head.append(styleElement);
return () => styleElement.remove();
}, []);
return (
<div
style={{
height: 'calc(100vh - var(--ifm-navbar-height) - 45px)',
width: '100%',
overflow: 'auto',
}}
>
<API
apiDescriptionDocument={JSON.stringify(openApiJson)}
hideSchemas={true}
router="hash"
/>
</div>
);
};

View File

@ -1,146 +0,0 @@
.form-container {
height: 45px;
overflow: hidden;
border-bottom: 1px solid var(--ifm-color-secondary-light);
position: sticky;
top: var(--ifm-navbar-height) + 10;
padding: 0px 8px;
background: var(--ifm-color-secondary-contrast-background);
z-index: 2;
display: flex;
}
.form {
display: flex;
height: 45px;
gap: 10px;
width: 50%;
margin-left: auto;
flex: 0.7;
}
.link {
color: white;
text-decoration: underline;
position: relative;
font-weight: bold;
transition: color 0.3s ease;
&:hover {
color: #ddd;
}
}
.input {
padding: 6px;
margin: 5px 0 5px 0;
max-width: 460px;
width: 100%;
box-sizing: border-box;
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
padding-left:30px;
height: 32px;
}
.input[disabled] {
color: rgb(153, 153, 153)
}
[data-theme='dark'] .input {
background-color: #16233f;
}
.inputWrapper {
display: flex;
align-items: center;
flex: 1;
position: relative;
}
.inputIcon {
display: flex;
align-items: center;
position: absolute;
top: 0;
height: 100%;
padding: 5px;
color: #B3B3B3;
}
[data-theme='dark'] .inputIcon {
color: white;
}
.select {
padding: 6px;
margin: 5px 0 5px 0;
max-width: 460px;
width: 100%;
box-sizing: border-box;
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
flex: 1;
}
[data-theme='dark'] .select {
background-color: #16233f;
}
.invalid {
border: 1px solid #f83e3e;
}
.token-invalid {
color: #f83e3e;
font-size: 12px;
}
.not-visible {
visibility: hidden;
}
.loader {
color: #16233f;
font-size: 2rem;
animation: animate 2s infinite;
}
[data-theme='dark'] .loader {
color: #a3c0f8;
}
@keyframes animate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(720deg);
}
}
.loader-container {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
}
.backButton {
position: absolute;
display: flex;
left: 8px;
height: 100%;
align-items: center;
cursor: pointer;
color: #999999;
&:hover {
color: #16233f;
}
}

View File

@ -1,212 +0,0 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { TbApi, TbChevronLeft, TbLink } from 'react-icons/tb';
// @ts-expect-error Migration loader as text not passing warnings
import tokenForm from '!css-loader!./token-form.css';
export type SubDoc = 'core' | 'metadata';
export type TokenFormProps = {
setOpenApiJson?: (json: object) => void;
setToken?: (token: string) => void;
setBaseUrl?: (baseUrl: string) => void;
isTokenValid?: boolean;
setIsTokenValid?: (arg: boolean) => void;
setLoadingState?: (arg: boolean) => void;
subDoc?: SubDoc;
};
const TokenForm = ({
setOpenApiJson,
setToken,
setBaseUrl: submitBaseUrl,
isTokenValid,
setIsTokenValid,
subDoc,
setLoadingState,
}: TokenFormProps) => {
const router = useRouter();
const pathname = usePathname();
const [isLoading, setIsLoading] = useState(false);
const [locationSetting, setLocationSetting] = useState(
(typeof window !== 'undefined' &&
window.localStorage.getItem('baseUrl') &&
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')
?.locationSetting) ??
'production',
);
const [baseUrl, setBaseUrl] = useState(
(typeof window !== 'undefined' &&
window.localStorage.getItem('baseUrl') &&
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')?.baseUrl) ??
'https://api.twenty.com',
);
const tokenLocal = (
typeof window !== 'undefined'
? window?.localStorage?.getItem?.('TryIt_securitySchemeValues')
: '{}'
) as string;
const token = JSON.parse(tokenLocal)?.bearerAuth ?? '';
const updateLoading = (loading = false) => {
setIsLoading(loading);
setLoadingState?.(!!loading);
};
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
window.localStorage.setItem(
'TryIt_securitySchemeValues',
JSON.stringify({ bearerAuth: event.target.value }),
);
await submitToken(event.target.value);
};
const updateBaseUrl = (baseUrl: string, locationSetting: string) => {
let url: string;
if (locationSetting === 'production') {
url = 'https://api.twenty.com';
} else if (locationSetting === 'next') {
url = 'https://api.twenty-next.com';
} else if (locationSetting === 'localhost') {
url = 'http://localhost:3000';
} else {
url = baseUrl;
}
setBaseUrl(url);
setLocationSetting(locationSetting);
submitBaseUrl?.(url);
window.localStorage.setItem(
'baseUrl',
JSON.stringify({ baseUrl: url, locationSetting }),
);
};
const validateToken = (openApiJson: any) => {
setIsTokenValid?.(!!openApiJson.tags);
};
const getJson = async (token: string) => {
updateLoading(true);
return await fetch(baseUrl + '/open-api/' + (subDoc ?? 'core'), {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((result) => {
validateToken(result);
updateLoading(false);
return result;
})
.catch(() => {
updateLoading(false);
setIsTokenValid?.(false);
});
};
const submitToken = async (token: any) => {
if (isLoading) return;
const json = await getJson(token);
setToken && setToken(token);
setOpenApiJson && setOpenApiJson(json);
};
useEffect(() => {
(async () => {
updateBaseUrl(baseUrl, locationSetting);
await submitToken(token);
})();
}, []);
// We load playground style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = tokenForm.toString();
document.head.append(styleElement);
return () => styleElement.remove();
}, []);
return (
<div className="form-container">
<form className="form">
<div className="backButton" onClick={() => router.back()}>
<TbChevronLeft size={18} />
<span>Back</span>
</div>
<div className="inputWrapper">
<select
className="select"
onChange={(event) => {
updateBaseUrl(baseUrl, event.target.value);
}}
value={locationSetting}
>
<option value="production">Production API</option>
<option value="demo">Demo API</option>
<option value="localhost">Localhost</option>
<option value="other">Other</option>
</select>
</div>
<div className="inputWrapper">
<div className="inputIcon" title="Base URL">
<TbLink size={20} />
</div>
<input
className={'input'}
type="text"
readOnly={isLoading}
disabled={locationSetting !== 'other'}
placeholder="Base URL"
value={baseUrl}
onChange={(event) =>
updateBaseUrl(event.target.value, locationSetting)
}
onBlur={() => submitToken(token)}
/>
</div>
<div className="inputWrapper">
<div className="inputIcon" title="Api Key">
<TbApi size={20} />
</div>
<input
className={!isTokenValid && !isLoading ? 'input invalid' : 'input'}
type="text"
readOnly={isLoading}
placeholder="API Key"
defaultValue={token}
onChange={updateToken}
/>
</div>
<div className="inputWrapper" style={{ maxWidth: '100px' }}>
<select
className="select"
onChange={(event) =>
router.replace(
pathname.split('/').slice(0, -1).join('/') +
'/' +
event.target.value,
)
}
value={pathname.split('/').at(-1)}
>
<option value="core">Core</option>
<option value="metadata">Metadata</option>
</select>
</div>
</form>
</div>
);
};
export default TokenForm;