Files
twenty/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx
T
Charles Bochet fea47aa9f8 Add twenty/folder-structure custom oxlint rule (#18467)
## Summary

- Re-implements `eslint-plugin-project-structure`'s folder structure
enforcement as a custom oxlint rule (`twenty/folder-structure`),
recovering functionality lost during the ESLint → Oxlint migration
- Validates `src/modules/` structure: kebab-case module folder names,
allowed subdirectories (hooks, utils, components, states, types,
graphql, etc.), hook file naming (`use{PascalCase}.(ts|tsx)`), util file
naming (`{camelCase}.(ts|tsx)`), and module nesting depth (max 4 levels)
- Enabled as `"warn"` in twenty-front with 403 pre-existing violations
to address incrementally

## What the rule checks

| Check | Example valid | Example invalid |
|-------|-------------|-----------------|
| Module names kebab-case | `object-record/` | `graphWidgetBarChart/` |
| Allowed subdirs only | `hooks/`, `components/`, `utils/` |
`random-stuff/` |
| Hook file naming | `useMyHook.ts` | `badName.ts` |
| Util file naming | `buildQuery.ts` | `build-query.ts` |
| Max nesting depth 4 | `a/b/c/d/hooks/` | `a/b/c/d/e/hooks/` |
| Utils kebab-case subfolders | `utils/cron-to-human/` |
`utils/camelCase/` |

## Pre-existing violations (403 total)

| Category | Count | Examples |
|----------|-------|---------|
| Non-kebab-case module names | 160 | `graphWidgetBarChart`,
`AIChatThreads` |
| Module depth > 4 | 215 |
`settings/roles/role-permissions/object-level-permissions/field-permissions`
|
| Util file naming | 22 | `.util.ts` suffix, kebab-case, PascalCase
filenames |
| Misc (hooks, tests) | 6 | Non-hook files in hooks/, folders in test
dirs |
2026-03-06 17:02:46 +00:00

211 lines
7.0 KiB
TypeScript

import { Section } from 'twenty-ui/layout';
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
import { Trans, useLingui } from '@lingui/react/macro';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { TextInput } from '@/ui/input/components/TextInput';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { Button, ButtonGroup } from 'twenty-ui/input';
import { styled } from '@linaria/react';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
import {
useCreatePublicDomainMutation,
useDeletePublicDomainMutation,
useFindManyPublicDomainsQuery,
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CheckPublicDomainValidRecordsEffect } from '@/settings/domains/components/CheckPublicDomainValidRecordsEffect';
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useState } from 'react';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonGroupContainer = styled.div`
> * > :not(:first-of-type) > button {
border-left: none;
}
`;
const StyledButtonContainer = styled.div`
align-self: flex-start;
`;
const StyledDomainFormWrapper = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledRecordsWrapper = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
& > :not(:first-of-type) {
margin-top: ${themeCssVariables.spacing[4]};
}
`;
export const SettingPublicDomain = () => {
const [selectedPublicDomain, setSelectedPublicDomain] = useAtomState(
selectedPublicDomainState,
);
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [createPublicDomain, { loading }] = useCreatePublicDomainMutation();
const [newPublicDomain, setNewPublicDomain] = useState<string | undefined>(
selectedPublicDomain?.domain ?? '',
);
const [newPublicDomainError, setNewPublicDomainError] = useState<
string | undefined
>(undefined);
const { refetch: refetchPublicDomains } = useFindManyPublicDomainsQuery();
const [deletePublicDomain] = useDeletePublicDomainMutation();
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
useCheckPublicDomainValidRecords();
const onDelete = async () => {
if (!selectedPublicDomain) {
return;
}
await deletePublicDomain({
variables: { domain: selectedPublicDomain.domain },
onCompleted: () => {
enqueueSuccessSnackBar({
message: t`Public domain successfully deleted`,
});
navigate(SettingsPath.Domains);
refetchPublicDomains();
},
onError: (error) =>
enqueueErrorSnackBar({
apolloError: error,
}),
});
};
const validationSchema = getDomainValidationSchema(t);
const onCreate = async () => {
if (!isDefined(newPublicDomain)) {
return;
}
const result = validationSchema.safeParse(newPublicDomain);
if (!result.success) {
setNewPublicDomainError(result.error?.issues[0].message);
return;
}
setNewPublicDomainError(undefined);
await createPublicDomain({
variables: { domain: newPublicDomain },
onCompleted: (data) => {
setSelectedPublicDomain(data.createPublicDomain);
enqueueSuccessSnackBar({
message: t`Public domain created successfully`,
});
},
onError: (error) => {
setNewPublicDomainError(error.message);
enqueueErrorSnackBar({
apolloError: error,
});
},
});
};
return (
<SubMenuTopBarContainer
title={t`Public domain`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Public Domain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={loading || isDefined(selectedPublicDomain)}
onSave={onCreate}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Public domain`}
description={t`Set the name of your public domain and configure your DNS records.`}
/>
{isDefined(selectedPublicDomain) && (
<CheckPublicDomainValidRecordsEffect
publicDomain={selectedPublicDomain}
/>
)}
<StyledDomainFormWrapper>
<TextInput
value={newPublicDomain}
onChange={setNewPublicDomain}
error={newPublicDomainError}
type="text"
disabled={isDefined(selectedPublicDomain)}
placeholder="crm.yourPublicDomain.com"
fullWidth
/>
{isDefined(selectedPublicDomain) && (
<StyledButtonGroupContainer>
<ButtonGroup>
<StyledButtonContainer>
<Button
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={() =>
checkPublicDomainRecords(selectedPublicDomain.domain)
}
type="button"
/>
</StyledButtonContainer>
<StyledButtonContainer>
<Button
Icon={IconTrash}
variant="primary"
onClick={onDelete}
/>
</StyledButtonContainer>
</ButtonGroup>
</StyledButtonGroupContainer>
)}
</StyledDomainFormWrapper>
{isDefined(selectedPublicDomain) && publicDomainRecords?.domain && (
<StyledRecordsWrapper>
{isDefined(publicDomainRecords.records) && (
<SettingsDomainRecords records={publicDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};