1859 extensibility improve application settings section (#16786)

## After
<img width="934" height="750" alt="image"
src="https://github.com/user-attachments/assets/f2d7f743-fa4f-4e7d-a060-033f6087e4b6"
/>
<img width="1008" height="652" alt="image"
src="https://github.com/user-attachments/assets/3d36bfae-aa30-4946-88f2-e99bf074f768"
/>
This commit is contained in:
martmull
2025-12-24 14:35:36 +01:00
committed by GitHub
parent 2eb8cf9e81
commit 158a7a89d5
27 changed files with 387 additions and 397 deletions
+5 -5
View File
@@ -1083,7 +1083,7 @@ __metadata:
resolution: "hello-world@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:0.2.1"
twenty-sdk: "npm:0.2.4"
languageName: unknown
linkType: soft
@@ -1904,9 +1904,9 @@ __metadata:
languageName: node
linkType: hard
"twenty-sdk@npm:0.2.1":
version: 0.2.1
resolution: "twenty-sdk@npm:0.2.1"
"twenty-sdk@npm:0.2.4":
version: 0.2.4
resolution: "twenty-sdk@npm:0.2.4"
dependencies:
"@genql/cli": "npm:^3.0.3"
axios: "npm:^1.6.0"
@@ -1927,7 +1927,7 @@ __metadata:
uuid: "npm:^13.0.0"
bin:
twenty: dist/cli.cjs
checksum: 10c0/5a4f4674129cc747e8e5091bf75025260db20087f76f1cee49896a073c8cf83b8c95e1dce3f2f2cbaaf9570aa1886c4c9a5585f0e61e0ee79552eab4d292fb4e
checksum: 10c0/91bcc2c6a96d8bb7997c6584de338d370ffd4cebbc052ffdd5dd8bb8894f1851c039601a2ce0bb0f3b97e210f3bc877de0572125b8c3b601f6e1baac2f99a8c5
languageName: node
linkType: hard
+4
View File
@@ -32,6 +32,10 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;600&display=swap"
/>
<title>Twenty</title>
<!-- BEGIN: Twenty Config -->
@@ -136,6 +136,13 @@ export const SettingsObjectFieldItemTableRow = ({
fieldName: fieldMetadataItem.name,
});
// eslint-disable-next-line @nx/workspace-no-navigate-prefer-link
const navigateToFieldEdit = () =>
navigate(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
});
const { activateMetadataField } = useFieldMetadataItem();
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
@@ -195,15 +202,7 @@ export const SettingsObjectFieldItemTableRow = ({
return (
<StyledObjectFieldTableRow
onClick={
mode === 'view'
? () =>
navigate(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
})
: undefined
}
onClick={mode === 'view' ? navigateToFieldEdit : undefined}
>
<UndecoratedLink to={linkToNavigate}>
<StyledNameTableCell>
@@ -251,12 +250,7 @@ export const SettingsObjectFieldItemTableRow = ({
<StyledIconTableCell>
{status === 'active' ? (
mode === 'view' ? (
<UndecoratedLink
to={getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
})}
>
<UndecoratedLink to={linkToNavigate}>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
@@ -276,12 +270,7 @@ export const SettingsObjectFieldItemTableRow = ({
isCustomField={fieldMetadataItem.isCustom === true}
readonly={readonly}
fieldMetadataItemId={fieldMetadataItem.id}
onEdit={() =>
navigate(SettingsPath.ObjectFieldEdit, {
objectNamePlural: objectMetadataItem.namePlural,
fieldName: fieldMetadataItem.name,
})
}
onEdit={navigateToFieldEdit}
onActivate={() =>
activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id)
}
@@ -217,7 +217,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`Documentation`,
onClick: () =>
window.open(
getDocumentationUrl(currentWorkspaceMember?.locale),
getDocumentationUrl({ locale: currentWorkspaceMember?.locale }),
'_blank',
),
Icon: IconHelpCircle,
@@ -1,19 +1,20 @@
import styled from '@emotion/styled';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { type ServerlessFunction } from '~/generated-metadata/graphql';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useTheme } from '@emotion/react';
import { IconChevronRight } from 'twenty-ui/display';
export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
`;
import { StyledTableRow } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledRuntimeTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.secondary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
@@ -32,15 +33,18 @@ export const SettingsServerlessFunctionsFieldItemTableRow = ({
}) => {
const theme = useTheme();
return (
<StyledApisFieldTableRow to={to}>
<StyledTableRow to={to}>
<StyledNameTableCell>{serverlessFunction.name}</StyledNameTableCell>
<StyledNameTableCell>{serverlessFunction.runtime}</StyledNameTableCell>
<StyledNameTableCell></StyledNameTableCell>
<StyledRuntimeTableCell>
{serverlessFunction.runtime}
</StyledRuntimeTableCell>
<StyledIconTableCell>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconTableCell>
</StyledApisFieldTableRow>
</StyledTableRow>
);
};
@@ -10,8 +10,8 @@ import { type ServerlessFunction } from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
export const StyledTableRow = styled(TableRow)`
grid-template-columns: 164px 1fr 96px 32px;
`;
const StyledTableBody = styled(TableBody)`
@@ -35,6 +35,7 @@ export const SettingsServerlessFunctionsTable = ({
<Table>
<StyledTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader></TableHeader>
<TableHeader>{t`Runtime`}</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
@@ -1,4 +1,3 @@
import { type EnvironmentVariable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -19,6 +18,7 @@ import {
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import type { ApplicationVariable } from '~/generated/graphql';
const StyledEditModeTableRow = styled(TableRow)`
grid-template-columns: 180px auto 56px;
@@ -34,8 +34,8 @@ export const SettingsServerlessFunctionTabEnvironmentVariableTableRow = ({
onDelete,
initialEditMode = false,
}: {
envVariable: EnvironmentVariable;
onChange: (newEnvVariable: EnvironmentVariable) => void;
envVariable: ApplicationVariable;
onChange: (newEnvVariable: ApplicationVariable) => void;
onDelete: () => void;
initialEditMode?: boolean;
}) => {
@@ -4,17 +4,23 @@ const DOCUMENTATION_PATH = '/user-guide/introduction';
// Locales that have documentation translations available
const SUPPORTED_DOC_LOCALES = ['fr', 'pt', 'de', 'es', 'it', 'ja', 'ko', 'zh'];
export const getDocumentationUrl = (locale?: string | null): string => {
export const getDocumentationUrl = ({
locale,
path = DOCUMENTATION_PATH,
}: {
locale?: string | null;
path?: string;
}): string => {
if (!locale) {
return `${DOCUMENTATION_BASE_URL}${DOCUMENTATION_PATH}`;
return `${DOCUMENTATION_BASE_URL}${path}`;
}
// Extract language code from locale (e.g., 'fr' from 'fr-FR')
const langCode = locale.split('-')[0].toLowerCase();
if (SUPPORTED_DOC_LOCALES.includes(langCode)) {
return `${DOCUMENTATION_BASE_URL}/l/${langCode}${DOCUMENTATION_PATH}`;
return `${DOCUMENTATION_BASE_URL}/l/${langCode}${path}`;
}
return `${DOCUMENTATION_BASE_URL}${DOCUMENTATION_PATH}`;
return `${DOCUMENTATION_BASE_URL}${path}`;
};
@@ -84,7 +84,10 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
};
const handleDocumentation = () => {
window.open(getDocumentationUrl(currentWorkspaceMember?.locale), '_blank');
window.open(
getDocumentationUrl({ locale: currentWorkspaceMember?.locale }),
'_blank',
);
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
};
@@ -5,7 +5,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { useParams } from 'react-router-dom';
import { useFindOneApplicationQuery } from '~/generated-metadata/graphql';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { IconInfoCircle, IconSettings, IconBroadcast } from 'twenty-ui/display';
import { IconInfoCircle, IconSettings, IconBox } from 'twenty-ui/display';
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
@@ -39,22 +39,22 @@ export const SettingsApplicationDetails = () => {
const tabs = [
{ id: 'about', title: t`About`, Icon: IconInfoCircle },
{ id: 'content', title: t`Content`, Icon: IconBox },
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
{ id: 'content', title: t`Content`, Icon: IconBroadcast },
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'about':
return <SettingsApplicationDetailAboutTab application={application} />;
case 'settings':
return (
<SettingsApplicationDetailSettingsTab application={application} />
);
case 'content':
return (
<SettingsApplicationDetailContentTab application={application} />
);
case 'settings':
return (
<SettingsApplicationDetailSettingsTab application={application} />
);
default:
return <></>;
}
@@ -1,22 +1,56 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import {
H2Title,
CommandBlock,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useLingui } from '@lingui/react/macro';
import { Button } from 'twenty-ui/input';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
const StyledNoApplicationContainer = styled.div``;
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
export const SettingsApplications = () => {
const { t } = useLingui();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { data } = useFindManyApplicationsQuery();
const { copyToClipboard } = useCopyToClipboard();
const applications = data?.findManyApplications ?? [];
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const button = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<SubMenuTopBarContainer
title={t`Applications`}
@@ -29,22 +63,30 @@ export const SettingsApplications = () => {
]}
>
<SettingsPageContainer>
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<Section>
{applications.length > 0 ? (
<SettingsApplicationsTable applications={applications} />
) : (
<StyledNoApplicationContainer>
<Trans>
No installed application. Please check our{' '}
<LinkDisplay
value={{
url: 'https://www.npmjs.com/package/twenty-cli',
label: 'twenty-cli',
}}
/>
</Trans>
</StyledNoApplicationContainer>
)}
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={button} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -13,11 +13,11 @@ export type SettingsApplicationTableRowProps = {
};
export const StyledApplicationTableRow = styled(TableRow)`
grid-template-columns: 1fr 120px 36px;
grid-template-columns: 164px minmax(0, 1fr) 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
color: ${({ theme }) => theme.font.color.secondary};
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
overflow: hidden;
@@ -1,4 +1,4 @@
import { IconChevronRight } from 'twenty-ui/display';
import { H2Title, IconChevronRight, IconSearch } from 'twenty-ui/display';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
@@ -11,6 +11,9 @@ import {
} from '~/pages/settings/applications/components/SettingsApplicationTableRow';
import { useTheme } from '@emotion/react';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
import { Section } from 'twenty-ui/layout';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useMemo, useState } from 'react';
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(3)};
@@ -20,6 +23,11 @@ const StyledTableHeaderRow = styled(StyledApplicationTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsApplicationsTable = ({
applications,
}: {
@@ -29,28 +37,54 @@ export const SettingsApplicationsTable = ({
const theme = useTheme();
const [searchTerm, setSearchTerm] = useState('');
const filteredApplications = useMemo(() => {
return applications.filter(
(application) =>
application.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
application.description
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
}, [applications, searchTerm]);
return (
<StyledTable>
<StyledTableHeaderRow>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader />
</StyledTableHeaderRow>
{applications.map((application) => (
<SettingsApplicationTableRow
key={application.id}
application={application}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
))}
</StyledTable>
<Section>
<H2Title
title={t`Installed applications`}
description={t`List installed applications. Use filter to search for a specific application`}
/>
<StyledSearchInput
instanceId="env-var-search"
LeftIcon={IconSearch}
placeholder={t`Search an application`}
value={searchTerm}
onChange={setSearchTerm}
/>
<StyledTable>
<StyledTableHeaderRow>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader> {t`Description`}</TableHeader>
<TableHeader> {''}</TableHeader>
<TableHeader />
</StyledTableHeaderRow>
{filteredApplications.map((application) => (
<SettingsApplicationTableRow
key={application.id}
application={application}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
))}
</StyledTable>
</Section>
);
};
@@ -1,24 +1,65 @@
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { H2Title, IconTrash } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import type { Application } from '~/generated/graphql';
import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer';
import { Button } from 'twenty-ui/input';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { Trans } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useState } from 'react';
import { useUninstallApplicationMutation } from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
export const SettingsApplicationDetailAboutTab = ({
application,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
application?: Omit<Application, 'objects'> & {
objects: { id: string }[];
};
}) => {
const { openModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [uninstallApplication] = useUninstallApplicationMutation();
const navigate = useNavigateSettings();
if (!isDefined(application)) {
return null;
}
const { id, name, description } = application;
const handleUninstallApplication = async () => {
setIsLoading(true);
try {
await uninstallApplication({
variables: { universalIdentifier: application.universalIdentifier },
});
enqueueSuccessSnackBar({
message: t`Application successfully uninstalled.`,
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({ message: t`Error uninstalling application.` });
} finally {
setIsLoading(false);
}
};
const confirmationValue = t`yes`;
return (
<>
<Section>
@@ -49,6 +90,38 @@ export const SettingsApplicationDetailAboutTab = ({
/>
<SettingsApplicationVersionContainer application={application} />
</Section>
{application.canBeUninstalled && (
<>
<Section>
<H2Title
title={t`Manage your app`}
description={t`Uninstall this application`}
/>
<Button
accent="danger"
variant="secondary"
title={t`Uninstall`}
Icon={IconTrash}
onClick={() => openModal(UNINSTALL_APPLICATION_MODAL_ID)}
/>
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to
uninstall this application.
</Trans>
}
onConfirmClick={handleUninstallApplication}
confirmButtonText={t`Uninstall`}
loading={isLoading}
/>
</>
)}
</>
);
};
@@ -39,11 +39,23 @@ export const SettingsApplicationDetailContentTab = ({
return (
<>
{shouldDisplayObjects && (
<Section>
<H2Title
title={t`Objects`}
description={t`Objects managed by this app`}
/>
<SettingsObjectTable
objectMetadataItems={applicationObjectMetadataItems}
withSearchBar={false}
/>
</Section>
)}
{shouldDisplayServerlessFunctions && (
<Section>
<H2Title
title={t`Application serverless functions`}
description={t`Serverless functions created by application`}
title={t`Functions`}
description={t`Serverless functions powering this app`}
/>
<SettingsServerlessFunctionsTable
serverlessFunctions={serverlessFunctions}
@@ -53,24 +65,12 @@ export const SettingsApplicationDetailContentTab = ({
{shouldDisplayAgents && (
<Section>
<H2Title
title={t`Application agents`}
description={t`Agents created by application`}
title={t`Agents`}
description={t`Agents powering this app`}
/>
<SettingsAIAgentsTable />
</Section>
)}
{shouldDisplayObjects && (
<Section>
<H2Title
title={t`Application objects`}
description={t`Objects created by application`}
/>
<SettingsObjectTable
objectMetadataItems={applicationObjectMetadataItems}
withSearchBar={false}
/>
</Section>
)}
</>
);
};
@@ -1,78 +1,61 @@
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import styled from '@emotion/styled';
import { type ApplicationVariable } from '~/generated/graphql';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
type EnvironmentVariable,
SettingsApplicationDetailEnvironmentVariablesTableRow,
StyledApplicationEnvironmentVariableTableRow,
} from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
import { TextInput } from '@/ui/input/components/TextInput';
import { useDebouncedCallback } from 'use-debounce';
import { useState } from 'react';
import styled from '@emotion/styled';
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsApplicationDetailEnvironmentVariablesTable = ({
envVariables,
onUpdate,
readonly,
}: {
envVariables: EnvironmentVariable[];
onUpdate: (newEnv: Pick<EnvironmentVariable, 'key' | 'value'>) => void;
envVariables: ApplicationVariable[];
onUpdate: (newEnv: Pick<ApplicationVariable, 'key' | 'value'>) => void;
readonly?: boolean;
}) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredEnvVariable = useMemo(() => {
return envVariables.filter(
({ key, value }) =>
key.toLowerCase().includes(searchTerm.toLowerCase()) ||
value.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [envVariables, searchTerm]);
const [editedEnvVariables, setEditedEnvVariables] = useState(envVariables);
const onUpdateDebounced = useDebouncedCallback(
(value: Pick<ApplicationVariable, 'key' | 'value'>) => {
onUpdate(value);
},
250,
);
return (
<Section>
<H2Title
title={t`Configuration`}
description={t`Set your application configuration variables`}
/>
<StyledSearchInput
instanceId="env-var-search"
LeftIcon={IconSearch}
placeholder={t`Search a variable`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<StyledApplicationEnvironmentVariableTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
<TableHeader>{t`Info`}</TableHeader>
<TableHeader></TableHeader>
</StyledApplicationEnvironmentVariableTableRow>
{filteredEnvVariable.length > 0 && (
<StyledTableBody>
{filteredEnvVariable.map((envVariable) => (
<SettingsApplicationDetailEnvironmentVariablesTableRow
key={envVariable.id}
envVariable={envVariable}
onChange={onUpdate}
readonly={readonly}
/>
))}
</StyledTableBody>
)}
</Table>
<StyledContainer>
{editedEnvVariables.map((editedEnvVariable) => (
<TextInput
key={editedEnvVariable.key}
label={editedEnvVariable.key}
value={editedEnvVariable.value}
onChange={(newValue) => {
setEditedEnvVariables((prevState) =>
prevState.map((val) => {
if (val.key === editedEnvVariable.key) {
return { ...val, value: newValue };
}
return val;
}),
);
onUpdateDebounced({ ...editedEnvVariable, value: newValue });
}}
placeholder={t`Value`}
fullWidth
/>
))}
</StyledContainer>
</Section>
);
};
@@ -1,142 +0,0 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
AppTooltip,
IconCheck,
IconDotsVertical,
IconInfoCircle,
IconPencil,
OverflowingTextWithTooltip,
TooltipDelay,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { type ApplicationVariable } from '~/generated/graphql';
export const StyledApplicationEnvironmentVariableTableRow = styled(TableRow)`
grid-template-columns: auto 200px 36px 36px;
`;
export type EnvironmentVariable = ApplicationVariable;
export const SettingsApplicationDetailEnvironmentVariablesTableRow = ({
envVariable,
onChange,
readonly,
}: {
envVariable: EnvironmentVariable;
onChange: (
newEnvVariable: Pick<EnvironmentVariable, 'key' | 'value'>,
) => void;
readonly?: boolean;
}) => {
const [editedEnvVariable, setEditedEnvVariable] = useState(envVariable);
const [editMode, setEditMode] = useState(false);
const dropDownId = `settings-environment-variable-dropdown-${envVariable.key}`;
const { closeDropdown } = useCloseDropdown();
const theme = useTheme();
const { t } = useLingui();
const description =
envVariable.description.length > 0
? envVariable.description
: t`No description`;
const InfoTableCell = (
<TableCell>
<IconInfoCircle
id={`info-circle-id-description-${envVariable.key}`}
size={theme.icon.size.md}
color={theme.font.color.tertiary}
style={{ outline: 'none' }}
/>
<AppTooltip
anchorSelect={`#info-circle-id-description-${envVariable.key}`}
content={description}
offset={5}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</TableCell>
);
return editMode && !readonly ? (
<StyledApplicationEnvironmentVariableTableRow>
<TableCell>
<OverflowingTextWithTooltip text={envVariable.key} />
</TableCell>
<TableCell>
<TextInput
value={editedEnvVariable.value}
onChange={(newValue) =>
setEditedEnvVariable({ ...editedEnvVariable, value: newValue })
}
placeholder={t`Value`}
fullWidth
/>
</TableCell>
{InfoTableCell}
<TableCell>
<LightIconButton
accent="tertiary"
Icon={IconCheck}
onClick={() => {
onChange(editedEnvVariable);
setEditMode(false);
}}
/>
</TableCell>
</StyledApplicationEnvironmentVariableTableRow>
) : (
<StyledApplicationEnvironmentVariableTableRow
onClick={() => setEditMode(true)}
>
<TableCell>
<OverflowingTextWithTooltip text={envVariable.key} />
</TableCell>
<TableCell>
<OverflowingTextWithTooltip text={editedEnvVariable.value} />
</TableCell>
{InfoTableCell}
<TableCell>
{!readonly && (
<Dropdown
dropdownId={dropDownId}
clickableComponent={
<LightIconButton
aria-label={t`Env Variable Options`}
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Edit`}
LeftIcon={IconPencil}
onClick={() => {
setEditMode(true);
closeDropdown(dropDownId);
}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
</TableCell>
</StyledApplicationEnvironmentVariableTableRow>
);
};
@@ -2,19 +2,6 @@ import { isDefined } from 'twenty-shared/utils';
import type { Application } from '~/generated/graphql';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
import { H2Title, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { Trans, useLingui } from '@lingui/react/macro';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUninstallApplicationMutation } from '~/generated-metadata/graphql';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
export const SettingsApplicationDetailSettingsTab = ({
application,
@@ -23,20 +10,8 @@ export const SettingsApplicationDetailSettingsTab = ({
objects: { id: string }[];
};
}) => {
const { t } = useLingui();
const { openModal } = useModal();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const navigate = useNavigateSettings();
const [isLoading, setIsLoading] = useState(false);
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
const [uninstallApplication] = useUninstallApplicationMutation();
if (!isDefined(application)) {
return null;
}
@@ -45,26 +20,6 @@ export const SettingsApplicationDetailSettingsTab = ({
(a, b) => a.key.localeCompare(b.key),
);
const handleUninstallApplication = async () => {
setIsLoading(true);
try {
await uninstallApplication({
variables: { universalIdentifier: application.universalIdentifier },
});
enqueueSuccessSnackBar({
message: t`Application successfully uninstalled.`,
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({ message: t`Error uninstalling application.` });
} finally {
setIsLoading(false);
}
};
const confirmationValue = t`yes`;
return (
<>
<SettingsApplicationDetailEnvironmentVariablesTable
@@ -77,38 +32,6 @@ export const SettingsApplicationDetailSettingsTab = ({
})
}
/>
{application.canBeUninstalled && (
<>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Uninstall this application`}
/>
<Button
accent="danger"
variant="secondary"
title={t`Uninstall`}
Icon={IconTrash}
onClick={() => openModal(UNINSTALL_APPLICATION_MODAL_ID)}
/>
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to
uninstall this application.
</Trans>
}
onConfirmClick={handleUninstallApplication}
confirmButtonText={t`Uninstall`}
loading={isLoading}
/>
</>
)}
</>
);
};
@@ -59,6 +59,7 @@ export const SettingsObjectDetailPage = () => {
const theme = useTheme();
const { objectNamePlural = '' } = useParams();
const { findObjectMetadataItemByNamePlural } =
useFilteredObjectMetadataItems();
@@ -167,7 +168,10 @@ export const SettingsObjectDetailPage = () => {
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`Objects`, href: getSettingsPath(SettingsPath.Objects) },
{
children: t`Objects`,
href: getSettingsPath(SettingsPath.Objects),
},
{
children: objectMetadataItem.labelPlural,
},
@@ -77,6 +77,7 @@ export const SettingsObjectFieldEdit = () => {
] = useRecoilState(shouldNavigateBackToMemorizedUrlOnSaveState);
const { objectNamePlural = '', fieldName = '' } = useParams();
const { findObjectMetadataItemByNamePlural } =
useFilteredObjectMetadataItems();
@@ -171,9 +171,14 @@ export const SettingsServerlessFunctionDetail = () => {
},
{
children: `${applicationName}`,
href: getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId,
}),
href: getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'content',
),
},
{ children: `${serverlessFunction?.name}` },
]}
@@ -33,7 +33,7 @@ export enum SettingsPath {
AIAgentTurnDetail = 'ai/agents/:agentId/turns/:turnId',
Applications = 'applications',
ApplicationDetail = 'applications/:applicationId',
ApplicationServerlessFunctionDetail = 'applications/:applicationId/:serverlessFunctionId',
ApplicationServerlessFunctionDetail = 'applications/:applicationId/serverlessFunctions/:serverlessFunctionId',
ServerlessFunctions = 'functions',
NewServerlessFunction = 'functions/new',
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
@@ -0,0 +1,53 @@
import styled from '@emotion/styled';
import type { ReactElement } from 'react';
const StyledContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid ${({ theme }) => theme.border.color.medium};
background: ${({ theme }) => theme.background.transparent.secondary};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(3)};
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledCommandContain = styled.div`
font-family: ${({ theme }) => theme.code.font.family};
`;
const StyledButtonContainer = styled.div`
display: flex;
`;
const StyledLineContainer = styled.div``;
const StyledLineStartSpan = styled.span`
color: ${({ theme }) => theme.code.text.orange};
`;
const StyledLineSpan = styled.span`
color: ${({ theme }) => theme.code.text.green};
`;
type CommandBlockProps = {
commands: string[];
button?: ReactElement;
};
export const CommandBlock = ({ commands, button }: CommandBlockProps) => {
return (
<StyledContainer>
<StyledCommandContain>
<>
{commands.map((line, i) => (
<StyledLineContainer key={i}>
<StyledLineStartSpan>{'> '}</StyledLineStartSpan>
<StyledLineSpan>{line}</StyledLineSpan>
</StyledLineContainer>
))}
</>
</StyledCommandContain>
{button && <StyledButtonContainer>{button}</StyledButtonContainer>}
</StyledContainer>
);
};
@@ -163,6 +163,7 @@ export {
IconFileCheck,
IconFileExport,
IconFileImport,
IconFileInfo,
IconFilePencil,
IconFileText,
IconFileUpload,
+2
View File
@@ -28,6 +28,7 @@ export type {
ColorSampleProps,
} from './color/components/ColorSample';
export { ColorSample } from './color/components/ColorSample';
export { CommandBlock } from './command-block/components/CommandBlock';
export { IconAddressBook } from './icon/components/IconAddressBook';
export { IconChartBarHorizontal } from './icon/components/IconChartBarHorizontal';
export { IconGmail } from './icon/components/IconGmail';
@@ -228,6 +229,7 @@ export {
IconFileCheck,
IconFileExport,
IconFileImport,
IconFileInfo,
IconFilePencil,
IconFileText,
IconFileUpload,
@@ -6,5 +6,7 @@ export const CODE_DARK = {
sky: COLOR_DARK.sky10,
pink: COLOR_DARK.pink10,
orange: COLOR_DARK.orange8,
green: COLOR_DARK.lime8,
},
font: { family: 'DM Mono' },
};
@@ -6,5 +6,7 @@ export const CODE_LIGHT = {
sky: COLOR_LIGHT.sky10,
pink: COLOR_LIGHT.pink10,
orange: COLOR_LIGHT.orange8,
green: COLOR_LIGHT.lime8,
},
font: { family: 'DM Mono' },
};