mirror of
https://github.com/twentyhq/twenty.git
synced 2024-09-11 23:37:21 +03:00
Migrated Developer Docs (#5683)
- Migrated developer docs to Twenty website - Modified User Guide and Docs layout to include sections and subsections **Section Example:** <img width="549" alt="Screenshot 2024-05-30 at 15 44 42" src="https://github.com/twentyhq/twenty/assets/102751374/41bd4037-4b76-48e6-bc79-48d3d6be9ab8"> **Subsection Example:** <img width="557" alt="Screenshot 2024-05-30 at 15 44 55" src="https://github.com/twentyhq/twenty/assets/102751374/f14c65a9-ab0c-4530-b624-5b20fc00511a"> - Created different components (Tabs, Tables, Editors etc.) for the mdx files **Tabs & Editor** <img width="665" alt="Screenshot 2024-05-30 at 15 47 39" src="https://github.com/twentyhq/twenty/assets/102751374/5166b5c7-b6cf-417d-9f29-b1f674c1c531"> **Tables** <img width="698" alt="Screenshot 2024-05-30 at 15 57 39" src="https://github.com/twentyhq/twenty/assets/102751374/2bbfe937-ec19-4004-ab00-f7a56e96db4a"> <img width="661" alt="Screenshot 2024-05-30 at 16 03 32" src="https://github.com/twentyhq/twenty/assets/102751374/ae95b47c-dd92-44f9-b535-ccdc953f71ff"> - Created a crawler for Twenty Developers (now that it will be on the twenty website). Once this PR is merged and the website is re-deployed, we need to start crawling and make sure the index name is ‘twenty-developer’ - Added a dropdown menu in the header to access User Guide and Developers + added Developers to footer https://github.com/twentyhq/twenty/assets/102751374/1bd1fbbd-1e65-4461-b18b-84d4ddbb8ea1 - Made new layout responsive Please fill in the information for each mdx file so that it can appear on its card, as well as in the ‘In this article’ section. Example with ‘Getting Started’ in the User Guide: <img width="786" alt="Screenshot 2024-05-30 at 16 29 39" src="https://github.com/twentyhq/twenty/assets/102751374/2714b01d-a664-4ddc-9291-528632ee12ea"> Example with info and sectionInfo filled in for 'Getting Started': <img width="620" alt="Screenshot 2024-05-30 at 16 33 57" src="https://github.com/twentyhq/twenty/assets/102751374/bc69e880-da6a-4b7e-bace-1effea866c11"> Please keep in mind that the images that are being used for Developers are the same as those found in User Guide and may not match the article. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
parent
f7cdd14c75
commit
671de4170f
@ -29,8 +29,7 @@ const TokenForm = ({
|
||||
const location = useLocation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [locationSetting, setLocationSetting] = useState(
|
||||
parseJson(localStorage.getItem('baseUrl'))?.locationSetting ??
|
||||
'production',
|
||||
parseJson(localStorage.getItem('baseUrl'))?.locationSetting ?? 'production',
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
parseJson(localStorage.getItem('baseUrl'))?.baseUrl ??
|
||||
@ -64,13 +63,16 @@ const TokenForm = ({
|
||||
} else {
|
||||
url = baseUrl?.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl
|
||||
: baseUrl;
|
||||
}
|
||||
|
||||
setBaseUrl(url);
|
||||
setLocationSetting(locationSetting);
|
||||
submitBaseUrl?.(url);
|
||||
localStorage.setItem('baseUrl', JSON.stringify({ baseUrl: url, locationSetting }));
|
||||
localStorage.setItem(
|
||||
'baseUrl',
|
||||
JSON.stringify({ baseUrl: url, locationSetting }),
|
||||
);
|
||||
};
|
||||
|
||||
const validateToken = (openApiJson) => {
|
||||
@ -133,7 +135,7 @@ const TokenForm = ({
|
||||
<select
|
||||
className="select"
|
||||
onChange={(event) => {
|
||||
updateBaseUrl(baseUrl, event.target.value)
|
||||
updateBaseUrl(baseUrl, event.target.value);
|
||||
}}
|
||||
value={locationSetting}
|
||||
>
|
||||
@ -154,7 +156,9 @@ const TokenForm = ({
|
||||
disabled={locationSetting !== 'other'}
|
||||
placeholder="Base URL"
|
||||
value={baseUrl}
|
||||
onChange={(event) => updateBaseUrl(event.target.value, locationSetting)}
|
||||
onChange={(event) =>
|
||||
updateBaseUrl(event.target.value, locationSetting)
|
||||
}
|
||||
onBlur={() => submitToken(token)}
|
||||
/>
|
||||
</div>
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 333 KiB |
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 4.8 KiB |
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import MotionContainer from '@/app/_components/ui/layout/LoaderAnimation';
|
||||
@ -36,12 +37,6 @@ const AvatarItem = styled.div`
|
||||
box-shadow: -6px 6px 0px 1px rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.username {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -74,7 +69,12 @@ const AvatarGrid = ({ users }: { users: User[] }) => {
|
||||
{users.map((user) => (
|
||||
<Link href={`/contributors/${user.id}`} key={`l_${user.id}`}>
|
||||
<AvatarItem key={user.id}>
|
||||
<img src={user.avatarUrl} alt={user.id} />
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt={user.id}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<span className="username">{user.id}</span>
|
||||
</AvatarItem>
|
||||
</Link>
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { format } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { GithubIcon } from '@/app/_components/ui/icons/SvgIcons';
|
||||
|
||||
@ -89,7 +90,7 @@ export const ProfileCard = ({
|
||||
return (
|
||||
<ProfileContainer>
|
||||
<Avatar>
|
||||
<img src={avatarUrl} alt={username} />
|
||||
<Image src={avatarUrl} alt={username} width={100} height={100} />
|
||||
</Avatar>
|
||||
<Details>
|
||||
<h3 className="username">
|
||||
|
@ -7,7 +7,14 @@ interface AlgoliaHit extends StoredDocSearchHit {
|
||||
};
|
||||
}
|
||||
|
||||
export const AlgoliaDocSearch = () => {
|
||||
interface AlgoliaDocSearchProps {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export const AlgoliaDocSearch = ({ pathname }: AlgoliaDocSearchProps) => {
|
||||
const indexName = pathname.includes('user-guide')
|
||||
? 'user-guide'
|
||||
: 'developer';
|
||||
return (
|
||||
<DocSearch
|
||||
hitComponent={({ hit }: { hit: AlgoliaHit }) => (
|
||||
@ -42,7 +49,7 @@ export const AlgoliaDocSearch = () => {
|
||||
)}
|
||||
appId={process.env.NEXT_PUBLIC_ALGOLIA_APP_ID as string}
|
||||
apiKey={process.env.NEXT_PUBLIC_ALGOLIA_API_KEY as string}
|
||||
indexName="twenty-user-guide"
|
||||
indexName={`twenty-${indexName}`}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { getCardPath } from '@/shared-utils/getCardPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
color: ${Theme.border.color.plain};
|
||||
@ -46,23 +47,28 @@ const StyledSubHeading = styled.div`
|
||||
const StyledImage = styled.img`
|
||||
border-bottom: 1.5px solid #14141429;
|
||||
height: 160px;
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 6px;
|
||||
`;
|
||||
|
||||
export default function UserGuideCard({
|
||||
export default function DocsCard({
|
||||
card,
|
||||
isSection = false,
|
||||
}: {
|
||||
card: UserGuideArticlesProps;
|
||||
card: DocsArticlesProps;
|
||||
isSection?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const path = getCardPath(card, pathname, isSection);
|
||||
|
||||
if (card.title) {
|
||||
return (
|
||||
<StyledContainer
|
||||
onClick={() => router.push(`/user-guide/${card.fileName}`)}
|
||||
>
|
||||
<StyledContainer onClick={() => router.push(path)}>
|
||||
<StyledImage src={card.image} alt={card.title} />
|
||||
<StyledHeading>{card.title}</StyledHeading>
|
||||
<StyledSubHeading>{card.info}</StyledSubHeading>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ArticleContent } from '@/app/_components/ui/layout/articles/ArticleContent';
|
||||
import { Breadcrumbs } from '@/app/_components/ui/layout/Breadcrumbs';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { FileContent } from '@/app/_server-utils/get-posts';
|
||||
import { getUriAndLabel } from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled('div')`
|
||||
${mq({
|
||||
@ -27,6 +29,7 @@ const StyledContainer = styled('div')`
|
||||
const StyledWrapper = styled.div`
|
||||
@media (max-width: 450px) {
|
||||
padding: ${Theme.spacing(10)} 30px ${Theme.spacing(20)};
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
@media (min-width: 451px) and (max-width: 800px) {
|
||||
@ -112,11 +115,14 @@ const StyledImageContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function UserGuideContent({ item }: { item: FileContent }) {
|
||||
export default function DocsContent({ item }: { item: FileContent }) {
|
||||
const pathname = usePathname();
|
||||
const { uri, label } = getUriAndLabel(pathname);
|
||||
|
||||
const BREADCRUMB_ITEMS = [
|
||||
{
|
||||
uri: '/user-guide',
|
||||
label: 'User Guide',
|
||||
uri: uri,
|
||||
label: label,
|
||||
},
|
||||
];
|
||||
|
@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import DocsCard from '@/app/_components/docs/DocsCard';
|
||||
import { Breadcrumbs } from '@/app/_components/ui/layout/Breadcrumbs';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import UserGuideCard from '@/app/_components/user-guide/UserGuideCard';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { constructSections } from '@/shared-utils/constructSections';
|
||||
import { filterDocsIndex } from '@/shared-utils/filterDocsIndex';
|
||||
import { getUriAndLabel } from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
@ -26,12 +31,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
@media (max-width: 450px) {
|
||||
padding: ${Theme.spacing(10)} 30px ${Theme.spacing(20)};
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
padding: ${Theme.spacing(10)} 50px ${Theme.spacing(20)};
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
@ -45,12 +52,25 @@ const StyledTitle = styled.div`
|
||||
font-size: ${Theme.font.size.sm};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
width: 340px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSection = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@media (min-width: 801px) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -63,6 +83,10 @@ const StyledHeader = styled.div`
|
||||
width: 340px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
margin-bottom: 24px;
|
||||
width: 340px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.h1`
|
||||
@ -71,6 +95,7 @@ const StyledHeading = styled.h1`
|
||||
font-size: 40px;
|
||||
color: ${Theme.text.color.primary};
|
||||
margin: 0px;
|
||||
margin-top: 32px;
|
||||
@media (max-width: 800px) {
|
||||
font-size: 28px;
|
||||
}
|
||||
@ -102,28 +127,60 @@ const StyledContent = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
interface UserGuideProps {
|
||||
userGuideArticleCards: UserGuideArticlesProps[];
|
||||
interface DocsProps {
|
||||
docsArticleCards: DocsArticlesProps[];
|
||||
isSection?: boolean;
|
||||
}
|
||||
|
||||
export default function UserGuideMain({
|
||||
userGuideArticleCards,
|
||||
}: UserGuideProps) {
|
||||
export default function DocsMain({
|
||||
docsArticleCards,
|
||||
isSection = false,
|
||||
}: DocsProps) {
|
||||
const sections = constructSections(docsArticleCards, isSection);
|
||||
const pathname = usePathname();
|
||||
const { uri, label } = getUriAndLabel(pathname);
|
||||
|
||||
const BREADCRUMB_ITEMS = [
|
||||
{
|
||||
uri: uri,
|
||||
label: label,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledWrapper>
|
||||
<StyledTitle>User Guide</StyledTitle>
|
||||
{isSection ? (
|
||||
<Breadcrumbs
|
||||
items={BREADCRUMB_ITEMS}
|
||||
activePage={sections[0].name}
|
||||
separator="/"
|
||||
/>
|
||||
) : (
|
||||
<StyledTitle>{label}</StyledTitle>
|
||||
)}
|
||||
{sections.map((section, index) => {
|
||||
const filteredArticles = isSection
|
||||
? docsArticleCards
|
||||
: filterDocsIndex(docsArticleCards, section.name);
|
||||
return (
|
||||
<StyledSection key={index}>
|
||||
<StyledHeader>
|
||||
<StyledHeading>User Guide</StyledHeading>
|
||||
<StyledSubHeading>
|
||||
A brief guide to grasp the basics of Twenty
|
||||
</StyledSubHeading>
|
||||
<StyledHeading>{section.name}</StyledHeading>
|
||||
<StyledSubHeading>{section.info}</StyledSubHeading>
|
||||
</StyledHeader>
|
||||
<StyledContent>
|
||||
{userGuideArticleCards.map((card) => {
|
||||
return <UserGuideCard key={card.title} card={card} />;
|
||||
})}
|
||||
{filteredArticles.map((card) => (
|
||||
<DocsCard
|
||||
key={card.title}
|
||||
card={card}
|
||||
isSection={isSection}
|
||||
/>
|
||||
))}
|
||||
</StyledContent>
|
||||
</StyledSection>
|
||||
);
|
||||
})}
|
||||
</StyledWrapper>
|
||||
</StyledContainer>
|
||||
);
|
@ -3,11 +3,15 @@ import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import DocsSidebar from '@/app/_components/docs/DocsSideBar';
|
||||
import DocsTableContents from '@/app/_components/docs/TableContent';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import UserGuideTableContents from '@/app/_components/user-guide/TableContent';
|
||||
import UserGuideSidebar from '@/app/_components/user-guide/UserGuideSidebar';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import {
|
||||
isPlaygroundPage,
|
||||
shouldShowEmptySidebar,
|
||||
} from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
@ -24,22 +28,23 @@ const StyledEmptySideBar = styled.div`
|
||||
})};
|
||||
`;
|
||||
|
||||
export const UserGuideMainLayout = ({
|
||||
export const DocsMainLayout = ({
|
||||
children,
|
||||
userGuideIndex,
|
||||
docsIndex,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
docsIndex: DocsArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<UserGuideSidebar userGuideIndex={userGuideIndex} />
|
||||
{!isPlaygroundPage(pathname) && <DocsSidebar docsIndex={docsIndex} />}
|
||||
{children}
|
||||
{pathname === '/user-guide' ? (
|
||||
{shouldShowEmptySidebar(pathname) ? (
|
||||
<StyledEmptySideBar />
|
||||
) : (
|
||||
<UserGuideTableContents />
|
||||
<DocsTableContents />
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
118
packages/twenty-website/src/app/_components/docs/DocsSideBar.tsx
Normal file
118
packages/twenty-website/src/app/_components/docs/DocsSideBar.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { AlgoliaDocSearch } from '@/app/_components/docs/AlgoliaDocSearch';
|
||||
import DocsSidebarSection from '@/app/_components/docs/DocsSidebarSection';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { getSectionIcon } from '@/shared-utils/getSectionIcons';
|
||||
|
||||
import '@docsearch/css';
|
||||
import '../../user-guide/algolia.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
display: ['none', 'flex', 'flex'],
|
||||
flexDirection: 'column',
|
||||
background: `${Theme.background.secondary}`,
|
||||
borderRight: `1px solid ${Theme.background.transparent.medium}`,
|
||||
borderBottom: `1px solid ${Theme.background.transparent.medium}`,
|
||||
padding: `${Theme.spacing(10)} ${Theme.spacing(4)}`,
|
||||
gap: `${Theme.spacing(6)}`,
|
||||
})}
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
overflow: scroll;
|
||||
height: calc(100vh - 60px);
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
padding: ${Theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledHeadingText = styled.h1`
|
||||
cursor: pointer;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
`;
|
||||
|
||||
const DocsSidebar = ({ docsIndex }: { docsIndex: DocsArticlesProps[] }) => {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const path = pathName.includes('user-guide')
|
||||
? '/user-guide'
|
||||
: pathName.includes('developers')
|
||||
? '/developers'
|
||||
: '/twenty-ui';
|
||||
|
||||
const sections = Array.from(
|
||||
new Set(docsIndex.map((guide) => guide.section)),
|
||||
).map((section) => ({
|
||||
name: section,
|
||||
icon: getSectionIcon(section),
|
||||
guides: docsIndex.filter((guide) => {
|
||||
const isInSection = guide.section === section;
|
||||
const hasFiles = guide.numberOfFiles > 0;
|
||||
const isNotSingleFileTopic = !(
|
||||
guide.numberOfFiles > 1 && guide.topic === guide.title
|
||||
);
|
||||
|
||||
return isInSection && hasFiles && isNotSingleFileTopic;
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<AlgoliaDocSearch pathname={pathName} />
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<StyledHeading>
|
||||
<StyledIconContainer>{section.icon}</StyledIconContainer>
|
||||
<StyledHeadingText
|
||||
onClick={() =>
|
||||
router.push(
|
||||
section.name === 'User Guide'
|
||||
? '/user-guide'
|
||||
: section.name === 'Developers'
|
||||
? '/developers'
|
||||
: path,
|
||||
)
|
||||
}
|
||||
>
|
||||
{section.name}
|
||||
</StyledHeadingText>
|
||||
</StyledHeading>
|
||||
<DocsSidebarSection docsIndex={section.guides} />
|
||||
</div>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsSidebar;
|
@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconPoint } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { IconChevronDown, IconChevronRight } from '@/app/_components/ui/icons';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { groupArticlesByTopic } from '@/content/user-guide/constants/groupArticlesByTopic';
|
||||
import { getCardPath } from '@/shared-utils/getCardPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledIndex = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
margin-top: 8px;
|
||||
padding-bottom: ${Theme.spacing(2)};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledSubTopicItem = styled.a<{ isselected: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: ${Theme.spacing(8)};
|
||||
color: ${(props) =>
|
||||
props.isselected ? Theme.text.color.primary : Theme.text.color.secondary};
|
||||
font-weight: ${(props) =>
|
||||
props.isselected ? Theme.font.weight.medium : Theme.font.weight.regular};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
gap: 19px;
|
||||
padding: ${(props) =>
|
||||
props.isselected ? '6px 12px 6px 11px' : '0px 12px 0px 11px'};
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.background.transparent.light
|
||||
: Theme.background.secondary};
|
||||
border-radius: ${Theme.border.radius.md};
|
||||
text-decoration: none;
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:visited,
|
||||
&:link,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1414140a;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #1414140f;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
padding: 0px 4px 0px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
margin-top: 3px;
|
||||
margin-left: -8px;
|
||||
color: ${Theme.color.gray30};
|
||||
`;
|
||||
|
||||
const StyledCardTitle = styled.p`
|
||||
margin: 0px -5px;
|
||||
color: ${Theme.color.gray30};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledRectangle = styled.div<{ isselected: boolean; isHovered: boolean }>`
|
||||
height: ${(props) =>
|
||||
props.isselected ? '95%' : props.isHovered ? '70%' : '100%'};
|
||||
width: 2px;
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.border.color.plain
|
||||
: props.isHovered
|
||||
? Theme.background.transparent.strong
|
||||
: Theme.background.transparent.light};
|
||||
transition: height 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface TopicsState {
|
||||
[topic: string]: boolean;
|
||||
}
|
||||
|
||||
const DocsSidebarSection = ({
|
||||
docsIndex,
|
||||
}: {
|
||||
docsIndex: DocsArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const topics = groupArticlesByTopic(docsIndex);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const path = pathname.includes('user-guide')
|
||||
? '/user-guide/'
|
||||
: pathname.includes('developers')
|
||||
? '/developers/'
|
||||
: '/twenty-ui';
|
||||
|
||||
const initializeUnfoldedState = () => {
|
||||
const unfoldedState: TopicsState = {};
|
||||
Object.keys(topics).forEach((topic) => {
|
||||
const containsCurrentArticle = topics[topic].some((card) => {
|
||||
const topicPath = card.topic.toLowerCase().replace(/\s+/g, '-');
|
||||
return pathname.includes(topicPath);
|
||||
});
|
||||
unfoldedState[topic] = containsCurrentArticle;
|
||||
});
|
||||
return unfoldedState;
|
||||
};
|
||||
|
||||
const [unfolded, setUnfolded] = useState<TopicsState>(
|
||||
initializeUnfoldedState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newUnfoldedState = initializeUnfoldedState();
|
||||
setUnfolded(newUnfoldedState);
|
||||
}, [pathname]);
|
||||
|
||||
const toggleFold = (topic: string) => {
|
||||
setUnfolded((prev: TopicsState) => ({ ...prev, [topic]: !prev[topic] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{Object.entries(topics).map(([topic, cards]) => {
|
||||
const hasMultipleFiles = cards.some((card) => card.numberOfFiles > 1);
|
||||
|
||||
return (
|
||||
<StyledIndex key={topic}>
|
||||
{hasMultipleFiles ? (
|
||||
<StyledTitle onClick={() => toggleFold(topic)}>
|
||||
{unfolded[topic] ? (
|
||||
<StyledIcon>
|
||||
<IconChevronDown size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon>
|
||||
<IconChevronRight size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<div>{topic}</div>
|
||||
</StyledTitle>
|
||||
) : null}
|
||||
|
||||
{(unfolded[topic] || !hasMultipleFiles) &&
|
||||
cards.map((card) => {
|
||||
const isselected = pathname === `${path}${card.fileName}`;
|
||||
const sectionName = card.topic
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
const routerPath = getCardPath(card, path, false, sectionName);
|
||||
return (
|
||||
<StyledSubTopicItem
|
||||
key={card.title}
|
||||
isselected={isselected}
|
||||
href={routerPath}
|
||||
onClick={() => router.push(routerPath)}
|
||||
onMouseEnter={() => setHoveredItem(card.title)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
{card.numberOfFiles > 1 ? (
|
||||
<>
|
||||
<StyledRectangle
|
||||
isselected={isselected}
|
||||
isHovered={hoveredItem === card.title}
|
||||
/>
|
||||
{card.title}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StyledIconContainer>
|
||||
<IconPoint size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledCardTitle>{card.title}</StyledCardTitle>
|
||||
</>
|
||||
)}
|
||||
</StyledSubTopicItem>
|
||||
);
|
||||
})}
|
||||
</StyledIndex>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsSidebarSection;
|
@ -88,7 +88,7 @@ interface HeadingType {
|
||||
level: number;
|
||||
}
|
||||
|
||||
const UserGuideTableContents = () => {
|
||||
const DocsTableContents = () => {
|
||||
const [headings, setHeadings] = useState<HeadingType[]>([]);
|
||||
const pathname = usePathname();
|
||||
const { activeText } = useHeadsObserver(pathname);
|
||||
@ -146,4 +146,4 @@ const UserGuideTableContents = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideTableContents;
|
||||
export default DocsTableContents;
|
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { explorerPlugin } from '@graphiql/plugin-explorer';
|
||||
import { Theme, useTheme } from '@graphiql/react';
|
||||
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
||||
import { GraphiQL } from 'graphiql';
|
||||
|
||||
import { SubDoc } from '@/app/_components/playground/token-form';
|
||||
|
||||
import Playground from './playground';
|
||||
|
||||
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 (
|
||||
<div className="fullHeightPlayground">
|
||||
<GraphiQL
|
||||
plugins={[explorer]}
|
||||
fetcher={fetcher}
|
||||
defaultHeaders={JSON.stringify({ Authorization: `Bearer ${token}` })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GraphQlPlayground = ({ subDoc }: { subDoc: SubDoc }) => {
|
||||
const [token, setToken] = useState<string>();
|
||||
const [baseUrl, setBaseUrl] = useState<string>();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'graphiql:theme',
|
||||
window.localStorage.getItem('theme') || 'light',
|
||||
);
|
||||
|
||||
const handleThemeChange = (ev: any) => {
|
||||
if (ev.key === 'theme') {
|
||||
setTheme(ev.newValue as Theme);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleThemeChange);
|
||||
|
||||
return () => window.removeEventListener('storage', handleThemeChange);
|
||||
}, []);
|
||||
|
||||
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;
|
@ -0,0 +1,83 @@
|
||||
'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/developers"
|
||||
>
|
||||
Settings > Developers
|
||||
</a>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="loader-container">
|
||||
<TbLoader2 className="loader" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
@ -0,0 +1,146 @@
|
||||
.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;
|
||||
}
|
||||
}
|
@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TbApi, TbChevronLeft, TbLink } from 'react-icons/tb';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
// @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(
|
||||
(window &&
|
||||
window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')
|
||||
?.locationSetting) ??
|
||||
'production',
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
(window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')?.baseUrl) ??
|
||||
'https://api.twenty.com',
|
||||
);
|
||||
|
||||
const tokenLocal = 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 === 'demo') {
|
||||
url = 'https://api-demo.twenty.com';
|
||||
} else if (locationSetting === 'localhost') {
|
||||
url = 'http://localhost:3000';
|
||||
} else {
|
||||
url = baseUrl?.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: 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('/').at(-2) + '/' + 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;
|
@ -65,6 +65,7 @@ interface BreadcrumbsProps {
|
||||
}[];
|
||||
activePage: string;
|
||||
separator: string;
|
||||
style?: boolean;
|
||||
}
|
||||
|
||||
export const Breadcrumbs = ({
|
||||
|
@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import {
|
||||
DiscordIcon,
|
||||
@ -67,11 +66,6 @@ const RightSideFooterColumnTitle = styled.div`
|
||||
`;
|
||||
|
||||
export const FooterDesktop = () => {
|
||||
const path = usePathname();
|
||||
const isTwentyDev = path.includes('developers');
|
||||
|
||||
if (isTwentyDev) return;
|
||||
|
||||
return (
|
||||
<FooterContainer>
|
||||
<div
|
||||
@ -95,8 +89,8 @@ export const FooterDesktop = () => {
|
||||
</RightSideFooterColumn>
|
||||
<RightSideFooterColumn>
|
||||
<RightSideFooterColumnTitle>Resources</RightSideFooterColumnTitle>
|
||||
<RightSideFooterLink href="https://docs.twenty.com">
|
||||
Documentation
|
||||
<RightSideFooterLink href="/developers">
|
||||
Developers
|
||||
</RightSideFooterLink>
|
||||
<RightSideFooterLink href="/releases">
|
||||
Changelog
|
||||
|
@ -10,6 +10,16 @@ const StyledContent = styled.div`
|
||||
flex: 1;
|
||||
max-width: 950px;
|
||||
|
||||
code {
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${Theme.text.color.secondary};
|
||||
font-family: ${Theme.font.family};
|
||||
|
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 32px;
|
||||
`;
|
||||
|
||||
const StyledTable = styled.table`
|
||||
width: fit-content;
|
||||
margin-top: 32px;
|
||||
border-collapse: collapse;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.th`
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-collapse: collapse;
|
||||
background-color: #f1f1f1;
|
||||
`;
|
||||
|
||||
const StyledTableRow = styled.tr<{ isEven: boolean }>`
|
||||
background-color: ${(props) => (props.isEven ? '#f1f1f1' : 'transparent')};
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.td`
|
||||
border: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
padding: 20px 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledVariable = styled.td`
|
||||
border: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
interface ArticleTableProps {
|
||||
options: [string, string, string, string][];
|
||||
}
|
||||
|
||||
const OptionTable = ({ options }: ArticleTableProps) => {
|
||||
let display = true;
|
||||
if (!options[0][3]) {
|
||||
display = false;
|
||||
}
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<StyledTableHeader>Props</StyledTableHeader>
|
||||
<StyledTableHeader>Type</StyledTableHeader>
|
||||
<StyledTableHeader>Description</StyledTableHeader>
|
||||
{display ? <StyledTableHeader>Default</StyledTableHeader> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map(([props, type, description, defaultValue], index) => (
|
||||
<StyledTableRow key={index} isEven={index % 2 === 1}>
|
||||
<StyledVariable>{props}</StyledVariable>
|
||||
<StyledVariable>{type}</StyledVariable>
|
||||
<StyledDescription>{description}</StyledDescription>
|
||||
{display ? <StyledVariable>{defaultValue}</StyledVariable> : null}
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
OptionTable.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired,
|
||||
};
|
||||
|
||||
export default OptionTable;
|
@ -0,0 +1,5 @@
|
||||
const TabItem = ({ children }: any) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
export default TabItem;
|
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 32px;
|
||||
`;
|
||||
|
||||
const StyledTable = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.th`
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
padding: 10px 0px 10px 20px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledExample = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-right: 20px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledVariable = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-right: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #538ce9;
|
||||
`;
|
||||
|
||||
const StyledTableRow = styled.tr<{ isEven: boolean }>`
|
||||
background-color: ${(props) => (props.isEven ? '#f1f1f1' : 'transparent')};
|
||||
`;
|
||||
|
||||
interface ArticleTableProps {
|
||||
options: [string, string, string][];
|
||||
}
|
||||
|
||||
const OptionTable = ({ options }: ArticleTableProps) => {
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<StyledTableHeader>Variable</StyledTableHeader>
|
||||
<StyledTableHeader>Example</StyledTableHeader>
|
||||
<StyledTableHeader>Description</StyledTableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map(([variable, defaultValue, description], index) => (
|
||||
<StyledTableRow key={index} isEven={index % 2 === 1}>
|
||||
<StyledVariable>{variable}</StyledVariable>
|
||||
<StyledExample>{defaultValue}</StyledExample>
|
||||
<StyledDescription>{description}</StyledDescription>
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
OptionTable.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired,
|
||||
};
|
||||
|
||||
export default OptionTable;
|
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
width: 80%;
|
||||
overflow: none;
|
||||
`;
|
||||
|
||||
const StyledTab = styled.div<{ active: boolean }>`
|
||||
padding: 10px 20px;
|
||||
border-bottom: 2px solid ${(props) => (props.active ? '#000' : 'transparent')};
|
||||
font-weight: ${(props) => (props.active ? 'bold' : 'normal')};
|
||||
`;
|
||||
|
||||
interface ArticleTabsProps {
|
||||
children: any;
|
||||
label1: string;
|
||||
label2: string;
|
||||
label3?: string;
|
||||
}
|
||||
|
||||
const Tabs = ({ children, label1, label2, label3 }: ArticleTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const labels = label3 ? [label1, label2, label3] : [label1, label2];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledContainer>
|
||||
{labels.map((label, index) => {
|
||||
return (
|
||||
<StyledTab
|
||||
onClick={() => setActiveTab(index)}
|
||||
key={label}
|
||||
active={activeTab === index}
|
||||
>
|
||||
{label}
|
||||
</StyledTab>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
<div>{children[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
import {
|
||||
SandpackCodeEditor,
|
||||
SandpackLayout,
|
||||
SandpackProvider,
|
||||
} from '@codesandbox/sandpack-react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Sandpack = styled.div`
|
||||
max-width: 600px;
|
||||
`;
|
||||
|
||||
const SandpackContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
interface SandpackEditorProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function SandpackEditor({ content }: SandpackEditorProps) {
|
||||
return (
|
||||
<Sandpack>
|
||||
<SandpackProvider
|
||||
template="react"
|
||||
files={{
|
||||
'/App.js': `${content}`,
|
||||
}}
|
||||
>
|
||||
<SandpackLayout>
|
||||
<SandpackContainer>
|
||||
<SandpackCodeEditor showTabs showInlineErrors wrapContent />
|
||||
</SandpackContainer>
|
||||
</SandpackLayout>
|
||||
</SandpackProvider>
|
||||
</Sandpack>
|
||||
);
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconBook, IconChevronDown, IconRobotFace } from '@tabler/icons-react';
|
||||
|
||||
import { ExternalArrow, GithubIcon } from '@/app/_components/ui/icons/SvgIcons';
|
||||
import { CallToAction } from '@/app/_components/ui/layout/header/callToAction';
|
||||
@ -11,13 +13,106 @@ import {
|
||||
LogoContainer,
|
||||
} from '@/app/_components/ui/layout/header/styled';
|
||||
import { Logo } from '@/app/_components/ui/layout/Logo';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { formatNumberOfStars } from '@/shared-utils/formatNumberOfStars';
|
||||
|
||||
const DropdownMenu = styled.ul<{ open: boolean }>`
|
||||
display: ${(props) => (props.open ? 'block' : 'none')};
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
border-radius: 8px;
|
||||
padding: 2px 0;
|
||||
margin: 4px 0px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const DropdownItem = styled.a`
|
||||
color: rgb(71, 71, 71);
|
||||
text-decoration: none;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
margin: 0px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
&:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = styled.div`
|
||||
color: rgb(71, 71, 71);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
const StyledChevron = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
color: rgb(179, 179, 179);
|
||||
`;
|
||||
|
||||
const Arrow = styled.div<{ open: boolean }>`
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
transition: transform 0.3s;
|
||||
transform: ${(props) => (props.open ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
numberOfStars: number;
|
||||
};
|
||||
|
||||
export const HeaderDesktop = ({ numberOfStars }: Props) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setDropdownOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<DesktopNav>
|
||||
<LogoContainer>
|
||||
@ -27,9 +122,32 @@ export const HeaderDesktop = ({ numberOfStars }: Props) => {
|
||||
<ListItem href="/story">Story</ListItem>
|
||||
<ListItem href="/pricing">Pricing</ListItem>
|
||||
<ListItem href="/releases">Releases</ListItem>
|
||||
<ListItem href="https://docs.twenty.com">
|
||||
Docs <ExternalArrow />
|
||||
</ListItem>
|
||||
<Dropdown
|
||||
ref={dropdownRef}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
Docs
|
||||
<Arrow open={dropdownOpen}>
|
||||
<StyledChevron>
|
||||
<IconChevronDown size={Theme.icon.size.sm} />
|
||||
</StyledChevron>
|
||||
</Arrow>
|
||||
<DropdownMenu open={dropdownOpen}>
|
||||
<DropdownItem href="/user-guide">
|
||||
<StyledIconContainer>
|
||||
<IconBook size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
User Guide
|
||||
</DropdownItem>
|
||||
<DropdownItem href="/developers">
|
||||
<StyledIconContainer>
|
||||
<IconRobotFace size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
Developers
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<ListItem href="https://github.com/twentyhq/twenty">
|
||||
<GithubIcon color="rgb(71,71,71)" />
|
||||
{formatNumberOfStars(numberOfStars)}
|
||||
|
@ -65,9 +65,8 @@ export const HeaderMobile = ({ numberOfStars }: Props) => {
|
||||
<ListItem href="/story">Story</ListItem>
|
||||
<ListItem href="/pricing">Pricing</ListItem>
|
||||
<ListItem href="/releases">Releases</ListItem>
|
||||
<ListItem href="https://docs.twenty.com">
|
||||
Docs <ExternalArrow />
|
||||
</ListItem>
|
||||
<ListItem href="/user-guide">User Guide</ListItem>
|
||||
<ListItem href="/developers">Developers</ListItem>
|
||||
<ListItem href="https://github.com/twentyhq/twenty">
|
||||
<GithubIcon color="rgb(71,71,71)" />{' '}
|
||||
{formatNumberOfStars(numberOfStars)} <ExternalArrow />
|
||||
|
@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { IconBook } from '@/app/_components/ui/icons';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { AlgoliaDocSearch } from '@/app/_components/user-guide/AlgoliaDocSearch';
|
||||
import UserGuideSidebarSection from '@/app/_components/user-guide/UserGuideSidebarSection';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
|
||||
import '@docsearch/css';
|
||||
import '../../user-guide/algolia.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
display: ['none', 'flex', 'flex'],
|
||||
flexDirection: 'column',
|
||||
background: `${Theme.background.secondary}`,
|
||||
borderRight: `1px solid ${Theme.background.transparent.medium}`,
|
||||
borderBottom: `1px solid ${Theme.background.transparent.medium}`,
|
||||
padding: `${Theme.spacing(10)} ${Theme.spacing(4)}`,
|
||||
gap: `${Theme.spacing(6)}`,
|
||||
})}
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
overflow: scroll;
|
||||
height: calc(100vh - 60px);
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
padding: ${Theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledHeadingText = styled.div`
|
||||
cursor: pointer;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
`;
|
||||
|
||||
const UserGuideSidebar = ({
|
||||
userGuideIndex,
|
||||
}: {
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<AlgoliaDocSearch />
|
||||
<StyledHeading>
|
||||
<StyledIconContainer>
|
||||
<IconBook size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledHeadingText onClick={() => router.push('/user-guide')}>
|
||||
User Guide
|
||||
</StyledHeadingText>
|
||||
</StyledHeading>
|
||||
<UserGuideSidebarSection userGuideIndex={userGuideIndex} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideSidebar;
|
@ -1,157 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { IconChevronDown, IconChevronRight } from '@/app/_components/ui/icons';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { groupArticlesByTopic } from '@/content/user-guide/constants/groupArticlesByTopic';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledIndex = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
padding-bottom: ${Theme.spacing(2)};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledSubTopicItem = styled.a<{ isselected: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: ${Theme.spacing(8)};
|
||||
color: ${(props) =>
|
||||
props.isselected ? Theme.text.color.primary : Theme.text.color.secondary};
|
||||
font-weight: ${(props) =>
|
||||
props.isselected ? Theme.font.weight.medium : Theme.font.weight.regular};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
gap: 19px;
|
||||
padding: ${(props) =>
|
||||
props.isselected ? '6px 12px 6px 11px' : '0px 12px 0px 11px'};
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.background.transparent.light
|
||||
: Theme.background.secondary};
|
||||
border-radius: ${Theme.border.radius.md};
|
||||
text-decoration: none;
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:visited,
|
||||
&:link,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1414140a;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #1414140f;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
padding: 0px 4px 0px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledRectangle = styled.div<{ isselected: boolean; isHovered: boolean }>`
|
||||
height: ${(props) =>
|
||||
props.isselected ? '95%' : props.isHovered ? '70%' : '100%'};
|
||||
width: 2px;
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.border.color.plain
|
||||
: props.isHovered
|
||||
? Theme.background.transparent.strong
|
||||
: Theme.background.transparent.light};
|
||||
transition: height 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface TopicsState {
|
||||
[topic: string]: boolean;
|
||||
}
|
||||
|
||||
const UserGuideSidebarSection = ({
|
||||
userGuideIndex,
|
||||
}: {
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const topics = groupArticlesByTopic(userGuideIndex);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const [unfolded, setUnfolded] = useState<TopicsState>(() =>
|
||||
Object.keys(topics).reduce((acc: TopicsState, topic: string) => {
|
||||
acc[topic] = true;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const toggleFold = (topic: string) => {
|
||||
setUnfolded((prev: TopicsState) => ({ ...prev, [topic]: !prev[topic] }));
|
||||
};
|
||||
return (
|
||||
<StyledContainer>
|
||||
{Object.entries(topics).map(([topic, cards]) => (
|
||||
<StyledIndex key={topic}>
|
||||
<StyledTitle onClick={() => toggleFold(topic)}>
|
||||
{unfolded[topic] ? (
|
||||
<StyledIcon>
|
||||
<IconChevronDown size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon>
|
||||
<IconChevronRight size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<div>{topic}</div>
|
||||
</StyledTitle>
|
||||
{unfolded[topic] &&
|
||||
cards.map((card) => {
|
||||
const isselected = pathname === `/user-guide/${card.fileName}`;
|
||||
return (
|
||||
<StyledSubTopicItem
|
||||
key={card.title}
|
||||
isselected={isselected}
|
||||
href={`/user-guide/${card.fileName}`}
|
||||
onClick={() => router.push(`/user-guide/${card.fileName}`)}
|
||||
onMouseEnter={() => setHoveredItem(card.title)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
<StyledRectangle
|
||||
isselected={isselected}
|
||||
isHovered={hoveredItem === card.title}
|
||||
/>
|
||||
{card.title}
|
||||
</StyledSubTopicItem>
|
||||
);
|
||||
})}
|
||||
</StyledIndex>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideSidebarSection;
|
@ -6,7 +6,12 @@ import gfm from 'remark-gfm';
|
||||
|
||||
import ArticleEditContent from '@/app/_components/ui/layout/articles/ArticleEditContent';
|
||||
import ArticleLink from '@/app/_components/ui/layout/articles/ArticleLink';
|
||||
import ArticlePropsTable from '@/app/_components/ui/layout/articles/ArticlePropsTable';
|
||||
import ArticleTab from '@/app/_components/ui/layout/articles/ArticleTab';
|
||||
import ArticleTable from '@/app/_components/ui/layout/articles/ArticleTable';
|
||||
import ArticleTabs from '@/app/_components/ui/layout/articles/ArticleTabs';
|
||||
import ArticleWarning from '@/app/_components/ui/layout/articles/ArticleWarning';
|
||||
import SandpackEditor from '@/app/_components/ui/layout/articles/SandpackEditor';
|
||||
|
||||
interface ItemInfo {
|
||||
title: string;
|
||||
@ -115,6 +120,21 @@ export async function compileMDXFile(filePath: string) {
|
||||
ArticleLink(properties) {
|
||||
return <ArticleLink {...properties} />;
|
||||
},
|
||||
ArticleTabs(properties) {
|
||||
return <ArticleTabs {...properties} />;
|
||||
},
|
||||
ArticleTab(properties) {
|
||||
return <ArticleTab {...properties} />;
|
||||
},
|
||||
ArticleTable(properties) {
|
||||
return <ArticleTable {...properties} />;
|
||||
},
|
||||
ArticlePropsTable(properties) {
|
||||
return <ArticlePropsTable {...properties} />;
|
||||
},
|
||||
SandpackEditor(properties) {
|
||||
return <SandpackEditor {...properties} />;
|
||||
},
|
||||
},
|
||||
options: {
|
||||
parseFrontmatter: true,
|
||||
@ -145,7 +165,6 @@ export async function getPost(
|
||||
return null;
|
||||
}
|
||||
const { content, frontmatter } = await compileMDXFile(filePath);
|
||||
|
||||
return {
|
||||
content,
|
||||
itemInfo: { ...frontmatter, type: 'file', path: slug },
|
||||
|
31
packages/twenty-website/src/app/developers/[slug]/page.tsx
Normal file
31
packages/twenty-website/src/app/developers/[slug]/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.slug);
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import GraphQlPlayground from '../../../_components/playground/graphql-playground';
|
||||
|
||||
const CoreGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'core'} />;
|
||||
};
|
||||
|
||||
export default CoreGraphql;
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import GraphQlPlayground from '../../../_components/playground/graphql-playground';
|
||||
|
||||
const CoreGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'metadata'} />;
|
||||
};
|
||||
|
||||
export default CoreGraphql;
|
11
packages/twenty-website/src/app/developers/layout.tsx
Normal file
11
packages/twenty-website/src/app/developers/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function DocsLayout({ children }: { children: ReactNode }) {
|
||||
const filePath = 'src/content/developers/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
17
packages/twenty-website/src/app/developers/page.tsx
Normal file
17
packages/twenty-website/src/app/developers/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - Docs',
|
||||
description: 'Twenty is a CRM designed to fit your unique business needs.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DocsHome() {
|
||||
const filePath = 'src/content/developers/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import { API } from '@stoplight/elements';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
|
||||
|
||||
const RestApiComponent = ({ 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)} router="hash" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const restApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
|
||||
const children = <RestApiComponent openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw' }}>
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="core"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default restApi;
|
@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import { API } from '@stoplight/elements';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
|
||||
|
||||
const RestApiComponent = ({ 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)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<API apiDescriptionDocument={JSON.stringify(openApiJson)} router="hash" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const restApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
|
||||
const children = <RestApiComponent openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="metadata"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default restApi;
|
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/developers/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/developers/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/developers/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
31
packages/twenty-website/src/app/twenty-ui/[slug]/page.tsx
Normal file
31
packages/twenty-website/src/app/twenty-ui/[slug]/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.slug);
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
11
packages/twenty-website/src/app/twenty-ui/layout.tsx
Normal file
11
packages/twenty-website/src/app/twenty-ui/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function TwentyUILayout({ children }: { children: ReactNode }) {
|
||||
const filePath = 'src/content/twenty-ui/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
17
packages/twenty-website/src/app/twenty-ui/page.tsx
Normal file
17
packages/twenty-website/src/app/twenty-ui/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - Twenty UI',
|
||||
description: 'Twenty is a CRM designed to fit your unique business needs.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function TwentyUIHome() {
|
||||
const filePath = 'src/content/twenty-ui/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/twenty-ui/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/twenty-ui/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/twenty-ui/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import UserGuideContent from '@/app/_components/user-guide/UserGuideContent';
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
@ -27,5 +27,5 @@ export default async function UserGuideSlug({
|
||||
}) {
|
||||
const basePath = '/src/content/user-guide';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return mainPost && <UserGuideContent item={mainPost} />;
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { UserGuideMainLayout } from '@/app/_components/user-guide/UserGuideMainLayout';
|
||||
import { getUserGuideArticles } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function UserGuideLayout({ children }: { children: ReactNode }) {
|
||||
const userGuideIndex = getUserGuideArticles();
|
||||
return (
|
||||
<UserGuideMainLayout userGuideIndex={userGuideIndex}>
|
||||
{children}
|
||||
</UserGuideMainLayout>
|
||||
);
|
||||
const filePath = 'src/content/user-guide/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import UserGuideMain from '@/app/_components/user-guide/UserGuideMain';
|
||||
import { getUserGuideArticles } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - User Guide',
|
||||
@ -11,7 +11,8 @@ export const metadata = {
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function UserGuideHome() {
|
||||
const userGuideArticleCards = getUserGuideArticles();
|
||||
const filePath = 'src/content/user-guide/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <UserGuideMain userGuideArticleCards={userGuideArticleCards} />;
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/user-guide/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserGuideSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/user-guide/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/user-guide';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserGuideSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/user-guide/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Backend Development
|
||||
icon: TbTerminal
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
info: NestJS, Custom Objects, Queues...
|
||||
---
|
@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Best Practices
|
||||
icon: TbChecklist
|
||||
image: /images/user-guide/tips/light-bulb.png
|
||||
---
|
||||
|
||||
This document outlines the best practices you should follow when working on the backend.
|
||||
|
||||
## Follow a modular approach
|
||||
|
||||
The backend follows a modular approach, which is a fundamental principle when working with NestJS. Make sure you break down your code into reusable modules to maintain a clean and organized codebase.
|
||||
Each module should encapsulate a particular feature or functionality and have a well-defined scope. This modular approach enables clear separation of concerns and removes unnecessary complexities.
|
||||
|
||||
## Expose services to use in modules
|
||||
|
||||
Always create services that have a clear and single responsibility, which enhances code readability and maintainability. Name the services descriptively and consistently.
|
||||
|
||||
You should also expose services that you want to use in other modules. Exposing services to other modules is possible through NestJS's powerful dependency injection system, and promotes loose coupling between components.
|
||||
|
||||
## Avoid using `any` type
|
||||
|
||||
When you declare a variable as `any`, TypeScript's type checker doesn't perform any type checking, making it possible to assign any type of values to the variable. TypeScript uses type inference to determine the type of variable based on the value. By declaring it as `any`, TypeScript can no longer infer the type. This makes it hard to catch type-related errors during development, leading to runtime errors and makes the code less maintainable, less reliable, and harder to understand for others.
|
||||
|
||||
This is why everything should have a type. So if you create a new object with a first name and last name, you should create an interface or type that contains a first name and last name that defines the shape of the object you are manipulating.
|
@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Custom Objects
|
||||
icon: TbAugmentedReality
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
||||
|
||||
Objects are structures that allow you to store data (records, attributes, and values) specific to an organization. Twenty provides both standard and custom objects.
|
||||
|
||||
Standard objects are in-built objects with a set of attributes available for all users. Examples of standard objects in Twenty include Company and Person. Standard objects have standard fields that are also available for all Twenty users, like Company.displayName.
|
||||
|
||||
Custom objects are objects that you can create to store information that is unique to your organization. They are not built-in; members of your workspace can create and customize custom objects to hold information that standard objects aren't suitable for.
|
||||
|
||||
|
||||
## High-level schema
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/custom-object-schema.png" alt="High level schema" />
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## How it works
|
||||
|
||||
Custom objects come from metadata tables that determine the shape, name, and type of the objects. All this information is present in the metadata schema database, consisting of tables:
|
||||
|
||||
- **DataSource**: Details where the data is present.
|
||||
- **Object**: Describes the object and links to a DataSource.
|
||||
- **Field**: Outlines an Object's fields and connects to the Object.
|
||||
|
||||
To add a custom object, the workspaceMember will query the /metadata API. This updates the metadata accordingly and computes a GraphQL schema based on the metadata, storing it in a GQL cache for later use.
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/add-custom-objects.jpeg" alt="Query the /metadata API to add custom objects" />
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
To fetch data, the process involves making queries through the /graphql endpoint and passing them through the Query Resolver.
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/custom-object-schema.png" alt="Query the /graphql endpoint to fetch data" />
|
||||
</div>
|
@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Feature Flags
|
||||
icon: TbFlag
|
||||
image: /images/user-guide/table-views/table.png
|
||||
---
|
||||
|
||||
Feature flags are used to hide experimental features. For twenty they are set on workspace level and not on a user level.
|
||||
|
||||
## Adding a new feature flag
|
||||
|
||||
In `FeatureFlagKey.ts` add the feature flag:
|
||||
|
||||
```ts
|
||||
type FeatureFlagKey =
|
||||
| 'IS_FEATURENAME_ENABLED'
|
||||
| ...;
|
||||
```
|
||||
|
||||
Also add it to the enum in `feature-flag.entity.ts`:
|
||||
|
||||
```ts
|
||||
enum FeatureFlagKeys {
|
||||
IsFeatureNameEnabled = 'IS_FEATURENAME_ENABLED',
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
To apply a feature flag on a **backend** feature use:
|
||||
|
||||
```ts
|
||||
@Gate({
|
||||
featureFlag: 'IS_FEATURENAME_ENABLED',
|
||||
})
|
||||
```
|
||||
|
||||
To apply a feature flag on a **frontend** feature use:
|
||||
|
||||
```ts
|
||||
const isFeatureNameEnabled = useIsFeatureEnabled('IS_FEATURENAME_ENABLED');
|
||||
```
|
||||
|
||||
|
||||
## Configure feature flags for the deployment
|
||||
|
||||
Change the corresponding record in the Table `core.featureFlag`:
|
||||
|
||||
| id | key | workspaceId | value |
|
||||
|----------|--------------------------|---------------|--------|
|
||||
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
|
@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Folder Architecture
|
||||
info: A detailed look into our server folder architecture
|
||||
icon: TbFolder
|
||||
image: /images/user-guide/fields/field.png
|
||||
---
|
||||
|
||||
|
||||
The backend directory structure is as follows:
|
||||
|
||||
```
|
||||
server
|
||||
└───ability
|
||||
└───constants
|
||||
└───core
|
||||
└───database
|
||||
└───decorators
|
||||
└───filters
|
||||
└───guards
|
||||
└───health
|
||||
└───integrations
|
||||
└───metadata
|
||||
└───workspace
|
||||
└───utils
|
||||
```
|
||||
|
||||
## Ability
|
||||
|
||||
Defines permissions and includes handlers for each entity.
|
||||
|
||||
## Decorators
|
||||
|
||||
Defines custom decorators in NestJS for added functionality.
|
||||
|
||||
See [custom decorators](https://docs.nestjs.com/custom-decorators) for more details.
|
||||
|
||||
## Filters
|
||||
|
||||
Includes exception filters to handle exceptions that might occur in GraphQL endpoints.
|
||||
|
||||
## Guards
|
||||
|
||||
See [guards](https://docs.nestjs.com/guards) for more details.
|
||||
|
||||
## Health
|
||||
|
||||
Includes a publicly available REST API (healthz) that returns a JSON to confirm whether the database is working as expected.
|
||||
|
||||
## Metadata
|
||||
|
||||
Defines custom objects and makes available a GraphQL API (graphql/metadata).
|
||||
|
||||
## Workspace
|
||||
|
||||
Generates and serves custom GraphQL schema based on the metadata.
|
||||
|
||||
### Workspace Directory Structure
|
||||
|
||||
```
|
||||
workspace
|
||||
|
||||
└───workspace-schema-builder
|
||||
└───factories
|
||||
└───graphql-types
|
||||
└───database
|
||||
└───interfaces
|
||||
└───object-definitions
|
||||
└───services
|
||||
└───storage
|
||||
└───utils
|
||||
└───workspace-resolver-builder
|
||||
└───factories
|
||||
└───interfaces
|
||||
└───workspace-query-builder
|
||||
└───factories
|
||||
└───interfaces
|
||||
└───workspace-query-runner
|
||||
└───interfaces
|
||||
└───utils
|
||||
└───workspace-datasource
|
||||
└───workspace-manager
|
||||
└───workspace-migration-runner
|
||||
└───utils
|
||||
└───workspace.module.ts
|
||||
└───workspace.factory.spec.ts
|
||||
└───workspace.factory.ts
|
||||
```
|
||||
|
||||
|
||||
The root of the workspace directory includes the `workspace.factory.ts`, a file containing the `createGraphQLSchema` function. This function generates workspace-specific schema by using the metadata to tailor a schema for individual workspaces. By separating the schema and resolver construction, we use the `makeExecutableSchema` function, which combines these discrete elements.
|
||||
|
||||
This strategy is not just about organization, but also helps with optimization, such as caching generated type definitions to enhance performance and scalability.
|
||||
|
||||
### Workspace Schema builder
|
||||
|
||||
Generates the GraphQL schema, and includes:
|
||||
|
||||
#### Factories:
|
||||
|
||||
Specialised constructors to generate GraphQL-related constructs.
|
||||
- The type.factory translates field metadata into GraphQL types using `TypeMapperService`.
|
||||
- The type-definition.factory creates GraphQL input or output objects derived from `objectMetadata`.
|
||||
|
||||
#### GraphQL Types
|
||||
|
||||
Includes enumerations, inputs, objects, and scalars, and serves as the building blocks for the schema construction.
|
||||
|
||||
#### Interfaces and Object Definitions
|
||||
|
||||
Contains the blueprints for GraphQL entities, and includes both predefined and custom types like `MONEY` or `URL`.
|
||||
|
||||
#### Services
|
||||
|
||||
Contains the service responsible for associating FieldMetadataType with its appropriate GraphQL scalar or query modifiers.
|
||||
|
||||
#### Storage
|
||||
|
||||
Includes the `TypeDefinitionsStorage` class that contains reusable type definitions, preventing duplication of GraphQL types.
|
||||
|
||||
### Workspace Resolver Builder
|
||||
|
||||
Creates resolver functions for querying and mutatating the GraphQL schema.
|
||||
|
||||
Each factory in this directory is responsible for producing a distinct resolver type, such as the `FindManyResolverFactory`, designed for adaptable application across various tables.
|
||||
|
||||
### Workspace Query Builder
|
||||
|
||||
Includes factories that generate `pg_graphql` queries.
|
||||
|
||||
### Workspace Query Runner
|
||||
|
||||
Runs the generated queries on the database and parses the result.
|
@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Message Queue
|
||||
icon: TbSchema
|
||||
image: /images/user-guide/emails/emails_header.png
|
||||
---
|
||||
|
||||
Queues facilitate async operations to be performed. They can be used for performing background tasks such as sending a welcome email on register.
|
||||
Each use case will have its own queue class extended from `MessageQueueServiceBase`.
|
||||
|
||||
Currently, queue supports two drivers which can be configurred by env variable `MESSAGE_QUEUE_TYPE`.
|
||||
1. `pg-boss`: this is the default driver, which uses [pg-boss](https://github.com/timgit/pg-boss) under the hood.
|
||||
2. `bull-mq`: this uses [bull-mq](https://bullmq.io/) under the hood.
|
||||
|
||||
## Steps to create and use a new queue
|
||||
|
||||
1. Add a queue name for your new queue under enum `MESSAGE_QUEUES`.
|
||||
2. Provide the factory implementation of the queue with the queue name as the dependency token.
|
||||
3. Inject the queue that you created in the required module/service with the queue name as the dependency token.
|
||||
4. Add worker class with token based injection just like producer.
|
||||
|
||||
### Example usage
|
||||
```ts
|
||||
class Resolver {
|
||||
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {}
|
||||
|
||||
async onSomeAction() {
|
||||
//business logic
|
||||
await this.queue.add(someData);
|
||||
}
|
||||
}
|
||||
|
||||
//async worker
|
||||
class CustomWorker {
|
||||
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {
|
||||
this.initWorker();
|
||||
}
|
||||
|
||||
async initWorker() {
|
||||
await this.queue.work(async ({ id, data }) => {
|
||||
//worker logic
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Backend Commands
|
||||
icon: TbTerminal
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
||||
|
||||
## Useful commands
|
||||
|
||||
These commands should be exectued from packages/twenty-server folder.
|
||||
From any other folder you can run `npx nx <command>` twenty-server.
|
||||
|
||||
### First time setup
|
||||
|
||||
```
|
||||
npx nx database:reset # setup the database with dev seeds
|
||||
```
|
||||
|
||||
### Starting the app
|
||||
|
||||
```
|
||||
npx nx start
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```
|
||||
npx nx lint
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```
|
||||
npx nx test:unit
|
||||
```
|
||||
|
||||
### Resetting the database
|
||||
|
||||
If you want to reset the database, you can run the following command:
|
||||
|
||||
```bash
|
||||
npx nx database:reset
|
||||
```
|
||||
|
||||
<ArticleWarning>
|
||||
|
||||
This will drop the database and re-run the migrations and seed.
|
||||
|
||||
Make sure to back up any data you want to keep before running this command.
|
||||
|
||||
</ArticleWarning>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Twenty primarily uses NestJS for the backend.
|
||||
|
||||
Prisma was the first ORM we used. But in order to allow users to create custom fields and custom objects, a lower-level made more sense as we need to have fine-grained control. The project now uses TypeORM.
|
||||
|
||||
Here's what the tech stack now looks like.
|
||||
|
||||
|
||||
**Core**
|
||||
- [NestJS](https://nestjs.com/)
|
||||
- [TypeORM](https://typeorm.io/)
|
||||
- [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
|
||||
|
||||
**Database**
|
||||
- [Postgres](https://www.postgresql.org/)
|
||||
|
||||
**Third-party integrations**
|
||||
- [Sentry](https://sentry.io/welcome/) for tracking bugs
|
||||
|
||||
**Testing**
|
||||
- [Jest](https://jestjs.io/)
|
||||
|
||||
**Tooling**
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [ESLint](https://eslint.org/)
|
||||
|
||||
**Development**
|
||||
- [AWS EKS](https://aws.amazon.com/eks/)
|
@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Zapier App
|
||||
icon: TbBrandZapier
|
||||
image: /images/user-guide/integrations/plug.png
|
||||
---
|
||||
|
||||
Effortlessly sync Twenty with 3000+ apps using [Zapier](https://zapier.com/). Automate tasks, boost productivity, and supercharge your customer relationships!
|
||||
|
||||
## About Zapier
|
||||
|
||||
Zapier is a tool that allows you automate workflows by connecting the apps that your team uses everyday. The fundamental concept of Zapier is automation workflows, called Zaps, and include triggers and actions.
|
||||
|
||||
You can learn more about how Zapier works [here](https://zapier.com/how-it-works).
|
||||
|
||||
## Setup
|
||||
|
||||
### Step 1: Install Zapier packages
|
||||
|
||||
```bash
|
||||
cd packages/twenty-zapier
|
||||
|
||||
yarn
|
||||
```
|
||||
|
||||
### Step 2: Login with the CLI
|
||||
|
||||
Use your Zapier credentials to log in using the CLI:
|
||||
|
||||
```bash
|
||||
zapier login
|
||||
```
|
||||
|
||||
### Step 3: Set environment variables
|
||||
|
||||
From the `packages/twenty-zapier` folder, run:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Run the application locally, go to [http://localhost:3000/settings/developers](http://localhost:3000/settings/developers/api-keys), and generate an API key.
|
||||
|
||||
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.
|
||||
|
||||
## Development
|
||||
|
||||
<ArticleWarning>
|
||||
|
||||
Make sure to run `yarn build` before any `zapier` command.
|
||||
|
||||
</ArticleWarning>
|
||||
|
||||
### Test
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
### Lint
|
||||
```bash
|
||||
yarn format
|
||||
```
|
||||
### Watch and compile as you edit code
|
||||
```bash
|
||||
yarn watch
|
||||
```
|
||||
### Validate your Zapier app
|
||||
```bash
|
||||
yarn validate
|
||||
```
|
||||
### Deploy your Zapier app
|
||||
```bash
|
||||
yarn deploy
|
||||
```
|
||||
### List all Zapier CLI commands
|
||||
```bash
|
||||
zapier
|
||||
```
|
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Bugs and Requests
|
||||
icon: TbBug
|
||||
image: /images/user-guide/api/api.png
|
||||
info: Ask for help on Github or Discord
|
||||
---
|
||||
|
||||
## Reporting Bugs
|
||||
To report a bug, please [create an issue on GitHub](https://github.com/twentyhq/twenty/issues/new).
|
||||
|
||||
You can also ask for help on [Discord](https://discord.gg/cx5n4Jzs57).
|
||||
|
||||
## Feature Requests
|
||||
|
||||
If you're not sure it's a bug and you feel it's closer to a feature request, then you should probably [open a discussion instead](https://github.com/twentyhq/twenty/discussions/new).
|
@ -0,0 +1,50 @@
|
||||
export const DOCS_INDEX = {
|
||||
'Getting started': {
|
||||
'Local Setup': [{ fileName: 'local-setup' }],
|
||||
'Self-Hosting': [
|
||||
{ fileName: 'self-hosting' },
|
||||
{ fileName: 'self-hosting-var' },
|
||||
{ fileName: 'docker-compose' },
|
||||
{ fileName: 'cloud-providers' },
|
||||
],
|
||||
},
|
||||
Extending: {
|
||||
'Rest APIs': [
|
||||
{ fileName: 'rest-apis' },
|
||||
{ fileName: 'core-api-rest' },
|
||||
{ fileName: 'metadata-api-rest' },
|
||||
],
|
||||
'GraphQL APIs': [
|
||||
{ fileName: 'graphql-apis' },
|
||||
{ fileName: 'core-api-graphql' },
|
||||
{ fileName: 'metadata-api-graphql' },
|
||||
],
|
||||
},
|
||||
Contributing: {
|
||||
'Bugs and Requests': [{ fileName: 'bug-and-requests' }],
|
||||
'Frontend Development': [
|
||||
{ fileName: 'storybook' },
|
||||
{ fileName: 'components' },
|
||||
{ fileName: 'frontend-development' },
|
||||
{ fileName: 'frontend-commands' },
|
||||
{ fileName: 'work-with-figma' },
|
||||
{ fileName: 'best-practices-front' },
|
||||
{ fileName: 'style-guide' },
|
||||
{ fileName: 'folder-architecture-front' },
|
||||
{ fileName: 'hotkeys' },
|
||||
],
|
||||
'Backend Development': [
|
||||
{ fileName: 'backend-development' },
|
||||
{ fileName: 'server-commands' },
|
||||
{ fileName: 'feature-flags' },
|
||||
{ fileName: 'folder-architecture-server' },
|
||||
{ fileName: 'zapier' },
|
||||
{ fileName: 'best-practices-server' },
|
||||
{ fileName: 'custom-objects' },
|
||||
{ fileName: 'queue' },
|
||||
],
|
||||
},
|
||||
'User Guide': {
|
||||
'Empty Section': [],
|
||||
},
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Frontend Development
|
||||
icon: TbTerminal2
|
||||
image: /images/user-guide/create-workspace/workspace-cover.png
|
||||
info: Storybook, Figma, React Best Practices...
|
||||
---
|
@ -0,0 +1,329 @@
|
||||
---
|
||||
title: Best Practices
|
||||
icon: TbChecklist
|
||||
image: /images/user-guide/tips/light-bulb.png
|
||||
---
|
||||
|
||||
This document outlines the best practices you should follow when working on the frontend.
|
||||
|
||||
## State management
|
||||
|
||||
React and Recoil handle state management in the codebase.
|
||||
|
||||
### Use `useRecoilState` to store state
|
||||
|
||||
It's good practice to create as many atoms as you need to store your state.
|
||||
|
||||
<ArticleWarning>
|
||||
|
||||
It's better to use extra atoms than trying to be too concise with props drilling.
|
||||
|
||||
</ArticleWarning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={myAtom}
|
||||
onChange={(e) => setMyAtom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Do not use `useRef` to store state
|
||||
|
||||
Avoid using `useRef` to store state.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
|
||||
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
|
||||
|
||||
## Managing re-renders
|
||||
|
||||
Re-renders can be hard to manage in React.
|
||||
|
||||
Here are some rules to follow to avoid unnecessary re-renders.
|
||||
|
||||
Keep in mind that you can **always** avoid re-renders by understanding their cause.
|
||||
|
||||
### Work at the root level
|
||||
|
||||
Avoiding re-renders in new features is now made easy by eliminating them at the root level.
|
||||
|
||||
The `PageChangeEffect` sidecar component contains just one `useEffect` that holds all the logic to execute on a page change.
|
||||
|
||||
That way you know that there's just one place that can trigger a re-render.
|
||||
|
||||
### Always think twice before adding `useEffect` in your codebase
|
||||
|
||||
Re-renders are often caused by unnecessary `useEffect`.
|
||||
|
||||
You should think whether you need `useEffect`, or if you can move the logic in a event handler function.
|
||||
|
||||
You'll find it generally easy to move the logic in a `handleClick` or `handleChange` function.
|
||||
|
||||
You can also find them in libraries like Apollo: `onCompleted`, `onError`, etc.
|
||||
|
||||
### Use a sibling component to extract `useEffect` or data fetching logic
|
||||
|
||||
If you feel like you need to add a `useEffect` in your root component, you should consider extracting it in a sidecar component.
|
||||
|
||||
You can apply the same for data fetching logic, with Apollo hooks.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, will cause re-renders even if data is not changing,
|
||||
// because useEffect needs to be re-evaluated
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
setData(someDependency);
|
||||
}
|
||||
}, [someDependency]);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, will not cause re-renders if data is not changing,
|
||||
// because useEffect is re-evaluated in another sibling component
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
setData(someDependency);
|
||||
}
|
||||
}, [someDependency]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
);
|
||||
```
|
||||
|
||||
### Use recoil family states and recoil family selectors
|
||||
|
||||
Recoil family states and selectors are a great way to avoid re-renders.
|
||||
|
||||
They are useful when you need to store a list of items.
|
||||
|
||||
### You shouldn't use `React.memo(MyComponent)`
|
||||
|
||||
Avoid using `React.memo()` because it does not solve the cause of the re-render, but instead breaks the re-render chain, which can lead to unexpected behavior and make the code very hard to refactor.
|
||||
|
||||
### Limit `useCallback` or `useMemo` usage
|
||||
|
||||
They are often not necessary and will make the code harder to read and maintain for a gain of performance that is unnoticeable.
|
||||
|
||||
## Console.logs
|
||||
|
||||
`console.log` statements are invaluable during development, offering real-time insights into variable values and code flow. But, leaving them in production code can lead to several issues:
|
||||
|
||||
1. **Performance**: Excessive logging can affect the runtime performance, specially on client-side applications.
|
||||
|
||||
2. **Security**: Logging sensitive data can expose critical information to anyone who inspects the browser's console.
|
||||
|
||||
3. **Cleanliness**: Filling up the console with logs can obscure important warnings or errors that developers or tools need to see.
|
||||
|
||||
4. **Professionalism**: End users or clients checking the console and seeing a myriad of log statements might question the code's quality and polish.
|
||||
|
||||
Make sure you remove all `console.logs` before pushing the code to production.
|
||||
|
||||
## Naming
|
||||
|
||||
### Variable Naming
|
||||
|
||||
Variable names ought to precisely depict the purpose or function of the variable.
|
||||
|
||||
#### The issue with generic names
|
||||
Generic names in programming are not ideal because they lack specificity, leading to ambiguity and reduced code readability. Such names fail to convey the variable or function's purpose, making it challenging for developers to understand the code's intent without deeper investigation. This can result in increased debugging time, higher susceptibility to errors, and difficulties in maintenance and collaboration. Meanwhile, descriptive naming makes the code self-explanatory and easier to navigate, enhancing code quality and developer productivity.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, uses a generic name that doesn't communicate its
|
||||
// purpose or content clearly
|
||||
const [value, setValue] = useState('');
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, uses a descriptive name
|
||||
const [email, setEmail] = useState('');
|
||||
```
|
||||
|
||||
#### Some words to avoid in variable names
|
||||
|
||||
- dummy
|
||||
|
||||
### Event handlers
|
||||
|
||||
Event handler names should start with `handle`, while `on` is a prefix used to name events in components props.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
const onEmailChange = (val: string) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good
|
||||
const handleEmailChange = (val: string) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## Optional Props
|
||||
|
||||
Avoid passing the default value for an optional prop.
|
||||
|
||||
**EXAMPLE**
|
||||
|
||||
Take the`EmailField` component defined below:
|
||||
|
||||
```tsx
|
||||
type EmailFieldProps = {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const EmailField = ({ value, disabled = false }: EmailFieldProps) => (
|
||||
<TextInput value={value} disabled={disabled} fullWidth />
|
||||
);
|
||||
```
|
||||
|
||||
**Usage**
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, passing in the same value as the default value adds no value
|
||||
const Form = () => <EmailField value="username@email.com" disabled={false} />;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, assumes the default value
|
||||
const Form = () => <EmailField value="username@email.com" />;
|
||||
```
|
||||
|
||||
## Component as props
|
||||
|
||||
Try as much as possible to pass uninstantiated components as props, so children can decide on their own of what props they need to pass.
|
||||
|
||||
The most common example for that is icon components:
|
||||
|
||||
```tsx
|
||||
const SomeParentComponent = () => <MyComponent Icon={MyIcon} />;
|
||||
|
||||
// In MyComponent
|
||||
const MyComponent = ({ MyIcon }: { MyIcon: IconComponent }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MyIcon size={theme.icon.size.md}>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
```
|
||||
|
||||
For React to understand that the component is a component, you need to use PascalCase, to later instantiate it with `<MyIcon>`
|
||||
|
||||
## Prop Drilling: Keep It Minimal
|
||||
|
||||
Prop drilling, in the React context, refers to the practice of passing state variables and their setters through many component layers, even if intermediary components don't use them. While sometimes necessary, excessive prop drilling can lead to:
|
||||
|
||||
1. **Decreased Readability**: Tracing where a prop originates or where it's utilized can become convoluted in a deeply nested component structure.
|
||||
|
||||
2. **Maintenance Challenges**: Changes in one component's prop structure might require adjustments in several components, even if they don't directly use the prop.
|
||||
|
||||
3. **Reduced Component Reusability**: A component receiving a lot of props solely for passing them down becomes less general-purpose and harder to reuse in different contexts.
|
||||
|
||||
If you feel that you are using excessive prop drilling, see [state management best practices](#state-management).
|
||||
|
||||
## Imports
|
||||
|
||||
When importing, opt for the designated aliases rather than specifying complete or relative paths.
|
||||
|
||||
**The Aliases**
|
||||
|
||||
```js
|
||||
{
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "src"),
|
||||
"@": path.resolve(__dirname, "src/modules"),
|
||||
"@testing": path.resolve(__dirname, "src/testing"),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**
|
||||
```tsx
|
||||
// ❌ Bad, specifies the entire relative path
|
||||
import {
|
||||
CatalogDecorator
|
||||
} from '../../../../../testing/decorators/CatalogDecorator';
|
||||
import {
|
||||
ComponentDecorator
|
||||
} from '../../../../../testing/decorators/ComponentDecorator';
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, utilises the designated aliases
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from 'twenty-ui';=
|
||||
```
|
||||
|
||||
## Schema Validation
|
||||
|
||||
[Zod](https://github.com/colinhacks/zod) is the schema validator for untyped objects:
|
||||
|
||||
```js
|
||||
const validationSchema = z
|
||||
.object({
|
||||
exist: z.boolean(),
|
||||
email: z
|
||||
.string()
|
||||
.email('Email must be a valid email'),
|
||||
password: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
})
|
||||
.required();
|
||||
|
||||
type Form = z.infer<typeof validationSchema>;
|
||||
```
|
||||
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Always perform thorough manual testing before proceeding to guarantee that modifications haven’t caused disruptions elsewhere, given that tests have not yet been extensively integrated.
|
||||
|
@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Folder Architecture
|
||||
info: A detailed look into our folder architecture
|
||||
icon: TbFolder
|
||||
image: /images/user-guide/fields/field.png
|
||||
---
|
||||
|
||||
In this guide, you will explore the details of the project directory structure and how it contributes to the organization and maintainability of Twenty.
|
||||
|
||||
By following this folder architecture convention, it's easier to find the files related to specific features and ensure that the application is scalable and maintainable.
|
||||
|
||||
```
|
||||
front
|
||||
└───modules
|
||||
│ └───module1
|
||||
│ │ └───submodule1
|
||||
│ └───module2
|
||||
│ └───ui
|
||||
│ │ └───display
|
||||
│ │ └───inputs
|
||||
│ │ │ └───buttons
|
||||
│ │ └───...
|
||||
└───pages
|
||||
└───...
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
Includes the top-level components defined by the application routes. They import more low-level components from the modules folder (more details below).
|
||||
|
||||
## Modules
|
||||
|
||||
Each module represents a feature or a group of feature, comprising its specific components, states, and operational logic.
|
||||
They should all follow the structure below. You can nest modules within modules (referred to as submodules) and the same rules will apply.
|
||||
|
||||
```
|
||||
module1
|
||||
└───components
|
||||
│ └───component1
|
||||
│ └───component2
|
||||
└───constants
|
||||
└───contexts
|
||||
└───graphql
|
||||
│ └───fragments
|
||||
│ └───queries
|
||||
│ └───mutations
|
||||
└───hooks
|
||||
│ └───internal
|
||||
└───states
|
||||
│ └───selectors
|
||||
└───types
|
||||
└───utils
|
||||
```
|
||||
|
||||
### Contexts
|
||||
|
||||
A context is a way to pass data through the component tree without having to pass props down manually at every level.
|
||||
|
||||
See [React Context](https://react.dev/reference/react#context-hooks) for more details.
|
||||
|
||||
### GraphQL
|
||||
|
||||
Includes fragments, queries, and mutations.
|
||||
|
||||
See [GraphQL](https://graphql.org/learn/) for more details.
|
||||
|
||||
- Fragments
|
||||
|
||||
A fragment is a reusable piece of a query, which you can use in different places. By using fragments, it's easier to avoid duplicating code.
|
||||
|
||||
See [GraphQL Fragments](https://graphql.org/learn/queries/#fragments) for more details.
|
||||
|
||||
- Queries
|
||||
|
||||
See [GraphQL Queries](https://graphql.org/learn/queries/) for more details.
|
||||
|
||||
- Mutations
|
||||
|
||||
See [GraphQL Mutations](https://graphql.org/learn/queries/#mutations) for more details.
|
||||
|
||||
### Hooks
|
||||
|
||||
See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more details.
|
||||
|
||||
### States
|
||||
|
||||
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
|
||||
|
||||
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
|
||||
|
||||
React's built-in state management still handles state within a component.
|
||||
|
||||
### Utils
|
||||
|
||||
Should just contain reusable pure functions. Otherwise, create custom hooks in the `hooks` folder.
|
||||
|
||||
|
||||
## UI
|
||||
|
||||
Contains all the reusable UI components used in the application.
|
||||
|
||||
This folder can contain sub-folders, like `data`, `display`, `feedback`, and `input` for specific types of components. Each component should be self-contained and reusable, so that you can use it in different parts of the application.
|
||||
|
||||
By separating the UI components from the other components in the `modules` folder, it's easier to maintain a consistent design and to make changes to the UI without affecting other parts (business logic) of the codebase.
|
||||
|
||||
## Interface and dependencies
|
||||
|
||||
You can import other module code from any module except for the `ui` folder. This will keep its code easy to test.
|
||||
|
||||
### Internal
|
||||
|
||||
Each part (hooks, states, ...) of a module can have an `internal` folder, which contains parts that are just used within the module.
|
@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Frontend Commands
|
||||
icon: TbTerminal2
|
||||
image: /images/user-guide/create-workspace/workspace-cover.png
|
||||
---
|
||||
|
||||
## Useful commands
|
||||
|
||||
### Starting the app
|
||||
|
||||
```bash
|
||||
nx start twenty-front
|
||||
```
|
||||
|
||||
### Regenerate graphql schema based on API graphql schema
|
||||
|
||||
```bash
|
||||
nx graphql:generate twenty-front
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
nx lint twenty-front
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
nx test twenty-front# run jest tests
|
||||
nx storybook:dev twenty-front# run storybook
|
||||
nx storybook:test twenty-front# run tests # (needs yarn storybook:dev to be running)
|
||||
nx storybook:coverage twenty-front # (needs yarn storybook:dev to be running)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
The project has a clean and simple stack, with minimal boilerplate code.
|
||||
|
||||
**App**
|
||||
|
||||
- [React](https://react.dev/)
|
||||
- [Apollo](https://www.apollographql.com/docs/)
|
||||
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Testing**
|
||||
|
||||
- [Jest](https://jestjs.io/)
|
||||
- [Storybook](https://storybook.js.org/)
|
||||
|
||||
**Tooling**
|
||||
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Craco](https://craco.js.org/docs/)
|
||||
- [ESLint](https://eslint.org/)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routing
|
||||
|
||||
[React Router](https://reactrouter.com/) handles the routing.
|
||||
|
||||
To avoid unnecessary [re-renders](/contributor/frontend/best-practices#managing-re-renders) all the routing logic is in a `useEffect` in `PageChangeEffect`.
|
||||
|
||||
### State Management
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
|
||||
|
||||
See [best practices](/contributor/frontend/best-practices#state-management) for more information on state management.
|
||||
|
||||
## Testing
|
||||
|
||||
[Jest](https://jestjs.io/) serves as the tool for unit testing while [Storybook](https://storybook.js.org/) is for component testing.
|
||||
|
||||
Jest is mainly for testing utility functions, and not components themselves.
|
||||
|
||||
Storybook is for testing the behavior of isolated components, as well as displaying the design system.
|
@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Hotkeys
|
||||
icon: TbKeyboard
|
||||
image: /images/user-guide/table-views/table.png
|
||||
---
|
||||
|
||||
You can intercept any hotkey combination and execute a custom action.
|
||||
|
||||
There's a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders.
|
||||
|
||||
There's also a wrapper hook `useScopedHotkeys` that makes it easy to manage scopes.
|
||||
|
||||
```ts
|
||||
useScopedHotkeys(
|
||||
'ctrl+k,meta+k',
|
||||
() => {
|
||||
openCommandMenu();
|
||||
},
|
||||
AppHotkeyScope.CommandMenu,
|
||||
[openCommandMenu],
|
||||
);
|
||||
```
|
@ -0,0 +1,291 @@
|
||||
---
|
||||
title: Style Guide
|
||||
icon: TbPencil
|
||||
image: /images/user-guide/notes/notes_header.png
|
||||
---
|
||||
|
||||
This document includes the rules to follow when writing code.
|
||||
|
||||
The goal here is to have a consistent codebase, which is easy to read and easy to maintain.
|
||||
|
||||
For this, it's better to be a bit more verbose than to be too concise.
|
||||
|
||||
Always keep in mind that people read code more often than they write it, specially on an open source project, where anyone can contribute.
|
||||
|
||||
There are a lot of rules that are not defined here, but that are automatically checked by linters.
|
||||
|
||||
## React
|
||||
|
||||
### Use functional components
|
||||
|
||||
Always use TSX functional components.
|
||||
|
||||
Do not use default `import` with `const`, because it's harder to read and harder to import with code completion.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, harder to read, harder to import with code completion
|
||||
const MyComponent = () => {
|
||||
return <div>Hello World</div>;
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
|
||||
// ✅ Good, easy to read, easy to import with code completion
|
||||
export function MyComponent() {
|
||||
return <div>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Create the type of the props and call it `(ComponentName)Props` if there's no need to export it.
|
||||
|
||||
Use props destructuring.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, no type
|
||||
export const MyComponent = (props) => <div>Hello {props.name}</div>;
|
||||
|
||||
// ✅ Good, type
|
||||
type MyComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
|
||||
```
|
||||
|
||||
#### Refrain from using `React.FC` or `React.FunctionComponent` to define prop types
|
||||
|
||||
```tsx
|
||||
/* ❌ - Bad, defines the component type annotations with `FC`
|
||||
* - With `React.FC`, the component implicitly accepts a `children` prop
|
||||
* even if it's not defined in the prop type. This might not always be
|
||||
* desirable, especially if the component doesn't intend to render
|
||||
* children.
|
||||
*/
|
||||
const EmailField: React.FC<{
|
||||
value: string;
|
||||
}> = ({ value }) => <TextInput value={value} disabled fullWidth />;
|
||||
```
|
||||
|
||||
```tsx
|
||||
/* ✅ - Good, a separate type (OwnProps) is explicitly defined for the
|
||||
* component's props
|
||||
* - This method doesn't automatically include the children prop. If
|
||||
* you want to include it, you have to specify it in OwnProps.
|
||||
*/
|
||||
type EmailFieldProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
const EmailField = ({ value }: EmailFieldProps) => (
|
||||
<TextInput value={value} disabled fullWidth />
|
||||
);
|
||||
```
|
||||
|
||||
#### No Single Variable Prop Spreading in JSX Elements
|
||||
|
||||
Avoid using single variable prop spreading in JSX elements, like `{...props}`. This practice often results in code that is less readable and harder to maintain because it's unclear which props the component is receiving.
|
||||
|
||||
```tsx
|
||||
/* ❌ - Bad, spreads a single variable prop into the underlying component
|
||||
*/
|
||||
const MyComponent = (props: OwnProps) => {
|
||||
return <OtherComponent {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
/* ✅ - Good, Explicitly lists all props
|
||||
* - Enhances readability and maintainability
|
||||
*/
|
||||
const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
|
||||
return <OtherComponent {...{ prop1, prop2, prop3 }} />;
|
||||
};
|
||||
```
|
||||
|
||||
Rationale:
|
||||
- At a glance, it's clearer which props the code passes down, making it easier to understand and maintain.
|
||||
- It helps to prevent tight coupling between components via their props.
|
||||
- Linting tools make it easier to identify misspelled or unused props when you list props explicitly.
|
||||
|
||||
## JavaScript
|
||||
|
||||
### Use nullish-coalescing operator `??`
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, can return 'default' even if value is 0 or ''
|
||||
const value = process.env.MY_VALUE || 'default';
|
||||
|
||||
// ✅ Good, will return 'default' only if value is null or undefined
|
||||
const value = process.env.MY_VALUE ?? 'default';
|
||||
```
|
||||
|
||||
### Use optional chaining `?.`
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
onClick && onClick();
|
||||
|
||||
// ✅ Good
|
||||
onClick?.();
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
### Use `type` instead of `interface`
|
||||
|
||||
Always use `type` instead of `interface`, because they almost always overlap, and `type` is more flexible.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
interface MyInterface {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ✅ Good
|
||||
type MyType = {
|
||||
name: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Use string literals instead of enums
|
||||
|
||||
[String literals](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types) are the go-to way to handle enum-like values in TypeScript. They are easier to extend with Pick and Omit, and offer a better developer experience, specially with code completion.
|
||||
|
||||
You can see why TypeScript recommends avoiding enums [here](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#enums).
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, utilizes an enum
|
||||
enum Color {
|
||||
Red = "red",
|
||||
Green = "green",
|
||||
Blue = "blue",
|
||||
}
|
||||
|
||||
let color = Color.Red;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, utilizes a string literal
|
||||
|
||||
let color: "red" | "green" | "blue" = "red";
|
||||
```
|
||||
|
||||
#### GraphQL and internal libraries
|
||||
|
||||
You should use enums that GraphQL codegen generates.
|
||||
|
||||
It's also better to use an enum when using an internal library, so the internal library doesn't have to expose a string literal type that is not related to the internal API.
|
||||
|
||||
Example:
|
||||
|
||||
```TSX
|
||||
const {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
} = usePreviousHotkeyScope();
|
||||
|
||||
setHotkeyScopeAndMemorizePreviousScope(
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
);
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
### Use StyledComponents
|
||||
|
||||
Style the components with [styled-components](https://emotion.sh/docs/styled).
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
<div className="my-class">Hello World</div>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good
|
||||
const StyledTitle = styled.div`
|
||||
color: red;
|
||||
`;
|
||||
```
|
||||
|
||||
Prefix styled components with "Styled" to differentiate them from "real" components.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
const Title = styled.div`
|
||||
color: red;
|
||||
`;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good
|
||||
const StyledTitle = styled.div`
|
||||
color: red;
|
||||
`;
|
||||
```
|
||||
|
||||
### Theming
|
||||
|
||||
Utilizing the theme for the majority of component styling is the preferred approach.
|
||||
|
||||
#### Units of measurement
|
||||
|
||||
Avoid using `px` or `rem` values directly within the styled components. The necessary values are generally already defined in the theme, so it’s recommended to make use of the theme for these purposes.
|
||||
|
||||
#### Colors
|
||||
|
||||
Refrain from introducing new colors; instead, use the existing palette from the theme. Should there be a situation where the palette does not align, please leave a comment so that the team can rectify it.
|
||||
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, directly specifies style values without utilizing the theme
|
||||
const StyledButton = styled.button`
|
||||
color: #333333;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
border-radius: 50px;
|
||||
`;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, utilizes the theme
|
||||
const StyledButton = styled.button`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
border-radius: ${({ theme }) => theme.border.rounded};
|
||||
`;
|
||||
```
|
||||
## Enforcing No-Type Imports
|
||||
|
||||
Avoid type imports. To enforce this standard, an ESLint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
// ❌ Bad
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
// ✅ Good
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
```
|
||||
|
||||
### Why No-Type Imports
|
||||
|
||||
- **Consistency**: By avoiding type imports and using a single approach for both type and value imports, the codebase remains consistent in its module import style.
|
||||
|
||||
- **Readability**: No-type imports improve code readability by making it clear when you're importing values or types. This reduces ambiguity and makes it easier to understand the purpose of imported symbols.
|
||||
|
||||
- **Maintainability**: It enhances codebase maintainability because developers can identify and locate type-only imports when reviewing or modifying code.
|
||||
|
||||
### ESLint Rule
|
||||
|
||||
An ESLint rule, `@typescript-eslint/consistent-type-imports`, enforces the no-type import standard. This rule will generate errors or warnings for any type import violations.
|
||||
|
||||
Please note that this rule specifically addresses rare edge cases where unintentional type imports occur. TypeScript itself discourages this practice, as mentioned in the [TypeScript 3.8 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html). In most situations, you should not need to use type-only imports.
|
||||
|
||||
To ensure your code complies with this rule, make sure to run ESLint as part of your development workflow.
|
@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Work with Figma
|
||||
info: Learn how you can collaborate with Twenty's Figma
|
||||
icon: TbBrandFigma
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
||||
|
||||
Figma is a collaborative interface design tool that aids in bridging the communication barrier between designers and developers.
|
||||
This guide explains how you can collaborate with Figma.
|
||||
|
||||
## Access
|
||||
|
||||
1. **Access the shared link:** You can access the project's Figma file [here](https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty).
|
||||
2. **Sign in:** If you're not already signed in, Figma will prompt you to do so.
|
||||
Key features are only available to logged-in users, such as the developer mode and the ability to select a dedicated frame.
|
||||
|
||||
<ArticleWarning>
|
||||
|
||||
You will not be able to collaborate effectively without an account.
|
||||
|
||||
</ArticleWarning>
|
||||
|
||||
|
||||
## Figma structure
|
||||
|
||||
On the left sidebar, you can access the different pages of Twenty's Figma. This is how they're organized:
|
||||
|
||||
- **Components page:** This is the first page. The designer uses it to create and organize the reusable design elements used throughout the design file. For example, buttons, icons, symbols, or any other reusable components. It serves to maintain consistency across the design.
|
||||
- **Main page:** The second page is the main page, which shows the complete user interface of the project. You can press ***Play*** to use the full app prototype.
|
||||
- **Features pages:** The other pages are typically dedicated to features in progress. They contain the design of specific features or modules of the application or website. They are typically still in progress.
|
||||
|
||||
## Useful Tips
|
||||
|
||||
With read-only access, you can't edit the design but you can access all features that will be useful to convert the designs into code.
|
||||
|
||||
### Use the Dev mode
|
||||
|
||||
Figma's Dev Mode enhances developers' productivity by providing easy design navigation, effective asset management, efficient communication tools, toolbox integrations, quick code snippets, and key layer information, bridging the gap between design and development. You can learn more about Dev Mode [here](https://www.figma.com/dev-mode/).
|
||||
|
||||
Switch to the "Developer" mode in the right part of the toolbar to see design specs, copy CSS, and access assets.
|
||||
|
||||
### Use the Prototype
|
||||
|
||||
Click on any element on the canvas and press the “Play” button at the top right edge of the interface to access the prototype view. Prototype mode allows you to interact with the design as if it were the final product. It demonstrates the flow between screens and how interface elements like buttons, links, or menus behave when interacted with.
|
||||
|
||||
1. **Understanding transitions and animations:** In the Prototype mode, you can view any transitions or animations added by a designer between screens or UI elements, providing clear visual instructions to developers on the intended behavior and style.
|
||||
2. **Implementation clarification:** A prototype can also help reduce ambiguities. Developers can interact with it to gain a better understanding of the functionality or appearance of particular elements.
|
||||
|
||||
For more comprehensive details and guidance on learning the Figma platform, you can visit the official [Figma Documentation](https://help.figma.com/hc/en-us).
|
||||
|
||||
### Measure distances
|
||||
|
||||
Select an element, hold `Option` key (Mac) or `Alt` key (Windows), then hover over another element to see the distance between them.
|
||||
|
||||
### Figma extension for VSCode (Recommended)
|
||||
|
||||
[Figma for VS Code](https://marketplace.visualstudio.com/items?itemName=figma.figma-vscode-extension)
|
||||
lets you navigate and inspect design files, collaborate with designers, track changes, and speed up implementation - all without leaving your text editor.
|
||||
It's part of our recommended extensions.
|
||||
|
||||
## Collaboration
|
||||
|
||||
1. **Using Comments:** You are welcome to use the comment feature by clicking on the bubble icon in the left part of the toolbar.
|
||||
2. **Cursor chat:** A nice feature of Figma is the Cursor chat. Just press `;` on Mac and `/` on Windows to send a message if you see someone else using Figma as the same time as you.
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Getting Started
|
||||
icon: IconUsers
|
||||
info: Discover Twenty, an open-source CRM, its features, benefits, system requirements, and how to get involved.
|
||||
image: /images/user-guide/what-is-twenty/20.png
|
||||
---
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: GraphQL APIs
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/api/api.png
|
||||
info: The most powerful way to build integrations
|
||||
---
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Core API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/import-export-data/cloud.png
|
||||
---
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Metadata API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
233
packages/twenty-website/src/content/developers/local-setup.mdx
Normal file
233
packages/twenty-website/src/content/developers/local-setup.mdx
Normal file
@ -0,0 +1,233 @@
|
||||
---
|
||||
title: Local Setup
|
||||
icon: TbDeviceDesktop
|
||||
image: /images/user-guide/fields/field.png
|
||||
info: Mostly for contributors or curious developers
|
||||
---
|
||||
|
||||
|
||||
Follow this guide if you would like to setup the project locally to contribute.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<ArticleTabs label1="Linux and MacOS" label2="Windows (WSL)">
|
||||
<ArticleTab>
|
||||
|
||||
Before you can install and use Twenty, make sure you install the following on your computer:
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
- [Node v18](https://nodejs.org/en/download)
|
||||
- [yarn v4](https://yarnpkg.com/getting-started/install)
|
||||
- [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md)
|
||||
|
||||
<ArticleWarning>
|
||||
`npm` won't work, you should use `yarn` instead. Yarn is now shipped with Node.js, so you don't need to install it separately.
|
||||
You only have to run `corepack enable` to enable Yarn if you haven't done it yet.
|
||||
</ArticleWarning>
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
1. Install WSL
|
||||
Open PowerShell as Administrator and run:
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
You should now see a prompt to restart your computer. If not, restart it manually.
|
||||
|
||||
Upon restart, a powershell window will open and install Ubuntu. This may take up some time.
|
||||
You'll see a prompt to create a username and password for your Ubuntu installation.
|
||||
|
||||
2. Install and configure git
|
||||
|
||||
```bash
|
||||
sudo apt-get install git
|
||||
|
||||
git config --global user.name "Your Name"
|
||||
|
||||
git config --global user.email "youremail@domain.com"
|
||||
```
|
||||
|
||||
3. Install Node.js, nvm, yarn
|
||||
```bash
|
||||
sudo apt-get install curl
|
||||
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
|
||||
|
||||
corepack enable
|
||||
```
|
||||
Close and reopen your terminal to start using nvm.
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Git Clone
|
||||
|
||||
In your terminal, run the following command.
|
||||
|
||||
|
||||
<ArticleTabs label1="SSH (Recommended)" label2="HTTPS">
|
||||
<ArticleTab>
|
||||
If you haven't already set up SSH keys, you can learn how to do so [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/about-ssh).
|
||||
```bash
|
||||
git clone git@github.com:twentyhq/twenty.git
|
||||
```
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/twentyhq/twenty.git
|
||||
```
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
## Step 2: Position yourself at the root
|
||||
|
||||
```bash
|
||||
cd twenty
|
||||
```
|
||||
|
||||
You should run all commands in the following steps from the root of the project.
|
||||
|
||||
## Step 3: Set up a PostgreSQL Database
|
||||
We rely on [pg_graphql](https://github.com/supabase/pg_graphql) and recommend you use the scripts below to provision a database with the right extensions.
|
||||
You can access the database at [localhost:5432](localhost:5432), with user `twenty` and password `twenty` .
|
||||
|
||||
<ArticleTabs label1="Linux" label2="Mac OS" label3="Windows (WSL)">
|
||||
<ArticleTab>
|
||||
<b>Option 1:</b> To provision your database locally:
|
||||
```bash
|
||||
make postgres-on-linux
|
||||
```
|
||||
|
||||
<b>Option 2:</b> If you have docker installed:
|
||||
```bash
|
||||
make postgres-on-docker
|
||||
```
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
<b>Option 1:</b> To provision your database locally with `brew`:
|
||||
```bash
|
||||
make postgres-on-macos-intel #for intel architecture
|
||||
|
||||
make postgres-on-macos-arm #for M1/M2/M3 architecture
|
||||
```
|
||||
|
||||
<b>Option 2:</b> If you have docker installed:
|
||||
```bash
|
||||
make postgres-on-docker
|
||||
```
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
<b>Option 1 :</b> To provision your database locally:
|
||||
```bash
|
||||
make postgres-on-linux
|
||||
```
|
||||
|
||||
Note: you might need to run `sudo apt-get install build-essential` before running the above command if you don't have the build tools installed.
|
||||
|
||||
<b>Option 2:</b> If you have docker installed:
|
||||
Running Docker on WSL adds an extra layer of complexity.
|
||||
Only use this option if you are comfortable with the extra steps involved, including turning on [Docker Desktop WSL2](https://docs.docker.com/desktop/wsl).
|
||||
```bash
|
||||
make postgres-on-docker
|
||||
```
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
|
||||
## Step 4: Setup environment variables
|
||||
|
||||
Use environment variables or `.env` files to configure your project.
|
||||
|
||||
Copy the `.env.example` files in `/front` and `/server`:
|
||||
```bash
|
||||
cp ./packages/twenty-front/.env.example ./packages/twenty-front/.env
|
||||
|
||||
cp ./packages/twenty-server/.env.example ./packages/twenty-server/.env
|
||||
```
|
||||
|
||||
## Step 5: Installing dependencies
|
||||
|
||||
<ArticleWarning>
|
||||
|
||||
Use `nvm` to install the correct `node` version. The `.nvmrc` ensures all contributors use the same version.
|
||||
|
||||
</ArticleWarning>
|
||||
|
||||
To build Twenty server and seed some data into your database, run the following commands:
|
||||
```bash
|
||||
nvm install # installs recommended node version
|
||||
|
||||
nvm use # use recommended node version
|
||||
|
||||
yarn
|
||||
```
|
||||
|
||||
## Step 6: Running the project
|
||||
|
||||
Setup your database with the following command:
|
||||
```bash
|
||||
npx nx database:reset twenty-server
|
||||
```
|
||||
|
||||
Start the server and the frontend:
|
||||
```bash
|
||||
npx nx start twenty-server
|
||||
|
||||
npx nx start twenty-front
|
||||
```
|
||||
|
||||
Alternatively, you can start both applications at once:
|
||||
```bash
|
||||
npx nx start
|
||||
```
|
||||
|
||||
Twenty's server will be up and running at [http://localhost:3000/graphql](http://localhost:3000/graphql).
|
||||
Twenty's frontend will be running at [http://localhost:3001](http://localhost:3001). Just login using the seeded demo account: `tim@apple.dev` (password: `Applecar2025`) to start using Twenty.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### CR line breaks found [Windows]
|
||||
|
||||
This is due to the line break characters of Windows and the git configuration. Try running:
|
||||
|
||||
```
|
||||
git config --global core.autocrlf false
|
||||
```
|
||||
|
||||
Then delete the repository and clone it again.
|
||||
|
||||
#### Missing metadata schema
|
||||
|
||||
During Twenty installation, you need to provision your postgres database with the right schemas, extensions, and users.
|
||||
If you're successful in running this provisioning, you should have `default` and `metadata` schemas in your database.
|
||||
If you don't, make sure you don't have more than one postgres instance running on your computer.
|
||||
|
||||
#### Cannot find module 'twenty-emails' or its corresponding type declarations.
|
||||
|
||||
You have to build the package `twenty-emails` before running the initialization of the database with `npx nx run twenty-emails:build`
|
||||
|
||||
#### Missing twenty-x package
|
||||
|
||||
Make sure to run yarn in the root directory and then run `npx nx server:dev twenty-server`. If this still doesn't work try building the missing package manually.
|
||||
|
||||
#### Lint on Save not working
|
||||
|
||||
This should work out of the box with the eslint extension installed. If this doens't work try adding this to your vscode setting (on the dev container scope):
|
||||
|
||||
```
|
||||
"editor.codeActionsOnSave": {
|
||||
|
||||
"source.fixAll.eslint": "explicit"
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
#### Docker container build
|
||||
|
||||
To successfully build Docker images, ensure that your system has a minimum of 2GB of memory available. For users of Docker Desktop, please verify that you've allocated sufficient resources to Docker within the application's settings.
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Rest APIs
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/what-is-twenty/20.png
|
||||
info: The simplest way to build integrations
|
||||
---
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Core API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/import-export-data/cloud.png
|
||||
---
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Metadata API
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Self-Hosting
|
||||
icon: TbServer
|
||||
image: /images/user-guide/integrations/plug.png
|
||||
info: Learn how to host Twenty on your own server
|
||||
---
|
@ -0,0 +1,436 @@
|
||||
---
|
||||
title: Vendor-Specific Instructions
|
||||
icon: TbCloud
|
||||
image: /images/user-guide/notes/notes_header.png
|
||||
---
|
||||
|
||||
<ArticleWarning>
|
||||
This document is maintained by the community. It might contain issues.
|
||||
Feel free to join our discord if you need assistance.
|
||||
</ArticleWarning>
|
||||
|
||||
|
||||
## Render
|
||||
|
||||
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/twentyhq/twenty)
|
||||
|
||||
## RepoCloud
|
||||
|
||||
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=259)
|
||||
|
||||
|
||||
## Azure Container Apps
|
||||
|
||||
### About
|
||||
|
||||
Hosts Twenty CRM using Azure Container Apps.
|
||||
The solution provisions file shares, a container apps environment with three containers, and a log analytics workspace.
|
||||
|
||||
The file shares are used to store uploaded images and files through the UI, and to store database backups.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Terraform installed https://developer.hashicorp.com/terraform/install
|
||||
- An Azure subscription with permissions to create resources
|
||||
|
||||
### Step by step instructions:
|
||||
|
||||
1. Create a new folder and copy all the files from below
|
||||
2. Run `terraform init`
|
||||
3. Run `terraform plan -out tfplan`
|
||||
4. Run `terraform apply tfplan`
|
||||
5. Connect to server `az containerapp exec --name twenty-server -g twenty-crm-rg`
|
||||
6. Initialize the database from the server `yarn database:init:prod`
|
||||
7. Go to https://your-twenty-front-fqdn - located in the portal
|
||||
|
||||
#### Production docker containers
|
||||
|
||||
This uses the prebuilt images found on [docker hub](https://hub.docker.com/r/twentycrm/).
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
- Is set in respective tf-files
|
||||
- See docs [Setup Environment Variables](https://docs.twenty.com/start/self-hosting/) for usage
|
||||
- After deployment you could can set `IS_SIGN_UP_DISABLED=true` (and run `terraform plan/apply` again) to disable new workspaces from being created
|
||||
|
||||
#### Security and networking
|
||||
|
||||
- Container `twenty-db` accepts only ingress TCP traffic from other containers in the environment. No external ingress traffic allowed
|
||||
- Container `twenty-server` accepts external traffic over HTTPS
|
||||
- Container `twenty-front` accepts external traffic over HTTPS
|
||||
|
||||
It´s highly recommended to enable [built-in authentication](https://learn.microsoft.com/en-us/azure/container-apps/authentication) for `twenty-front` using one of the supported providers.
|
||||
|
||||
Use the [custom domain](https://learn.microsoft.com/en-us/azure/container-apps/custom-domains-certificates) feature on the `twenty-front` container if you would like an easier domain name.
|
||||
|
||||
#### Files
|
||||
|
||||
##### providers.tf
|
||||
|
||||
```hcl
|
||||
# providers.tf
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
azapi = {
|
||||
source = "Azure/azapi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "azapi" {
|
||||
}
|
||||
|
||||
provider "azurerm" {
|
||||
features {}
|
||||
}
|
||||
|
||||
provider "azuread" {
|
||||
}
|
||||
|
||||
provider "random" {
|
||||
}
|
||||
```
|
||||
|
||||
##### main.tf
|
||||
|
||||
```hcl
|
||||
# main.tf
|
||||
|
||||
# Create a resource group
|
||||
resource "azurerm_resource_group" "main" {
|
||||
name = "twenty-crm-rg"
|
||||
location = "North Europe"
|
||||
}
|
||||
|
||||
# Variables
|
||||
locals {
|
||||
app_env_name = "twenty"
|
||||
|
||||
server_name = "twenty-server"
|
||||
server_tag = "latest"
|
||||
|
||||
front_app_name = "twenty-front"
|
||||
front_tag = "latest"
|
||||
|
||||
db_app_name = "twenty-postgres"
|
||||
db_tag = "latest"
|
||||
|
||||
db_user = "twenty"
|
||||
db_password = "twenty"
|
||||
|
||||
storage_mount_db_name = "twentydbstoragemount"
|
||||
storage_mount_server_name = "twentyserverstoragemount"
|
||||
|
||||
cpu = 1.0
|
||||
memory = "2Gi"
|
||||
}
|
||||
|
||||
# Set up a Log Analytics workspace
|
||||
resource "azurerm_log_analytics_workspace" "main" {
|
||||
name = "${local.app_env_name}-law"
|
||||
location = azurerm_resource_group.main.location
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
sku = "PerGB2018"
|
||||
retention_in_days = 30
|
||||
}
|
||||
|
||||
# Create a storage account
|
||||
resource "random_pet" "example" {
|
||||
length = 2
|
||||
separator = ""
|
||||
}
|
||||
|
||||
resource "azurerm_storage_account" "main" {
|
||||
name = "twentystorage${random_pet.example.id}"
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
location = azurerm_resource_group.main.location
|
||||
account_tier = "Standard"
|
||||
account_replication_type = "LRS"
|
||||
large_file_share_enabled = true
|
||||
}
|
||||
|
||||
# Create db file storage
|
||||
resource "azurerm_storage_share" "db" {
|
||||
name = "twentydatabaseshare"
|
||||
storage_account_name = azurerm_storage_account.main.name
|
||||
quota = 50
|
||||
enabled_protocol = "SMB"
|
||||
}
|
||||
|
||||
# Create backend file storage
|
||||
resource "azurerm_storage_share" "server" {
|
||||
name = "twentyservershare"
|
||||
storage_account_name = azurerm_storage_account.main.name
|
||||
quota = 50
|
||||
enabled_protocol = "SMB"
|
||||
}
|
||||
|
||||
# Create a Container App Environment
|
||||
resource "azurerm_container_app_environment" "main" {
|
||||
name = "${local.app_env_name}-env"
|
||||
location = azurerm_resource_group.main.location
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
|
||||
}
|
||||
|
||||
# Connect the db storage share to the container app environment
|
||||
resource "azurerm_container_app_environment_storage" "db" {
|
||||
name = local.storage_mount_db_name
|
||||
container_app_environment_id = azurerm_container_app_environment.main.id
|
||||
account_name = azurerm_storage_account.main.name
|
||||
share_name = azurerm_storage_share.db.name
|
||||
access_key = azurerm_storage_account.main.primary_access_key
|
||||
access_mode = "ReadWrite"
|
||||
}
|
||||
|
||||
# Connect the server storage share to the container app environment
|
||||
resource "azurerm_container_app_environment_storage" "server" {
|
||||
name = local.storage_mount_server_name
|
||||
container_app_environment_id = azurerm_container_app_environment.main.id
|
||||
account_name = azurerm_storage_account.main.name
|
||||
share_name = azurerm_storage_share.server.name
|
||||
access_key = azurerm_storage_account.main.primary_access_key
|
||||
access_mode = "ReadWrite"
|
||||
}
|
||||
```
|
||||
|
||||
##### frontend.tf
|
||||
|
||||
```hcl
|
||||
# frontend.tf
|
||||
|
||||
resource "azurerm_container_app" "twenty_front" {
|
||||
name = local.front_app_name
|
||||
container_app_environment_id = azurerm_container_app_environment.main.id
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
revision_mode = "Single"
|
||||
|
||||
depends_on = [azurerm_container_app.twenty_server]
|
||||
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = true
|
||||
target_port = 3000
|
||||
transport = "http"
|
||||
traffic_weight {
|
||||
percentage = 100
|
||||
latest_revision = true
|
||||
}
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = 1
|
||||
# things starts to fail when using more than 1 replica
|
||||
max_replicas = 1
|
||||
container {
|
||||
name = "twenty-front"
|
||||
image = "docker.io/twentycrm/twenty-front:${local.front_tag}"
|
||||
cpu = local.cpu
|
||||
memory = local.memory
|
||||
|
||||
env {
|
||||
name = "REACT_APP_SERVER_BASE_URL"
|
||||
value = "https://${azurerm_container_app.twenty_server.ingress[0].fqdn}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Set CORS rules for frontend app using AzAPI
|
||||
resource "azapi_update_resource" "cors" {
|
||||
type = "Microsoft.App/containerApps@2023-05-01"
|
||||
resource_id = azurerm_container_app.twenty_front.id
|
||||
body = jsonencode({
|
||||
properties = {
|
||||
configuration = {
|
||||
ingress = {
|
||||
corsPolicy = {
|
||||
allowedOrigins = ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
depends_on = [azurerm_container_app.twenty_front]
|
||||
}
|
||||
```
|
||||
|
||||
##### backend.tf
|
||||
|
||||
```hcl
|
||||
# backend.tf
|
||||
|
||||
# Create three random UUIDs
|
||||
resource "random_uuid" "access_token_secret" {}
|
||||
resource "random_uuid" "login_token_secret" {}
|
||||
resource "random_uuid" "refresh_token_secret" {}
|
||||
resource "random_uuid" "file_token_secret" {}
|
||||
|
||||
resource "azurerm_container_app" "twenty_server" {
|
||||
name = local.server_name
|
||||
container_app_environment_id = azurerm_container_app_environment.main.id
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
revision_mode = "Single"
|
||||
|
||||
depends_on = [azurerm_container_app.twenty_db, azurerm_container_app_environment_storage.server]
|
||||
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = true
|
||||
target_port = 3000
|
||||
transport = "http"
|
||||
traffic_weight {
|
||||
percentage = 100
|
||||
latest_revision = true
|
||||
}
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = 1
|
||||
max_replicas = 1
|
||||
volume {
|
||||
name = "twenty-server-data"
|
||||
storage_type = "AzureFile"
|
||||
storage_name = local.storage_mount_server_name
|
||||
}
|
||||
|
||||
container {
|
||||
name = local.server_name
|
||||
image = "docker.io/twentycrm/twenty-server:${local.server_tag}"
|
||||
cpu = local.cpu
|
||||
memory = local.memory
|
||||
|
||||
volume_mounts {
|
||||
name = "twenty-server-data"
|
||||
path = "/app/packages/twenty-server/.local-storage"
|
||||
}
|
||||
|
||||
# Environment variables
|
||||
env {
|
||||
name = "IS_SIGN_UP_DISABLED"
|
||||
value = false
|
||||
}
|
||||
env {
|
||||
name = "SIGN_IN_PREFILLED"
|
||||
value = false
|
||||
}
|
||||
env {
|
||||
name = "STORAGE_TYPE"
|
||||
value = "local"
|
||||
}
|
||||
env {
|
||||
name = "STORAGE_LOCAL_PATH"
|
||||
value = ".local-storage"
|
||||
}
|
||||
env {
|
||||
name = "PG_DATABASE_URL"
|
||||
value = "postgres://${local.db_user}:${local.db_password}@${local.db_app_name}:5432/default"
|
||||
}
|
||||
env {
|
||||
name = "FRONT_BASE_URL"
|
||||
value = "https://${local.front_app_name}"
|
||||
}
|
||||
env {
|
||||
name = "ACCESS_TOKEN_SECRET"
|
||||
value = random_uuid.access_token_secret.result
|
||||
}
|
||||
env {
|
||||
name = "LOGIN_TOKEN_SECRET"
|
||||
value = random_uuid.login_token_secret.result
|
||||
}
|
||||
env {
|
||||
name = "REFRESH_TOKEN_SECRET"
|
||||
value = random_uuid.refresh_token_secret.result
|
||||
}
|
||||
env {
|
||||
name = "FILE_TOKEN_SECRET"
|
||||
value = random_uuid.file_token_secret.result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Set CORS rules for server app using AzAPI
|
||||
resource "azapi_update_resource" "server_cors" {
|
||||
type = "Microsoft.App/containerApps@2023-05-01"
|
||||
resource_id = azurerm_container_app.twenty_server.id
|
||||
body = jsonencode({
|
||||
properties = {
|
||||
configuration = {
|
||||
ingress = {
|
||||
corsPolicy = {
|
||||
allowedOrigins = ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
depends_on = [azurerm_container_app.twenty_server]
|
||||
}
|
||||
```
|
||||
|
||||
##### database.tf
|
||||
|
||||
```hcl
|
||||
# database.tf
|
||||
|
||||
resource "azurerm_container_app" "twenty_db" {
|
||||
name = local.db_app_name
|
||||
container_app_environment_id = azurerm_container_app_environment.main.id
|
||||
resource_group_name = azurerm_resource_group.main.name
|
||||
revision_mode = "Single"
|
||||
|
||||
depends_on = [azurerm_container_app_environment_storage.db]
|
||||
|
||||
ingress {
|
||||
allow_insecure_connections = false
|
||||
external_enabled = false
|
||||
target_port = 5432
|
||||
transport = "tcp"
|
||||
traffic_weight {
|
||||
percentage = 100
|
||||
latest_revision = true
|
||||
}
|
||||
}
|
||||
|
||||
template {
|
||||
min_replicas = 1
|
||||
max_replicas = 1
|
||||
container {
|
||||
name = local.db_app_name
|
||||
image = "docker.io/twentycrm/twenty-postgres:${local.db_tag}"
|
||||
cpu = local.cpu
|
||||
memory = local.memory
|
||||
|
||||
volume_mounts {
|
||||
name = "twenty-db-data"
|
||||
path = "/var/lib/postgresql/data"
|
||||
}
|
||||
|
||||
env {
|
||||
name = "POSTGRES_USER"
|
||||
value = "postgres"
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_PASSWORD"
|
||||
value = "postgres"
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_DB"
|
||||
value = "default"
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "twenty-db-data"
|
||||
storage_type = "AzureFile"
|
||||
storage_name = local.storage_mount_db_name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Others
|
||||
|
||||
Please feel free to Open a PR to add more Cloud Provider options.
|
@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 1-Click Docker Compose
|
||||
icon: TbBrandDocker
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
||||
|
||||
## Option 1: One-line script
|
||||
|
||||
Install the project with the command below.
|
||||
It will install the latest stable version.
|
||||
```bash
|
||||
bash <(curl -sL https://git.new/20)
|
||||
```
|
||||
|
||||
If you want to install a specific version, you can do so by adding the version number. `VERSION=x.y.z BRANCH=branch-name bash <(curl -sL https://git.new/20)`
|
||||
|
||||
## Option 2: Manual steps
|
||||
|
||||
1. Copy the [.env.example](https://github.com/twentyhq/twenty/blob/main/packages/twenty-docker/.env.example) into a `.env` in the same directory where your `docker-compose.yml` file will be
|
||||
2. Run the command `openssl rand -base64 32` four times, make note of the string for each
|
||||
3. In your .env file, replace the three "replace_me_with_a_random_string_access" with the four random strings you just generated.
|
||||
|
||||
```
|
||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
||||
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
|
||||
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
|
||||
FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh
|
||||
```
|
||||
|
||||
4. Copy the [docker-compose.yml](https://github.com/twentyhq/twenty/blob/main/packages/twenty-docker/docker-compose.yml) in the same directory as your `.env` file.
|
||||
5. Run the command `docker-compose up -d`
|
||||
6. Go to http://localhost:3000 and see your docker instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Not able to login
|
||||
|
||||
If you encounter errors, (not able to log into the application after inputting an email) after the inital setup, try running the following commands and see if that solves your issue.
|
||||
```
|
||||
docker exec -it twenty-server-1 yarn
|
||||
docker exec -it twenty-server-1 npx nx database:reset
|
||||
```
|
||||
|
||||
#### Cannot connect to server, running behind a reverse proxy
|
||||
|
||||
Complete step three and four with:
|
||||
|
||||
3. Update `SERVER_URL=https://<your-api-url.com>` in your `.env`
|
||||
|
||||
#### Persistence
|
||||
|
||||
By default the docker-compose will create volumes for the Database and local storage of the Server. Note that the containers will not persist data if your server is not configured to be stateful (for example Heroku). You probably want to configure a special stateful resource for this purpose.
|
@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
icon: TbServer
|
||||
image: /images/user-guide/table-views/table.png
|
||||
---
|
||||
|
||||
import OptionTable from '@site/src/theme/OptionTable'
|
||||
|
||||
# Setup Environment Variables
|
||||
|
||||
## Frontend
|
||||
|
||||
<ArticleTable options={[
|
||||
['REACT_APP_SERVER_BASE_URL', 'http://localhost:3000', 'Url of backend server'],
|
||||
['GENERATE_SOURCEMAP', 'false', 'Generate source maps for debugging'],
|
||||
['CHROMATIC_PROJECT_TOKEN', '', 'Chromatic token used for CI'],
|
||||
]}></ArticleTable>
|
||||
|
||||
|
||||
## Backend
|
||||
|
||||
### Config
|
||||
|
||||
<ArticleTable options={[
|
||||
['PG_DATABASE_URL', 'postgres://user:pw@localhost:5432/default?connection_limit=1', 'Database connection'],
|
||||
['PG_SSL_ALLOW_SELF_SIGNED', 'false', 'Allow self signed certificates'],
|
||||
['REDIS_HOST', '127.0.0.1', 'Redis connection host'],
|
||||
['REDIS_PORT', '6379', 'Redis connection port'],
|
||||
['FRONT_BASE_URL', 'http://localhost:3001', 'Url to the hosted frontend'],
|
||||
['SERVER_URL', 'http://localhost:3000', 'Url to the hosted server'],
|
||||
['PORT', '3000', 'Port'],
|
||||
['CACHE_STORAGE_TYPE', 'memory', 'Cache type (memory, redis...)'],
|
||||
['CACHE_STORAGE_TTL', '3600 * 24 * 7', 'Cache TTL in seconds']
|
||||
]}></ArticleTable>
|
||||
|
||||
### Security
|
||||
|
||||
<ArticleTable options={[
|
||||
['API_RATE_LIMITING_TTL', '100', 'API rate limiting time window'],
|
||||
['API_RATE_LIMITING_LIMIT', '200', 'API rate limiting max requests'],
|
||||
]}></ArticleTable>
|
||||
### Tokens
|
||||
|
||||
<ArticleTable options={[
|
||||
['ACCESS_TOKEN_SECRET', '<random>', 'Secret used for the access tokens'],
|
||||
['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'],
|
||||
['LOGIN_TOKEN_SECRET', '<random>', 'Secret used for the login tokens'],
|
||||
['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'],
|
||||
['REFRESH_TOKEN_SECRET', '<random>', 'Secret used for the refresh tokens'],
|
||||
['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'],
|
||||
['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'],
|
||||
['FILE_TOKEN_SECRET', '<random>', 'Secret used for the file tokens'],
|
||||
['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'],
|
||||
['API_TOKEN_EXPIRES_IN', '1000y', 'Api token expiration time'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Auth
|
||||
|
||||
<ArticleTable options={[
|
||||
['MESSAGING_PROVIDER_GMAIL_ENABLED', 'false', 'Enable Gmail API connection'],
|
||||
['CALENDAR_PROVIDER_GOOGLE_ENABLED', 'false', 'Enable Google Calendar API connection'],
|
||||
['AUTH_GOOGLE_APIS_CALLBACK_URL', '', 'Google APIs auth callback'],
|
||||
['AUTH_PASSWORD_ENABLED', 'false', 'Enable Email/Password login'],
|
||||
['AUTH_GOOGLE_ENABLED', 'false', 'Enable Google SSO login'],
|
||||
['AUTH_GOOGLE_CLIENT_ID', '', 'Google client ID'],
|
||||
['AUTH_GOOGLE_CLIENT_SECRET', '', 'Google client secret'],
|
||||
['AUTH_GOOGLE_CALLBACK_URL', '', 'Google auth callback'],
|
||||
['AUTH_MICROSOFT_ENABLED', 'false', 'Enable Microsoft SSO login'],
|
||||
['AUTH_MICROSOFT_CLIENT_ID', '', 'Microsoft client ID'],
|
||||
['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'],
|
||||
['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'],
|
||||
['AUTH_MICROSOFT_CALLBACK_URL', '', 'Microsoft auth callback'],
|
||||
['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'],
|
||||
['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'],
|
||||
['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Email
|
||||
|
||||
<ArticleTable options={[
|
||||
['EMAIL_FROM_ADDRESS', 'contact@yourdomain.com', 'Global email From: header used to send emails'],
|
||||
['EMAIL_FROM_NAME', 'John from YourDomain', 'Global name From: header used to send emails'],
|
||||
['EMAIL_SYSTEM_ADDRESS', 'system@yourdomain.com', 'Email address used as a destination to send internal system notification'],
|
||||
['EMAIL_DRIVER', 'logger', "Email driver: 'logger' (to log emails in console) or 'smtp'"],
|
||||
['EMAIL_SMTP_HOST', '', 'Email Smtp Host'],
|
||||
['EMAIL_SMTP_PORT', '', 'Email Smtp Port'],
|
||||
['EMAIL_SMTP_USER', '', 'Email Smtp User'],
|
||||
['EMAIL_SMTP_PASSWORD', '', 'Email Smtp Password'],
|
||||
]}></ArticleTable>
|
||||
|
||||
#### Email SMTP Server configuration examples
|
||||
|
||||
<ArticleTabs label1="Gmail" label2="Office365" label3="Smtp4dev">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
You will need to provision an [App Password](https://support.google.com/accounts/answer/185833).
|
||||
- EMAIL_SMTP_HOST=smtp.gmail.com
|
||||
- EMAIL_SERVER_PORT=465
|
||||
- EMAIL_SERVER_USER=gmail_email_address
|
||||
- EMAIL_SERVER_PASSWORD='gmail_app_password'
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
|
||||
- EMAIL_SMTP_HOST=smtp.office365.com
|
||||
- EMAIL_SERVER_PORT=587
|
||||
- EMAIL_SERVER_USER=office365_email_address
|
||||
- EMAIL_SERVER_PASSWORD='office365_password'
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
**smtp4dev** is a fake SMTP email server for development and testing.
|
||||
- Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
|
||||
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
|
||||
- Set the following env variables:
|
||||
- EMAIL_SERVER_HOST=localhost
|
||||
- EMAIL_SERVER_PORT=2525
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
### Storage
|
||||
|
||||
<ArticleTable options={[
|
||||
['STORAGE_TYPE', 'local', "Storage driver: 'local' or 's3'"],
|
||||
['STORAGE_S3_REGION', '', 'Storage Region'],
|
||||
['STORAGE_S3_NAME', '', 'Bucket Name'],
|
||||
['STORAGE_S3_ENDPOINT', '', 'Use if a different Endpoint is needed (for example Google)'],
|
||||
['STORAGE_LOCAL_PATH', '.local-storage', 'data path (local storage)'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Message Queue
|
||||
|
||||
<ArticleTable options={[
|
||||
['MESSAGE_QUEUE_TYPE', 'pg-boss', "Queue driver: 'pg-boss' or 'bull-mq'"],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Logging
|
||||
|
||||
<ArticleTable options={[
|
||||
['LOGGER_DRIVER', 'console', "The logging driver can be: 'console' or 'sentry'"],
|
||||
['LOGGER_IS_BUFFER_ENABLED', 'true', 'Buffer the logs before sending them to the logging driver'],
|
||||
['LOG_LEVELS', 'error,warn', "The loglevels which are logged to the logging driver. Can include: 'log', 'warn', 'error'"],
|
||||
['EXCEPTION_HANDLER_DRIVER', 'sentry', "The exception handler driver can be: 'console' or 'sentry'"],
|
||||
['SENTRY_ENVIRONMENT', 'main', 'The sentry environment used if sentry logging driver is selected'],
|
||||
['SENTRY_RELEASE', 'latest', 'The sentry release used if sentry logging driver is selected'],
|
||||
['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'],
|
||||
['SENTRY_FRONT_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used by the frontend if sentry logging driver is selected'],
|
||||
]}></ArticleTable>
|
||||
|
||||
|
||||
### Data enrichment and AI
|
||||
|
||||
<ArticleTable options={[
|
||||
['OPENROUTER_API_KEY', '', "The API key for openrouter.ai, an abstraction layer over models from Mistral, OpenAI and more"]
|
||||
]}></ArticleTable>
|
||||
|
||||
|
||||
### Support Chat
|
||||
|
||||
<ArticleTable options={[
|
||||
['SUPPORT_DRIVER', 'front', "Support driver ('front' or 'none')"],
|
||||
['SUPPORT_FRONT_HMAC_KEY', '<secret>', 'Suport chat key'],
|
||||
['SUPPORT_FRONT_CHAT_ID', '<id>', 'Support chat id'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Telemetry
|
||||
|
||||
<ArticleTable options={[
|
||||
['TELEMETRY_ENABLED', 'true', 'Change this if you want to disable telemetry'],
|
||||
['TELEMETRY_ANONYMIZATION_ENABLED', 'true', 'Telemetry is anonymized by default, you probably don\'t want to change this'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Debug / Development
|
||||
|
||||
<ArticleTable options={[
|
||||
['DEBUG_MODE', 'true', 'Activate debug mode'],
|
||||
['SIGN_IN_PREFILLED', 'true', 'Prefill the Signin form for usage in a demo or dev environment'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Workspace Cleaning
|
||||
|
||||
<ArticleTable options={[
|
||||
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
|
||||
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Captcha
|
||||
|
||||
<ArticleTable options={[
|
||||
['CAPTCHA_DRIVER', '', "The captcha driver can be 'google-recaptcha' or 'turnstile'"],
|
||||
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
|
||||
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
|
||||
]}></ArticleTable>
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: UI Kit
|
||||
icon: TbServer
|
||||
image: /images/user-guide/table-views/table.png
|
||||
---
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Twenty UI
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Storybook
|
||||
icon: TbRocket
|
||||
image: /images/user-guide/glossary/glossary.png
|
||||
---
|
||||
|
@ -0,0 +1,38 @@
|
||||
export const TWENTY_UI_INDEX = {
|
||||
Components: {
|
||||
Display: [
|
||||
{ fileName: 'display' },
|
||||
{ fileName: 'checkmark' },
|
||||
{ fileName: 'chip' },
|
||||
{ fileName: 'icons' },
|
||||
{ fileName: 'soon-pill' },
|
||||
{ fileName: 'tag' },
|
||||
{ fileName: 'app-tooltip' },
|
||||
],
|
||||
Feedback: [{ fileName: 'progress-bar' }],
|
||||
Input: [
|
||||
{ fileName: 'input' },
|
||||
{ fileName: 'buttons' },
|
||||
{ fileName: 'color-scheme' },
|
||||
{ fileName: 'text' },
|
||||
{ fileName: 'checkbox' },
|
||||
{ fileName: 'icon-picker' },
|
||||
{ fileName: 'image-input' },
|
||||
{ fileName: 'radio' },
|
||||
{ fileName: 'select' },
|
||||
{ fileName: 'toggle' },
|
||||
{ fileName: 'block-editor' },
|
||||
],
|
||||
Navigation: [
|
||||
{ fileName: 'navigation' },
|
||||
{ fileName: 'breadcrumb' },
|
||||
{ fileName: 'links' },
|
||||
{ fileName: 'menu-item' },
|
||||
{ fileName: 'navigation-bar' },
|
||||
{ fileName: 'step-bar' },
|
||||
],
|
||||
},
|
||||
Developers: {
|
||||
'Empty Section': [],
|
||||
},
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Display
|
||||
icon: IconUsers
|
||||
image: /images/user-guide/views/filter.png
|
||||
---
|
@ -0,0 +1,82 @@
|
||||
---
|
||||
title: App Tooltip
|
||||
icon: TbTooltip
|
||||
image: /images/user-guide/tips/light-bulb.png
|
||||
---
|
||||
|
||||
A brief message that displays additional information when a user interacts with an element.
|
||||
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { AppTooltip } from "@/ui/display/tooltip/AppTooltip";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<p id="hoverText" style={{ display: "inline-block" }}>
|
||||
Customer Insights
|
||||
</p>
|
||||
<AppTooltip
|
||||
className
|
||||
anchorSelect="#hoverText"
|
||||
content="Explore customer behavior and preferences"
|
||||
delayHide={0}
|
||||
offset={6}
|
||||
noArrow={false}
|
||||
isOpen={true}
|
||||
place="bottom"
|
||||
positionStrategy="absolute"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional CSS class for additional styling'],
|
||||
['anchorSelect', 'CSS selector', 'Selector for the tooltip anchor (the element that triggers the tooltip)'],
|
||||
['content', 'string', 'The content you want to display within the tooltip'],
|
||||
['delayHide', 'number', 'The delay in seconds before hiding the tooltip after the cursor leaves the anchor'],
|
||||
['offset', 'number', 'The offset in pixels for positioning the tooltip'],
|
||||
['noArrow', 'boolean', 'If `true`, hides the arrow on the tooltip'],
|
||||
['isOpen', 'boolean', 'If `true`, the tooltip is open by default'],
|
||||
['place', '`PlacesType` string from `react-tooltip`', 'Specifies the placement of the tooltip. Values include `bottom`, `left`, `right`, `top`, `top-start`, `top-end`, `right-start`, `right-end`, `bottom-start`, `bottom-end`, `left-start`, and `left-end`'],
|
||||
['positionStrategy', '`PositionStrategy` string from `react-tooltip`', 'Position strategy for the tooltip. Has two values: `absolute` and `fixed`']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Overflowing Text with Tooltip
|
||||
|
||||
Handles overflowing text and displays a tooltip when the text overflows.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
const crmTaskDescription =
|
||||
'Follow up with client regarding their recent product inquiry. Discuss pricing options, address any concerns, and provide additional product information. Record the details of the conversation in the CRM for future reference.';
|
||||
|
||||
return <OverflowingTextWithTooltip text={crmTaskDescription} />;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['text', 'string', 'The content you want to display in the overflowing text area']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Checkmark
|
||||
icon: TbCheck
|
||||
image: /images/user-guide/tasks/tasks_header.png
|
||||
---
|
||||
|
||||
Represents a successful or completed action.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Checkmark } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <Checkmark />;
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
Extends `React.ComponentPropsWithoutRef<'div'>` and accepts all the props of a regular `div` element.
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Animated Checkmark
|
||||
|
||||
Represents a checkmark icon with the added feature of animation.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { AnimatedCheckmark } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<AnimatedCheckmark
|
||||
isAnimating={true}
|
||||
color="green"
|
||||
duration={0.5}
|
||||
size={30}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['isAnimating', 'boolean', 'Controls whether the checkmark is animating', 'false'],
|
||||
['color', 'string', 'Color of the checkmark', "Theme's gray0"],
|
||||
['duration', 'number', 'The duration of the animation in seconds', '0.5 seconds'],
|
||||
['size', 'number', 'The size of the checkmark', '28 pixels']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
138
packages/twenty-website/src/content/twenty-ui/display/chip.mdx
Normal file
138
packages/twenty-website/src/content/twenty-ui/display/chip.mdx
Normal file
@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Chip
|
||||
icon: TbLayoutList
|
||||
image: /images/user-guide/github/github-header.png
|
||||
---
|
||||
|
||||
A visual element that you can use as a clickable or non-clickable container with a label, optional left and right components, and various styling options to display labels and tags.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Chip } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Chip
|
||||
size="large"
|
||||
label="Clickable Chip"
|
||||
clickable={true}
|
||||
variant="highlighted"
|
||||
accent="text-primary"
|
||||
leftComponent
|
||||
rightComponent
|
||||
maxWidth="200px"
|
||||
className
|
||||
/>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['linkToEntity', 'string', 'The link to the entity', ''],
|
||||
['entityId', 'string', 'The unique identifier for the entity', ''],
|
||||
['name', 'string', 'The name of the entity', ''],
|
||||
['pictureUrl', 'string', "The URL of the entity's picture", ''],
|
||||
['avatarType', 'Avatar Type', 'The type of avatar you want to display. Has two options: `rounded` and `squared`', 'rounded'],
|
||||
['variant', '`EntityChipVariant` enum', 'Variant of the entity chip you want to display. Has two options: `regular` and `transparent`', 'regular'],
|
||||
['LeftIcon', 'IconComponent', 'A React component representing an icon. Displayed on the left side of the chip', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
## Examples
|
||||
|
||||
### Transparent Disabled Chip
|
||||
|
||||
<SandpackEditor content={`import { Chip } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Chip
|
||||
size="large"
|
||||
label="Transparent Disabled Chip"
|
||||
clickable={false}
|
||||
variant="rounded"
|
||||
accent="text-secondary"
|
||||
leftComponent
|
||||
rightComponent
|
||||
maxWidth="200px"
|
||||
className
|
||||
/>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
<br/>
|
||||
|
||||
### Disabled Chip with Tooltip
|
||||
|
||||
<SandpackEditor content={`import { Chip } from "twenty-ui";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Chip
|
||||
size="large"
|
||||
label="Disabled chip that triggers a tooltip when overflowing."
|
||||
clickable={false}
|
||||
variant="regular"
|
||||
accent="text-primary"
|
||||
leftComponent
|
||||
rightComponent
|
||||
maxWidth="200px"
|
||||
className
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
|
||||
|
||||
## Entity Chip
|
||||
|
||||
A Chip-like element to display information about an entity.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { EntityChip, IconTwentyStar } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Router>
|
||||
<EntityChip
|
||||
linkToEntity="/entity-link"
|
||||
entityId="entityTest"
|
||||
name="Entity name"
|
||||
pictureUrl=""
|
||||
avatarType="rounded"
|
||||
variant="regular"
|
||||
LeftIcon={IconTwentyStar}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['linkToEntity', 'string', 'The link to the entity', ''],
|
||||
['entityId', 'string', 'The unique identifier for the entity', ''],
|
||||
['name', 'string', 'The name of the entity', ''],
|
||||
['pictureUrl', 'string', "The URL of the entity's picture", ''],
|
||||
['avatarType', 'Avatar Type', 'The type of avatar you want to display. Has two options: `rounded` and `squared`', 'rounded'],
|
||||
['variant', '`EntityChipVariant` enum', 'Variant of the entity chip you want to display. Has two options: `regular` and `transparent`', 'regular'],
|
||||
['LeftIcon', 'IconComponent', 'A React component representing an icon. Displayed on the left side of the chip', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Icons
|
||||
icon: TbIcons
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
||||
|
||||
A list of icons used throughout our app.
|
||||
|
||||
## Tabler Icons
|
||||
|
||||
We use Tabler icons for React throughout the app.
|
||||
|
||||
<ArticleTabs label1="Installation" label2="Usage" label3="Props">
|
||||
|
||||
<ArticleTab>
|
||||
<br/>
|
||||
|
||||
```
|
||||
yarn add @tabler/icons-react
|
||||
```
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
You can import each icon as a component. Here's an example:
|
||||
<br />
|
||||
|
||||
<SandpackEditor content={`import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <IconArrowLeft color="red" size={48} />;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['size', 'number', 'The height and width of the icon in pixels', '24'],
|
||||
['color', 'string', 'The color of the icons', 'currentColor'],
|
||||
['stroke', 'number', 'The stroke width of the icon in pixels', '2']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
|
||||
|
||||
## Custom Icons
|
||||
|
||||
In addition to Tabler icons, the app also uses some custom icons.
|
||||
|
||||
### Icon Address Book
|
||||
|
||||
Displays an address book icon.
|
||||
|
||||
<ArticleTabs label1="Installation" label2="Usage">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { IconAddressBook } from 'twenty-ui';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <IconAddressBook size={24} stroke={2} />;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['size', 'number', 'The height and width of the icon in pixels', '24'],
|
||||
['stroke', 'number', 'The stroke width of the icon in pixels', '2']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Soon Pill
|
||||
icon: TbPill
|
||||
image: /images/user-guide/kanban-views/kanban.png
|
||||
---
|
||||
|
||||
A small badge or "pill" to indicate something is coming soon.
|
||||
|
||||
<SandpackEditor content={`import { SoonPill } from "@/ui/display/pill/components/SoonPill";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <SoonPill />;
|
||||
};`} />
|
||||
|
@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Tag
|
||||
icon: TbTag
|
||||
image: /images/user-guide/table-views/table.png
|
||||
---
|
||||
|
||||
Component to visually categorize or label content.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Tag } from "@/ui/display/tag/components/Tag";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Tag
|
||||
className
|
||||
color="red"
|
||||
text="Urgent"
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling', ''],
|
||||
['color', 'string', 'Color of the tag. Options include: `green`, `turquoise`, `sky`, `blue`, `purple`, `pink`, `red`, `orange`, `yellow`, `gray`', ''],
|
||||
['text', 'string', 'The content of the tag', ''],
|
||||
['onClick', 'function', 'Optional function called when a user clicks on the tag', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
5
packages/twenty-website/src/content/twenty-ui/input.mdx
Normal file
5
packages/twenty-website/src/content/twenty-ui/input.mdx
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Input
|
||||
icon: IconUsers
|
||||
image: /images/user-guide/tips/light-bulb.png
|
||||
---
|
@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Block Editor
|
||||
icon: TbTemplate
|
||||
image: /images/user-guide/api/api.png
|
||||
---
|
||||
|
||||
Uses a block-based rich text editor from [BlockNote](https://www.blocknotejs.org/) to allow users to edit and view blocks of content.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { useBlockNote } from "@blocknote/react";
|
||||
import { BlockEditor } from "@/ui/input/editor/components/BlockEditor";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const BlockNoteEditor = useBlockNote();
|
||||
|
||||
return <BlockEditor editor={BlockNoteEditor} />;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['editor', '`BlockNoteEditor`', 'The block editor instance or configuration']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
470
packages/twenty-website/src/content/twenty-ui/input/buttons.mdx
Normal file
470
packages/twenty-website/src/content/twenty-ui/input/buttons.mdx
Normal file
@ -0,0 +1,470 @@
|
||||
---
|
||||
title: Buttons
|
||||
image: /images/user-guide/views/filter.png
|
||||
---
|
||||
|
||||
A list of buttons and button groups used throughout the app.
|
||||
|
||||
## Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Button } from "@/ui/input/button/components/Button";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Button
|
||||
className
|
||||
Icon={null}
|
||||
title="Click Me"
|
||||
fullWidth={false}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
position="standalone"
|
||||
accent="default"
|
||||
soon={false}
|
||||
disabled={false}
|
||||
focus={true}
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional class name for additional styling', ''],
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button", ''],
|
||||
['title', 'string', 'The text content of the button', ''],
|
||||
['fullWidth', 'boolean', 'Defines whether the button should span the whole width of its container', '`false`'],
|
||||
['variant', 'string', 'The visual style variant of the button. Options include `primary`, `secondary`, and `tertiary`', 'primary'],
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`', 'medium'],
|
||||
['position', 'string', 'The position of the button in relation to its siblings. Options include: `standalone`, `left`, `right`, and `middle`', 'standalone'],
|
||||
['accent', 'string', 'The accent color of the button. Options include: `default`, `blue`, and `danger`', 'default'],
|
||||
['soon', 'boolean', 'Indicates if the button is marked as "soon" (such as for upcoming features)', '`false`'],
|
||||
['disabled', 'boolean', 'Specifies whether the button is disabled or not', '`false`'],
|
||||
['focus', 'boolean', 'Determines if the button has focus', '`false`'],
|
||||
['onClick', 'function', 'A callback function that triggers when the user clicks on the button', '']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Button Group
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
<SandpackEditor content={`import { Button } from "@/ui/input/button/components/Button";
|
||||
import { ButtonGroup } from "@/ui/input/button/components/ButtonGroup";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<ButtonGroup variant="primary" size="large" accent="blue" className>
|
||||
<Button
|
||||
className
|
||||
Icon={null}
|
||||
title="Button 1"
|
||||
fullWidth={false}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
position="standalone"
|
||||
accent="blue"
|
||||
soon={false}
|
||||
disabled={false}
|
||||
focus={false}
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
<Button
|
||||
className
|
||||
Icon={null}
|
||||
title="Button 2"
|
||||
fullWidth={false}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
position="left"
|
||||
accent="blue"
|
||||
soon={false}
|
||||
disabled={false}
|
||||
focus={false}
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
<Button
|
||||
className
|
||||
Icon={null}
|
||||
title="Button 3"
|
||||
fullWidth={false}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
position="right"
|
||||
accent="blue"
|
||||
soon={false}
|
||||
disabled={false}
|
||||
focus={false}
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['variant', 'string', 'The visual style variant of the buttons within the group. Options include `primary`, `secondary`, and `tertiary`'],
|
||||
['size', 'string', 'The size of the buttons within the group. Has two options: `medium` and `small`'],
|
||||
['accent', 'string', 'The accent color of the buttons within the group. Options include `default`, `blue` and `danger`'],
|
||||
['className', 'string', 'Optional class name for additional styling'],
|
||||
['children', 'ReactNode', 'An array of React elements representing the individual buttons within the group']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
|
||||
## Floating Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<FloatingButton
|
||||
className
|
||||
Icon={IconSearch}
|
||||
title="Click Me"
|
||||
size="medium"
|
||||
position="standalone"
|
||||
applyShadow={true}
|
||||
applyBlur={true}
|
||||
disabled={false}
|
||||
focus={true}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling', ''],
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button", ''],
|
||||
['title', 'string', 'The text content of the button', ''],
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`', 'small'],
|
||||
['position', 'string', 'The position of the button in relation to its siblings. Options include: `standalone`, `left`, `middle`, `right`', ''],
|
||||
['applyShadow', 'boolean', 'Determines whether to apply shadow to a button', '`true`'],
|
||||
['applyBlur', 'boolean', 'Determines whether to apply a blur effect to the button', '`true`'],
|
||||
['disabled', 'boolean', 'Determines whether the button is disabled', '`false`'],
|
||||
['focus', 'boolean', 'Indicates if the button has focus', '`false`']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
## Floating Button Group
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
|
||||
import { FloatingButtonGroup } from "@/ui/input/button/components/FloatingButtonGroup";
|
||||
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<FloatingButtonGroup size="small">
|
||||
<FloatingButton
|
||||
className
|
||||
Icon={IconClipboardText}
|
||||
title
|
||||
size="small"
|
||||
position="standalone"
|
||||
applyShadow={true}
|
||||
applyBlur={true}
|
||||
disabled={false}
|
||||
focus={true}
|
||||
/>
|
||||
<FloatingButton
|
||||
className
|
||||
Icon={IconCheckbox}
|
||||
title
|
||||
size="small"
|
||||
position="standalone"
|
||||
applyShadow={true}
|
||||
applyBlur={true}
|
||||
disabled={false}
|
||||
/>
|
||||
</FloatingButtonGroup>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`', 'small'],
|
||||
['children', 'ReactNode', 'An array of React elements representing the individual buttons within the group', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Floating Icon Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { FloatingIconButton } from "@/ui/input/button/components/FloatingIconButton";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<FloatingIconButton
|
||||
className
|
||||
Icon={IconSearch}
|
||||
size="small"
|
||||
position="standalone"
|
||||
applyShadow={true}
|
||||
applyBlur={true}
|
||||
disabled={false}
|
||||
focus={false}
|
||||
onClick={() => console.log("click")}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling', ''],
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button", ''],
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`', 'small'],
|
||||
['position', 'string', 'The position of the button in relation to its siblings. Options include: `standalone`, `left`, `right`, and `middle`', 'standalone'],
|
||||
['applyShadow', 'boolean', 'Determines whether to apply shadow to a button', '`true`'],
|
||||
['applyBlur', 'boolean', 'Determines whether to apply a blur effect to the button', '`true`'],
|
||||
['disabled', 'boolean', 'Determines whether the button is disabled', '`false`'],
|
||||
['focus', 'boolean', 'Indicates if the button has focus', '`false`'],
|
||||
['onClick', 'function', 'A callback function that triggers when the user clicks on the button', ''],
|
||||
['isActive', 'boolean', 'Determines if the button is in an active state', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Floating Icon Button Group
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { FloatingIconButtonGroup } from "@/ui/input/button/components/FloatingIconButtonGroup";
|
||||
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const iconButtons = [
|
||||
{
|
||||
Icon: IconClipboardText,
|
||||
onClick: () => console.log("Button 1 clicked"),
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
Icon: IconCheckbox,
|
||||
onClick: () => console.log("Button 2 clicked"),
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FloatingIconButtonGroup
|
||||
className
|
||||
size="small"
|
||||
iconButtons={iconButtons} />
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling'],
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`'],
|
||||
['iconButtons', 'array', 'An array of objects, each representing an icon button in the group. Each object should include the icon component you want to display in the button, the function you want to call when a user clicks on the button, and whether the button should be active or not.']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Light Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { LightButton } from "@/ui/input/button/components/LightButton";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <LightButton
|
||||
className
|
||||
icon={null}
|
||||
title="Click Me"
|
||||
accent="secondary"
|
||||
active={false}
|
||||
disabled={false}
|
||||
focus={true}
|
||||
onClick={()=>console.log('click')}
|
||||
/>;
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling', ''],
|
||||
['icon', '`React.ReactNode`', 'The icon you want to display in the button', ''],
|
||||
['title', 'string', 'The text content of the button', ''],
|
||||
['accent', 'string', 'The accent color of the button. Options include: `secondary` and `tertiary`', 'secondary'],
|
||||
['active', 'boolean', 'Determines if the button is in an active state', '`false`'],
|
||||
['disabled', 'boolean', 'Determines whether the button is disabled', '`false`'],
|
||||
['focus', 'boolean', 'Indicates if the button has focus', '`false`'],
|
||||
['onClick', 'function', 'A callback function that triggers when the user clicks on the button', '']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
|
||||
## Light Icon Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { LightIconButton } from "@/ui/input/button/components/LightIconButton";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<LightIconButton
|
||||
className
|
||||
testId="test1"
|
||||
Icon={IconSearch}
|
||||
title="Click Me"
|
||||
size="small"
|
||||
accent="secondary"
|
||||
active={true}
|
||||
disabled={false}
|
||||
focus={true}
|
||||
onClick={() => console.log("click")}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional name for additional styling', ''],
|
||||
['testId', 'string', 'Test identifier for the button', ''],
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button", ''],
|
||||
['title', 'string', 'The text content of the button', ''],
|
||||
['size', 'string', 'The size of the button. Has two options: `small` and `medium`', 'small'],
|
||||
['accent', 'string', 'The accent color of the button. Options include: `secondary` and `tertiary`', 'secondary'],
|
||||
['active', 'boolean', 'Determines if the button is in an active state', '`false`'],
|
||||
['disabled', 'boolean', 'Determines whether the button is disabled', '`false`'],
|
||||
['focus', 'boolean', 'Indicates if the button has focus', '`false`'],
|
||||
['onClick', 'function', 'A callback function that triggers when the user clicks on the button', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Main Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { MainButton } from "@/ui/input/button/components/MainButton";
|
||||
import { IconCheckbox } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<MainButton
|
||||
title="Checkbox"
|
||||
fullWidth={false}
|
||||
variant="primary"
|
||||
soon={false}
|
||||
Icon={IconCheckbox}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['title', 'string', 'The text content of the button', ''],
|
||||
['fullWidth', 'boolean', 'Defines whether the button should span the whole width of its container', '`false`'],
|
||||
['variant', 'string', 'The visual style variant of the button. Options include `primary` and `secondary`', 'primary'],
|
||||
['soon', 'boolean', 'Indicates if the button is marked as "soon" (such as for upcoming features)', '`false`'],
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button", ''],
|
||||
['React `button` props', '`React.ComponentProps<\'button\'>`', "Additional props from React's `button` element", '']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Rounded Icon Button
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { RoundedIconButton } from "@/ui/input/button/components/RoundedIconButton";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RoundedIconButton
|
||||
Icon={IconSearch}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['Icon', '`React.ComponentType`', "An optional icon component that's displayed within the button"],
|
||||
['React `button` props', '`React.ButtonHTMLAttributes<HTMLButtonElement>`', "Additional props from React's `button` element"]
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Checkbox
|
||||
icon: TbCheckbox
|
||||
image: /images/user-guide/tasks/tasks_header.png
|
||||
---
|
||||
|
||||
Used when a user needs to select multiple values from several options.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Checkbox } from "@/ui/input/components/Checkbox";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={true}
|
||||
indeterminate={false}
|
||||
onChange={() => console.log("onChange function fired")}
|
||||
onCheckedChange={() => console.log("onCheckedChange function fired")}
|
||||
variant="primary"
|
||||
size="small"
|
||||
shape="squared"
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['checked', 'boolean', 'Indicates whether the checkbox is checked', ''],
|
||||
['indeterminate', 'boolean', 'Indicates whether the checkbox is in an indeterminate state (neither checked nor unchecked)', ''],
|
||||
['onChange', 'function', 'The callback function you want to trigger when the checkbox state changes', ''],
|
||||
['onCheckedChange', 'function', 'The callback function you want to trigger when the `checked` state changes', ''],
|
||||
['variant', 'string', 'The visual style variant of the box. Options include: `primary`, `secondary`, and `tertiary`', 'primary'],
|
||||
['size', 'string', 'The size of the checkbox. Has two options: `small` and `large`', 'small'],
|
||||
['shape', 'string', 'The shape of the checkbox. Has two options: `squared` and `rounded`', 'squared']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Color Scheme
|
||||
icon: TbColorFilter
|
||||
image: /images/user-guide/fields/field.png
|
||||
---
|
||||
|
||||
## Color Scheme Card
|
||||
|
||||
Represents different color schemes and is specially tailored for light and dark themes.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { ColorSchemeCard } from "@/ui/input/color-scheme/components/ColorSchemeCard";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<ColorSchemeCard
|
||||
variant="Dark"
|
||||
selected={true}
|
||||
/>
|
||||
);
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['variant', 'string', 'The color scheme variant. Options include `Dark`, `Light`, and `System`', 'light'],
|
||||
['selected', 'boolean', 'If `true`, displays a checkmark to indicate the selected color scheme', ''],
|
||||
['additional props', '`React.ComponentPropsWithoutRef<\'div\'>`', 'Standard HTML `div` element props', '']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
||||
|
||||
## Color Scheme Picker
|
||||
|
||||
Allows users to choose between different color schemes.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { ColorSchemePicker } from "@/ui/input/color-scheme/components/ColorSchemePicker";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <ColorSchemePicker
|
||||
value="Dark"
|
||||
onChange
|
||||
/>;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['value', '`Color Scheme`', 'The currently selected color scheme'],
|
||||
['onChange', 'function', 'The callback function you want to trigger when a user selects a color scheme']
|
||||
]} />
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Icon Picker
|
||||
icon: TbColorPicker
|
||||
image: /images/user-guide/github/github-header.png
|
||||
---
|
||||
|
||||
A dropdown-based icon picker that allows users to select an icon from a list.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
export const MyComponent = () => {
|
||||
|
||||
const [selectedIcon, setSelectedIcon] = useState("");
|
||||
const handleIconChange = ({ iconKey, Icon }) => {
|
||||
console.log("Selected Icon:", iconKey);
|
||||
setSelectedIcon(iconKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['disabled', 'boolean', 'Disables the icon picker if set to `true`', ''],
|
||||
['onChange', 'function', 'The callback function triggered when the user selects an icon. It receives an object with `iconKey` and `Icon` properties', ''],
|
||||
['selectedIconKey', 'string', 'The key of the initially selected icon', ''],
|
||||
['onClickOutside', 'function', 'Callback function triggered when the user clicks outside the dropdown', ''],
|
||||
['onClose', 'function', 'Callback function triggered when the dropdown is closed', ''],
|
||||
['onOpen', 'function', 'Callback function triggered when the dropdown is opened', ''],
|
||||
['variant', 'string', 'The visual style variant of the clickable icon. Options include: `primary`, `secondary`, and `tertiary`', 'secondary']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Image Input
|
||||
icon: TbUpload
|
||||
image: /images/user-guide/objects/objects.png
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { SandpackEditor} from '@site/src/ui/SandpackEditor'
|
||||
import imageInputCode from '!!raw-loader!@site/src/ui/input/components/imageInputCode.js'
|
||||
|
||||
Allows users to upload and remove an image.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { ImageInput } from "@/ui/input/components/ImageInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <ImageInput/>;
|
||||
};`} />
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['picture', 'string', 'The image source URL', ''],
|
||||
['onUpload', 'function', 'The function called when a user uploads a new image. It receives the `File` object as a parameter', ''],
|
||||
['onRemove', 'function', 'The function called when the user clicks on the remove button', ''],
|
||||
['onAbort', 'function', 'The function called when a user clicks on the abort button during image upload', ''],
|
||||
['isUploading', 'boolean', 'Indicates whether an image is currently being uploaded', '`false`'],
|
||||
['errorMessage', 'string', 'An optional error message to display below the image input', ''],
|
||||
['disabled', 'boolean', 'If `true`, the entire input is disabled, and the buttons are not clickable', '`false`']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
102
packages/twenty-website/src/content/twenty-ui/input/radio.mdx
Normal file
102
packages/twenty-website/src/content/twenty-ui/input/radio.mdx
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Radio
|
||||
icon: TbCircleDot
|
||||
image: /images/user-guide/create-workspace/workspace-cover.png
|
||||
---
|
||||
|
||||
Used when users may only choose one option from a series of options.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { Radio } from "@/ui/input/components/Radio";
|
||||
|
||||
export const MyComponent = () => {
|
||||
|
||||
const handleRadioChange = (event) => {
|
||||
console.log("Radio button changed:", event.target.checked);
|
||||
};
|
||||
|
||||
const handleCheckedChange = (checked) => {
|
||||
console.log("Checked state changed:", checked);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Radio
|
||||
checked={true}
|
||||
value="Option 1"
|
||||
onChange={handleRadioChange}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
size="large"
|
||||
disabled={false}
|
||||
labelPosition="right"
|
||||
/>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['style', '`React.CSS` properties', 'Additional inline styles for the component', ''],
|
||||
['className', 'string', 'Optional CSS class for additional styling', ''],
|
||||
['checked', 'boolean', 'Indicates whether the radio button is checked', ''],
|
||||
['value', 'string', 'The label or text associated with the radio button', ''],
|
||||
['onChange', 'function', 'The function called when the selected radio button is changed', ''],
|
||||
['onCheckedChange', 'function', 'The function called when the `checked` state of the radio button changes', ''],
|
||||
['size', 'string', 'The size of the radio button. Options include: `large` and `small`', 'small'],
|
||||
['disabled', 'boolean', 'If `true`, the radio button is disabled and not clickable', 'false'],
|
||||
['labelPosition', 'string', 'The position of the label text relative to the radio button. Has two options: `left` and `right`', 'right']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
||||
|
||||
|
||||
## Radio Group
|
||||
|
||||
Groups together related radio buttons.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import React, { useState } from "react";
|
||||
import { Radio } from "@/ui/input/components/Radio";
|
||||
import { RadioGroup } from "@/ui/input/components/RadioGroup";
|
||||
|
||||
export const MyComponent = () => {
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState("Option 1");
|
||||
|
||||
const handleChange = (event) => {
|
||||
setSelectedValue(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup value={selectedValue} onChange={handleChange}>
|
||||
<Radio value="Option 1" />
|
||||
<Radio value="Option 2" />
|
||||
<Radio value="Option 3" />
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['value', 'string', 'The value of the currently selected radio button'],
|
||||
['onChange', 'function', 'The callback function triggered when the radio button is changed'],
|
||||
['onValueChange', 'function', 'The callback function triggered when the selected value in the group changes.'],
|
||||
['children', '`React.ReactNode`', 'Allows you to pass React components (such as Radio) as children to the Radio Group']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
|
||||
</ArticleTabs>
|
@ -0,0 +1,52 @@
|
||||
---
|
||||
title: Select
|
||||
icon: TbSelect
|
||||
image: /images/user-guide/what-is-twenty/20.png
|
||||
---
|
||||
|
||||
Allows users to pick a value from a list of predefined options.
|
||||
|
||||
<ArticleTabs label1="Usage" label2="Props">
|
||||
<ArticleTab>
|
||||
|
||||
<SandpackEditor content={`import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
dropdownScopeId="exampleDropdown"
|
||||
label="Select an option"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Option B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
`} />
|
||||
|
||||
</ArticleTab>
|
||||
<ArticleTab>
|
||||
|
||||
<ArticlePropsTable options={[
|
||||
['className', 'string', 'Optional CSS class for additional styling'],
|
||||
['disabled', 'boolean', 'When set to `true`, disables user interaction with the component'],
|
||||
['dropdownScopeId', 'string', 'Required prop that uniquely identifies the dropdown scope'],
|
||||
['label', 'string', 'The label to describe the purpose of the `Select` component'],
|
||||
['onChange', 'function', 'The function called when the selected values change'],
|
||||
['options', 'array', "Represents the options available for the `Selected` component. It's an array of objects where each object has a `value` (the unique identifier), `label` (the unique identifier), and an optional `Icon`"],
|
||||
['value', 'string', 'Represents the currently selected value. It should match one of the `value` properties in the `options` array']
|
||||
]} />
|
||||
|
||||
|
||||
</ArticleTab>
|
||||
</ArticleTabs>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user