Twenty config admin panel integration (#11755)

closes https://github.com/twentyhq/core-team-issues/issues/761
closes https://github.com/twentyhq/core-team-issues/issues/762

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2025-04-30 12:42:59 +05:30
committed by GitHub
parent 842367f7bb
commit e957b1acd6
73 changed files with 2958 additions and 853 deletions

View File

@ -281,11 +281,11 @@ const SettingsAdminIndicatorHealthStatus = lazy(() =>
})),
);
const SettingsAdminSecondaryEnvVariables = lazy(() =>
const SettingsAdminConfigVariableDetails = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminSecondaryEnvVariables'
'~/pages/settings/admin-panel/SettingsAdminConfigVariableDetails'
).then((module) => ({
default: module.SettingsAdminSecondaryEnvVariables,
default: module.SettingsAdminConfigVariableDetails,
})),
);
@ -505,9 +505,10 @@ export const SettingsRoutes = ({
path={SettingsPath.AdminPanelIndicatorHealthStatus}
element={<SettingsAdminIndicatorHealthStatus />}
/>
<Route
path={SettingsPath.AdminPanelOtherEnvVariables}
element={<SettingsAdminSecondaryEnvVariables />}
path={SettingsPath.AdminPanelConfigVariableDetails}
element={<SettingsAdminConfigVariableDetails />}
/>
</>
)}

View File

@ -7,6 +7,7 @@ import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionId
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
@ -21,8 +22,8 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetClientConfigQuery } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { useGetClientConfigQuery } from '~/generated/graphql';
export const ClientConfigProviderEffect = () => {
const setIsDebugMode = useSetRecoilState(isDebugModeState);
@ -82,6 +83,10 @@ export const ClientConfigProviderEffect = () => {
isAttachmentPreviewEnabledState,
);
const setIsConfigVariablesInDbEnabled = useSetRecoilState(
isConfigVariablesInDbEnabledState,
);
const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded,
});
@ -157,6 +162,9 @@ export const ClientConfigProviderEffect = () => {
setIsAttachmentPreviewEnabled(
data?.clientConfig?.isAttachmentPreviewEnabled,
);
setIsConfigVariablesInDbEnabled(
data?.clientConfig?.isConfigVariablesInDbEnabled,
);
}, [
data,
setIsDebugMode,
@ -182,6 +190,7 @@ export const ClientConfigProviderEffect = () => {
setGoogleMessagingEnabled,
setGoogleCalendarEnabled,
setIsAttachmentPreviewEnabled,
setIsConfigVariablesInDbEnabled,
]);
return <></>;

View File

@ -61,6 +61,7 @@ export const GET_CLIENT_CONFIG = gql`
isMicrosoftCalendarEnabled
isGoogleMessagingEnabled
isGoogleCalendarEnabled
isConfigVariablesInDbEnabled
}
}
`;

View File

@ -0,0 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const isConfigVariablesInDbEnabledState = createState<boolean>({
key: 'isConfigVariablesInDbEnabled',
defaultValue: false,
});

View File

@ -20,8 +20,8 @@ export const SettingsAdminContent = () => {
disabled: !canAccessFullAdminPanel && !canImpersonate,
},
{
id: SETTINGS_ADMIN_TABS.ENV_VARIABLES,
title: t`Env Variables`,
id: SETTINGS_ADMIN_TABS.CONFIG_VARIABLES,
title: t`Config Variables`,
Icon: IconVariable,
disabled: !canAccessFullAdminPanel,
},

View File

@ -1,68 +0,0 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsListItemCardContent } from '@/settings/components/SettingsListItemCardContent';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, IconHeartRateMonitor } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { useGetConfigVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div``;
const StyledInfoText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
const StyledCard = styled(Card)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
export const SettingsAdminEnvVariables = () => {
const theme = useTheme();
const { data: configVariables, loading: configVariablesLoading } =
useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const visibleGroups =
configVariables?.getConfigVariablesGrouped.groups.filter(
(group) => !group.isHiddenOnLoad,
) ?? [];
if (configVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>
<StyledInfoText>
{t`These are only the server values. Ensure your worker environment has the same variables and values, this is required for asynchronous tasks like email sync.`}
</StyledInfoText>
</Section>
{visibleGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H2Title title={group.name} description={group.description} />
{group.variables.length > 0 && (
<SettingsAdminEnvVariablesTable variables={group.variables} />
)}
</StyledGroupContainer>
))}
<Section>
<StyledCard rounded>
<SettingsListItemCardContent
label={t`Other Variables`}
to={getSettingsPath(SettingsPath.AdminPanelOtherEnvVariables)}
rightComponent={null}
LeftIcon={IconHeartRateMonitor}
LeftIconColor={theme.font.color.tertiary}
/>
</StyledCard>
</Section>
</>
);
};

View File

@ -1,170 +0,0 @@
import { SettingsAdminEnvCopyableText } from '@/settings/admin-panel/components/SettingsAdminEnvCopyableText';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { IconChevronRight, IconEye, IconEyeOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
type SettingsAdminEnvVariablesRowProps = {
variable: {
name: string;
description: string;
value: string;
isSensitive: boolean;
};
isExpanded: boolean;
onExpandToggle: (name: string) => void;
};
const StyledTruncatedCell = styled(TableCell)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
`;
const StyledButton = styled(motion.button)`
align-items: center;
border: none;
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
background-color: transparent;
height: 24px;
width: 24px;
box-sizing: border-box;
cursor: pointer;
`;
const MotionIconChevronDown = motion(IconChevronRight);
const StyledEllipsisLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const StyledValueContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: space-between;
width: 100%;
`;
const StyledTableRow = styled(TableRow)<{ isExpanded: boolean }>`
background-color: ${({ isExpanded, theme }) =>
isExpanded ? theme.background.transparent.light : 'transparent'};
`;
const StyledExpandableContainer = styled.div`
width: 100%;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminEnvVariablesRow = ({
variable,
isExpanded,
onExpandToggle,
}: SettingsAdminEnvVariablesRowProps) => {
const [showSensitiveValue, setShowSensitiveValue] = useState(false);
const theme = useTheme();
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive && !showSensitiveValue
? '••••••'
: variable.value;
const handleToggleVisibility = (event: React.MouseEvent) => {
event.stopPropagation();
setShowSensitiveValue(!showSensitiveValue);
};
const environmentVariablesDetails = [
{
label: 'Name',
value: <SettingsAdminEnvCopyableText text={variable.name} />,
},
{
label: 'Description',
value: (
<SettingsAdminEnvCopyableText
text={variable.description}
maxRows={1}
multiline={true}
/>
),
},
{
label: 'Value',
value: (
<StyledValueContainer>
<SettingsAdminEnvCopyableText
text={variable.value}
displayText={displayValue}
multiline={true}
/>
{variable.isSensitive && variable.value !== '' && (
<LightIconButton
Icon={showSensitiveValue ? IconEyeOff : IconEye}
size="small"
accent="secondary"
onClick={handleToggleVisibility}
/>
)}
</StyledValueContainer>
),
},
];
return (
<>
<StyledTableRow
onClick={() => onExpandToggle(variable.name)}
gridAutoColumns="5fr 4fr 3fr 1fr"
isExpanded={isExpanded}
>
<StyledTruncatedCell color={theme.font.color.primary}>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell>
<StyledEllipsisLabel>{variable.description}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell align="right">
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</StyledTruncatedCell>
<TableCell align="right">
<StyledButton
onClick={(e) => {
e.stopPropagation();
onExpandToggle(variable.name);
}}
>
<MotionIconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
initial={false}
animate={{ rotate: isExpanded ? 90 : 0 }}
/>
</StyledButton>
</TableCell>
</StyledTableRow>
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledExpandableContainer>
<SettingsAdminTableCard
items={environmentVariablesDetails}
gridAutoColumns="1fr 4fr"
/>
</StyledExpandableContainer>
</AnimatedExpandableContainer>
</>
);
};

View File

@ -1,51 +0,0 @@
import { SettingsAdminEnvVariablesRow } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useState } from 'react';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
type SettingsAdminEnvVariablesTableProps = {
variables: Array<{
name: string;
description: string;
value: string;
isSensitive: boolean;
}>;
};
export const SettingsAdminEnvVariablesTable = ({
variables,
}: SettingsAdminEnvVariablesTableProps) => {
const [expandedRowName, setExpandedRowName] = useState<string | null>(null);
const handleExpandToggle = (name: string) => {
setExpandedRowName(expandedRowName === name ? null : name);
};
return (
<Table>
<TableRow gridAutoColumns="5fr 4fr 3fr 1fr">
<TableHeader>Name</TableHeader>
<TableHeader>Description</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBody>
{variables.map((variable) => (
<SettingsAdminEnvVariablesRow
key={variable.name}
variable={variable}
isExpanded={expandedRowName === variable.name}
onExpandToggle={handleExpandToggle}
/>
))}
</StyledTableBody>
</Table>
);
};

View File

@ -1,5 +1,5 @@
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
@ -15,8 +15,8 @@ export const SettingsAdminTabContent = () => {
switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL:
return <SettingsAdminGeneral />;
case SETTINGS_ADMIN_TABS.ENV_VARIABLES:
return <SettingsAdminEnvVariables />;
case SETTINGS_ADMIN_TABS.CONFIG_VARIABLES:
return <SettingsAdminConfigVariables />;
case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
return <SettingsAdminHealthStatus />;
default:

View File

@ -0,0 +1,62 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import {
IconDeviceFloppy,
IconPencil,
IconRefreshAlert,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { ConfigSource, ConfigVariable } from '~/generated/graphql';
type ConfigVariableActionButtonsProps = {
variable: ConfigVariable;
isValueValid: boolean;
isSubmitting: boolean;
onSave: () => void;
onReset: () => void;
};
export const ConfigVariableActionButtons = ({
variable,
isValueValid,
isSubmitting,
onSave,
onReset,
}: ConfigVariableActionButtonsProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const isFromDatabase = variable.source === ConfigSource.DATABASE;
return (
<>
{isConfigVariablesInDbEnabled &&
variable.source === ConfigSource.DATABASE && (
<Button
title={t`Reset to Default`}
variant="secondary"
size="small"
accent="danger"
disabled={isSubmitting}
onClick={onReset}
Icon={IconRefreshAlert}
/>
)}
{isConfigVariablesInDbEnabled && !variable.isEnvOnly && (
<Button
title={isFromDatabase ? t`Save` : t`Edit`}
variant="primary"
size="small"
accent="blue"
disabled={isSubmitting || !isValueValid}
onClick={onSave}
type="submit"
Icon={isFromDatabase ? IconDeviceFloppy : IconPencil}
/>
)}
</>
);
};

View File

@ -0,0 +1,190 @@
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ConfigVariableValue } from 'twenty-shared/types';
import { MenuItemMultiSelect } from 'twenty-ui/navigation';
import { ConfigVariableType } from '~/generated/graphql';
import { ConfigVariableOptions } from '../types/ConfigVariableOptions';
type ConfigVariableDatabaseInputProps = {
label: string;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
type: ConfigVariableType;
options?: ConfigVariableOptions;
disabled?: boolean;
placeholder?: string;
};
export const ConfigVariableDatabaseInput = ({
label,
value,
onChange,
type,
options,
disabled,
placeholder,
}: ConfigVariableDatabaseInputProps) => {
const selectOptions =
options && Array.isArray(options)
? options.map((option) => ({
value: String(option),
label: String(option),
}))
: [];
const booleanOptions = [
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
];
const isValueSelected = (optionValue: string) => {
if (!Array.isArray(value)) return false;
return value.includes(optionValue);
};
const handleMultiSelectChange = (optionValue: string) => {
if (!Array.isArray(value)) return;
let newValues = [...value];
if (isValueSelected(optionValue)) {
newValues = newValues.filter((val) => val !== optionValue);
} else {
newValues.push(optionValue);
}
onChange(newValues);
};
switch (type) {
case ConfigVariableType.BOOLEAN:
return (
<Select
label={label}
value={String(value ?? '')}
onChange={(newValue: string) => onChange(newValue === 'true')}
disabled={disabled}
options={booleanOptions}
dropdownId="config-variable-boolean-select"
fullWidth
/>
);
case ConfigVariableType.NUMBER:
return (
<TextInputV2
label={label}
value={value !== null && value !== undefined ? String(value) : ''}
onChange={(text) => {
const num = Number(text);
onChange(isNaN(num) ? text : num);
}}
disabled={disabled}
placeholder={placeholder}
type="number"
fullWidth
/>
);
case ConfigVariableType.ARRAY:
return (
<>
{options && Array.isArray(options) ? (
<Dropdown
dropdownId="config-variable-array-dropdown"
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
dropdownPlacement="bottom-start"
dropdownOffset={{
y: 8,
}}
clickableComponent={
<SelectControl
selectedOption={{
value: '',
label:
Array.isArray(value) && value.length > 0
? value.join(', ')
: 'Select options',
}}
isDisabled={disabled}
hasRightElement={false}
selectSizeVariant="default"
/>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{selectOptions.map((option) => (
<MenuItemMultiSelect
key={option.value}
text={option.label}
selected={isValueSelected(option.value)}
className="config-variable-array-menu-item-multi-select"
onSelectChange={() =>
handleMultiSelectChange(option.value)
}
/>
))}
</DropdownMenuItemsContainer>
}
/>
) : (
<TextArea
label={label}
value={
Array.isArray(value)
? JSON.stringify(value)
: String(value ?? '')
}
onChange={(text) => {
try {
const arr = JSON.parse(text);
onChange(Array.isArray(arr) ? arr : value);
} catch {
onChange(text);
}
}}
disabled={disabled}
placeholder={placeholder || 'Enter JSON array'}
/>
)}
</>
);
case ConfigVariableType.ENUM:
return (
<Select
label={label}
value={String(value ?? '')}
onChange={(newValue: string) => onChange(newValue)}
disabled={disabled}
options={selectOptions}
dropdownId="config-variable-enum-select"
fullWidth
/>
);
case ConfigVariableType.STRING:
return (
<TextInputV2
label={label}
value={
typeof value === 'string'
? value
: value !== null && value !== undefined
? JSON.stringify(value)
: ''
}
onChange={(text) => onChange(text)}
disabled={disabled}
placeholder={placeholder || 'Enter value'}
fullWidth
/>
);
default:
throw new Error(`Unsupported type: ${type}`);
}
};

View File

@ -0,0 +1,44 @@
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import styled from '@emotion/styled';
const StyledChipContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
`;
type ConfigVariableFilterContainerProps = {
children: React.ReactNode;
activeChips: {
label: string;
onRemove: () => void;
source?: ConfigVariableSourceFilter;
variant?: 'default' | 'danger';
}[];
};
export const ConfigVariableFilterContainer = ({
children,
activeChips,
}: ConfigVariableFilterContainerProps) => {
return (
<div>
{children}
{activeChips.length > 0 && (
<StyledChipContainer>
{activeChips.map((chip) => (
<SortOrFilterChip
key={chip.label + chip.source}
labelKey={chip.label}
onRemove={chip.onRemove}
labelValue={chip.source ?? ''}
variant={chip.variant}
/>
))}
</StyledChipContainer>
)}
</div>
);
};

View File

@ -0,0 +1,66 @@
import { ConfigVariableFilterCategory } from '@/settings/admin-panel/config-variables/types/ConfigVariableFilterCategory';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useState } from 'react';
import { IconSettings } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { ConfigVariableOptionsDropdownContent } from './ConfigVariableOptionsDropdownContent';
type ConfigVariableFilterDropdownProps = {
sourceFilter: ConfigVariableSourceFilter;
groupFilter: ConfigVariableGroupFilter;
groupOptions: { value: string; label: string }[];
showHiddenGroupVariables: boolean;
onSourceFilterChange: (source: ConfigVariableSourceFilter) => void;
onGroupFilterChange: (group: ConfigVariableGroupFilter) => void;
onShowHiddenChange: (value: boolean) => void;
};
export const ConfigVariableFilterDropdown = ({
sourceFilter,
groupFilter,
groupOptions,
showHiddenGroupVariables,
onSourceFilterChange,
onGroupFilterChange,
onShowHiddenChange,
}: ConfigVariableFilterDropdownProps) => {
const [selectedCategory, setSelectedCategory] =
useState<ConfigVariableFilterCategory | null>(null);
const handleSelectCategory = (
category: ConfigVariableFilterCategory | null,
) => {
setSelectedCategory(category);
};
return (
<Dropdown
clickableComponent={
<Button
variant="secondary"
size="medium"
title="Options"
Icon={IconSettings}
/>
}
dropdownId="env-var-options-dropdown"
dropdownHotkeyScope={{ scope: 'env-var-options' }}
dropdownOffset={{ x: 0, y: 10 }}
dropdownComponents={
<ConfigVariableOptionsDropdownContent
selectedCategory={selectedCategory}
onSelectCategory={handleSelectCategory}
sourceFilter={sourceFilter}
groupFilter={groupFilter}
groupOptions={groupOptions}
showHiddenGroupVariables={showHiddenGroupVariables}
onSourceFilterChange={onSourceFilterChange}
onGroupFilterChange={onGroupFilterChange}
onShowHiddenChange={onShowHiddenChange}
/>
}
/>
);
};

View File

@ -0,0 +1,85 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { useRecoilValue } from 'recoil';
import { ConfigSource, ConfigVariable } from '~/generated/graphql';
const StyledHelpText = styled.div<{ color?: string }>`
color: ${({ theme, color }) => color || theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
line-height: 1.5;
`;
type ConfigVariableHelpTextProps = {
variable: ConfigVariable;
hasValueChanged: boolean;
color?: string;
};
export const ConfigVariableHelpText = ({
variable,
hasValueChanged,
}: ConfigVariableHelpTextProps) => {
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const { t } = useLingui();
const isFromDatabase = variable.source === ConfigSource.DATABASE;
const isFromEnvironment = variable.source === ConfigSource.ENVIRONMENT;
const isReadOnly = !isConfigVariablesInDbEnabled;
if (isReadOnly) {
return (
<StyledHelpText>
{t`Database configuration is currently disabled.`}{' '}
{isFromEnvironment
? t`Value is set in the server environment, it may be a different value on the worker.`
: t`Using default application value. Configure via environment variables.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && variable.isEnvOnly) {
return (
<StyledHelpText>
{t`This setting can only be configured through environment variables.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && !variable.isEnvOnly && hasValueChanged) {
return (
<StyledHelpText>
{isFromDatabase
? t`Click on the checkmark to apply your changes.`
: t`This value will be saved to the database.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && !variable.isEnvOnly && !hasValueChanged) {
if (isFromDatabase) {
return (
<>
<StyledHelpText>
{t`This database value overrides environment settings. `}
</StyledHelpText>
<StyledHelpText>
{t`Clear the field or "X" to revert to environment/default value.`}
</StyledHelpText>
</>
);
} else {
return (
<StyledHelpText>
{isFromEnvironment
? t`Current value from server environment. Set a custom value to override.`
: t`Using default value. Set a custom value to override.`}
</StyledHelpText>
);
}
}
return <StyledHelpText>{t`This should never happen`}</StyledHelpText>;
};

View File

@ -0,0 +1,141 @@
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { ConfigVariableSourceOptions } from '@/settings/admin-panel/config-variables/constants/ConfigVariableSourceOptions';
import { ConfigVariableFilterCategory } from '@/settings/admin-panel/config-variables/types/ConfigVariableFilterCategory';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconChevronLeft, IconEye, IconEyeOff } from 'twenty-ui/display';
import { MenuItem, MenuItemSelectTag } from 'twenty-ui/navigation';
type ConfigVariableOptionsDropdownContentProps = {
selectedCategory: ConfigVariableFilterCategory | null;
onSelectCategory: (category: ConfigVariableFilterCategory | null) => void;
sourceFilter: ConfigVariableSourceFilter;
groupFilter: ConfigVariableGroupFilter;
groupOptions: { value: ConfigVariableGroupFilter; label: string }[];
showHiddenGroupVariables: boolean;
onSourceFilterChange: (source: ConfigVariableSourceFilter) => void;
onGroupFilterChange: (group: ConfigVariableGroupFilter) => void;
onShowHiddenChange: (value: boolean) => void;
};
export const ConfigVariableOptionsDropdownContent = ({
selectedCategory,
onSelectCategory,
sourceFilter,
groupFilter,
groupOptions,
showHiddenGroupVariables,
onSourceFilterChange,
onGroupFilterChange,
onShowHiddenChange,
}: ConfigVariableOptionsDropdownContentProps) => {
const theme = useTheme();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const availableSourceOptions = ConfigVariableSourceOptions.filter(
(option) => isConfigVariablesInDbEnabled || option.value !== 'database',
);
if (!selectedCategory) {
return (
<>
<DropdownMenuItemsContainer>
<MenuItemSelectTag
text={t`Source`}
color="transparent"
onClick={() => onSelectCategory('source')}
/>
<MenuItemSelectTag
text={t`Group`}
color="transparent"
onClick={() => onSelectCategory('group')}
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
text={
showHiddenGroupVariables
? t`Hide hidden groups`
: t`Show hidden groups`
}
LeftIcon={() =>
showHiddenGroupVariables ? (
<IconEyeOff
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
) : (
<IconEye
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)
}
onClick={() => onShowHiddenChange(!showHiddenGroupVariables)}
/>
</DropdownMenuItemsContainer>
</>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => onSelectCategory(null)}
Icon={IconChevronLeft}
/>
}
>
{selectedCategory === 'source' && t`Select Source`}
{selectedCategory === 'group' && t`Select Group`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{selectedCategory === 'source' && (
<>
{availableSourceOptions.map((option) => (
<MenuItemSelectTag
key={option.value}
text={option.label}
color={option.color}
selected={option.value === sourceFilter}
onClick={() => {
onSourceFilterChange(option.value);
onSelectCategory(null);
}}
/>
))}
</>
)}
{selectedCategory === 'group' && (
<>
{groupOptions.map((option) => (
<MenuItemSelectTag
key={option.value}
text={option.label}
color="transparent"
selected={option.value === groupFilter}
onClick={() => {
onGroupFilterChange(option.value);
onSelectCategory(null);
}}
/>
))}
</>
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,28 @@
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSearch } from 'twenty-ui/display';
const StyledSearchInput = styled(TextInput)`
width: 100%;
`;
type ConfigVariableSearchInputProps = {
value: string;
onChange: (value: string) => void;
};
export const ConfigVariableSearchInput = ({
value,
onChange,
}: ConfigVariableSearchInputProps) => {
return (
<StyledSearchInput
placeholder={t`Search config variables`}
value={value}
onChange={onChange}
autoFocus={false}
LeftIcon={IconSearch}
/>
);
};

View File

@ -0,0 +1,57 @@
import { useLingui } from '@lingui/react/macro';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ConfigVariableValue } from 'twenty-shared/types';
import { ConfigVariable } from '~/generated/graphql';
import { ConfigVariableDatabaseInput } from './ConfigVariableDatabaseInput';
type ConfigVariableValueInputProps = {
variable: ConfigVariable;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
disabled?: boolean;
};
const StyledValueContainer = styled.div`
width: 100%;
`;
export const ConfigVariableValueInput = ({
variable,
value,
onChange,
disabled,
}: ConfigVariableValueInputProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
return (
<StyledValueContainer>
{isConfigVariablesInDbEnabled && !variable.isEnvOnly ? (
<ConfigVariableDatabaseInput
label={t`Value`}
value={value}
onChange={onChange}
type={variable.type}
options={variable.options}
disabled={disabled}
placeholder={
disabled ? 'Undefined' : t`Enter a value to store in database`
}
/>
) : (
<TextInputV2
value={String(value)}
disabled
label={t`Value`}
fullWidth
/>
)}
</StyledValueContainer>
);
};

View File

@ -3,8 +3,15 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
type SettingsAdminConfigCopyableTextProps = {
text: string;
displayText?: React.ReactNode;
multiline?: boolean;
maxRows?: number;
};
const StyledEllipsisLabel = styled.div`
white-space: nowrap;
@ -20,17 +27,12 @@ const StyledExpandedEllipsisLabel = styled.div`
const StyledCopyContainer = styled.span`
cursor: pointer;
`;
export const SettingsAdminEnvCopyableText = ({
export const SettingsAdminConfigCopyableText = ({
text,
displayText,
multiline = false,
maxRows,
}: {
text: string;
displayText?: React.ReactNode;
multiline?: boolean;
maxRows?: number;
}) => {
}: SettingsAdminConfigCopyableTextProps) => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const { t } = useLingui();

View File

@ -0,0 +1,207 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { ConfigVariableFilterContainer } from '@/settings/admin-panel/config-variables/components/ConfigVariableFilterContainer';
import { ConfigVariableFilterDropdown } from '@/settings/admin-panel/config-variables/components/ConfigVariableFilterDropdown';
import { SettingsAdminConfigVariablesTable } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesTable';
import { ConfigVariableSourceOptions } from '@/settings/admin-panel/config-variables/constants/ConfigVariableSourceOptions';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
ConfigSource,
useGetConfigVariablesGroupedQuery,
} from '~/generated/graphql';
import { ConfigVariableSearchInput } from './ConfigVariableSearchInput';
const StyledControlsContainer = styled.div`
display: flex;
gap: 8px;
justify-content: space-between;
`;
const StyledTableContainer = styled.div`
margin-bottom: 24px;
`;
export const SettingsAdminConfigVariables = () => {
const { data: configVariables, loading: configVariablesLoading } =
useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const [search, setSearch] = useState('');
const [showHiddenGroupVariables, setShowHiddenGroupVariables] =
useState(false);
const [sourceFilter, setSourceFilter] =
useState<ConfigVariableSourceFilter>('all');
const [groupFilter, setGroupFilter] =
useState<ConfigVariableGroupFilter>('all');
// Get all groups, not filtered by visibility
const allGroups = useMemo(
() => configVariables?.getConfigVariablesGrouped.groups ?? [],
[configVariables],
);
// Compute group options from all groups, not just visible ones
const groupOptions = useMemo(
() => [
{ value: 'all', label: 'All Groups' },
...allGroups.map((group) => ({
value: group.name,
label: group.name,
})),
],
[allGroups],
);
// Flatten all variables for filtering, attaching isHiddenOnLoad and groupName from group
const allVariables = useMemo(
() =>
configVariables?.getConfigVariablesGrouped.groups.flatMap((group) =>
group.variables.map((variable) => ({
...variable,
isHiddenOnLoad: group.isHiddenOnLoad,
groupName: group.name,
})),
) ?? [],
[configVariables],
);
// Filtering logic
const filteredVariables = useMemo(() => {
const isSearching = search.trim().length > 0;
const hasSelectedSpecificGroup = groupFilter !== 'all';
return allVariables.filter((v) => {
// Search filter
const matchesSearch =
v.name.toLowerCase().includes(search.toLowerCase()) ||
(v.description?.toLowerCase() || '').includes(search.toLowerCase());
if (isSearching && !matchesSearch) return false;
// Group filter
const matchesGroup = hasSelectedSpecificGroup
? v.groupName === groupFilter
: true;
if (hasSelectedSpecificGroup && !matchesGroup) return false;
// Hidden filter - Only apply if:
// 1. User is not searching
// 2. Show hidden is off
// 3. Item is from a hidden group
// 4. No specific group is selected (if a specific group is selected, show all its variables)
if (
!isSearching &&
!showHiddenGroupVariables &&
v.isHiddenOnLoad &&
!hasSelectedSpecificGroup
) {
return false;
}
// Source filter
let matchesSource = true;
if (sourceFilter === 'database')
matchesSource = v.source === ConfigSource.DATABASE;
if (sourceFilter === 'environment')
matchesSource = v.source === ConfigSource.ENVIRONMENT;
if (sourceFilter === 'default')
matchesSource = v.source === ConfigSource.DEFAULT;
return matchesSource;
});
}, [
allVariables,
search,
showHiddenGroupVariables,
sourceFilter,
groupFilter,
]);
// Build activeChips for current filters
const activeChips = [];
if (sourceFilter !== 'all') {
activeChips.push({
label:
ConfigVariableSourceOptions.find((o) => o.value === sourceFilter)
?.label || '',
onRemove: () => setSourceFilter('all'),
variant: 'default' as const,
});
}
if (groupFilter !== 'all') {
activeChips.push({
label: groupOptions.find((o) => o.value === groupFilter)?.label || '',
onRemove: () => setGroupFilter('all'),
variant: 'danger' as const,
});
}
// Group variables by groupName for rendering
const groupedVariables = useMemo(() => {
const groupMap = new Map();
filteredVariables.forEach((v) => {
if (!groupMap.has(v.groupName)) {
const group = allGroups.find((g) => g.name === v.groupName);
groupMap.set(v.groupName, {
variables: [],
description: group?.description || '',
});
}
groupMap.get(v.groupName).variables.push(v);
});
return groupMap;
}, [filteredVariables, allGroups]);
if (configVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>
<H2Title title={t`Config Variables`} />
<ConfigVariableFilterContainer activeChips={activeChips}>
<StyledControlsContainer>
<ConfigVariableSearchInput value={search} onChange={setSearch} />
<ConfigVariableFilterDropdown
sourceFilter={sourceFilter}
groupFilter={groupFilter}
groupOptions={groupOptions}
showHiddenGroupVariables={showHiddenGroupVariables}
onSourceFilterChange={setSourceFilter}
onGroupFilterChange={setGroupFilter}
onShowHiddenChange={setShowHiddenGroupVariables}
/>
</StyledControlsContainer>
</ConfigVariableFilterContainer>
</Section>
{groupedVariables.size === 0 && (
<StyledTableContainer>
<Section>
<H2Title
title={t`No variables found`}
description={t`No config variables match your current filters. Try adjusting your filters or search criteria.`}
/>
</Section>
</StyledTableContainer>
)}
{[...groupedVariables.entries()].map(([groupName, groupData]) => (
<StyledTableContainer key={groupName}>
<H2Title title={groupName} description={groupData.description} />
<SettingsAdminConfigVariablesTable variables={groupData.variables} />
</StyledTableContainer>
))}
</>
);
};

View File

@ -0,0 +1,70 @@
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronRight } from 'twenty-ui/display';
import { ConfigVariable } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsAdminConfigVariablesRowProps = {
variable: ConfigVariable;
};
const StyledTruncatedCell = styled(TableCell)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
`;
const StyledTableRow = styled(TableRow)`
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledEllipsisLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const SettingsAdminConfigVariablesRow = ({
variable,
}: SettingsAdminConfigVariablesRowProps) => {
const theme = useTheme();
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: variable.value;
return (
<StyledTableRow
gridAutoColumns="5fr 3fr 1fr"
to={getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
})}
>
<StyledTruncatedCell color={theme.font.color.primary}>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell align="right">
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</StyledTruncatedCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,37 @@
import { SettingsAdminConfigVariablesRow } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { ConfigVariable } from '~/generated/graphql';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
type SettingsAdminConfigVariablesTableProps = {
variables: ConfigVariable[];
};
export const SettingsAdminConfigVariablesTable = ({
variables,
}: SettingsAdminConfigVariablesTableProps) => {
return (
<Table>
<TableRow gridAutoColumns="5fr 3fr 1fr">
<TableHeader>Name</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBody>
{variables.map((variable) => (
<SettingsAdminConfigVariablesRow
key={variable.name}
variable={variable}
/>
))}
</StyledTableBody>
</Table>
);
};

View File

@ -0,0 +1,15 @@
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { ThemeColor } from 'twenty-ui/theme';
type ConfigVariableSourceOption = {
value: ConfigVariableSourceFilter;
label: string;
color: ThemeColor | 'transparent';
};
export const ConfigVariableSourceOptions: ConfigVariableSourceOption[] = [
{ value: 'all', label: 'All Sources', color: 'transparent' },
{ value: 'database', label: 'Database', color: 'blue' },
{ value: 'environment', label: 'Environment', color: 'green' },
{ value: 'default', label: 'Default', color: 'gray' },
];

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const CREATE_DATABASE_CONFIG_VARIABLE = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)
}
`;

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_DATABASE_CONFIG_VARIABLE = gql`
mutation DeleteDatabaseConfigVariable($key: String!) {
deleteDatabaseConfigVariable(key: $key)
}
`;

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const UPDATE_DATABASE_CONFIG_VARIABLE = gql`
mutation UpdateDatabaseConfigVariable($key: String!, $value: JSON!) {
updateDatabaseConfigVariable(key: $key, value: $value)
}
`;

View File

@ -12,6 +12,10 @@ export const GET_CONFIG_VARIABLES_GROUPED = gql`
description
value
isSensitive
isEnvOnly
type
options
source
}
}
}

View File

@ -0,0 +1,16 @@
import { gql } from '@apollo/client';
export const GET_DATABASE_CONFIG_VARIABLE = gql`
query GetDatabaseConfigVariable($key: String!) {
getDatabaseConfigVariable(key: $key) {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
`;

View File

@ -0,0 +1,115 @@
import { useLingui } from '@lingui/react/macro';
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
useCreateDatabaseConfigVariableMutation,
useDeleteDatabaseConfigVariableMutation,
useUpdateDatabaseConfigVariableMutation,
} from '~/generated/graphql';
export const useConfigVariableActions = (variableName: string) => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const [updateDatabaseConfigVariable] =
useUpdateDatabaseConfigVariableMutation();
const [createDatabaseConfigVariable] =
useCreateDatabaseConfigVariableMutation();
const [deleteDatabaseConfigVariable] =
useDeleteDatabaseConfigVariableMutation();
const handleUpdateVariable = async (
value: ConfigVariableValue,
isFromDatabase: boolean,
) => {
try {
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
}
enqueueSnackBar(t`Variable updated successfully`, {
variant: SnackBarVariant.Success,
});
} catch (error) {
enqueueSnackBar(t`Failed to update variable`, {
variant: SnackBarVariant.Error,
});
}
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
}
try {
await deleteDatabaseConfigVariable({
variables: {
key: variableName,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
} catch (error) {
enqueueSnackBar(t`Failed to remove override`, {
variant: SnackBarVariant.Error,
});
}
};
return {
handleUpdateVariable,
handleDeleteVariable,
};
};

View File

@ -0,0 +1,54 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { ConfigVariableValue } from 'twenty-shared/types';
import { z } from 'zod';
import { ConfigVariable } from '~/generated/graphql';
type FormValues = {
value: ConfigVariableValue;
};
export const useConfigVariableForm = (variable?: ConfigVariable) => {
const validationSchema = z.object({
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.null(),
]),
});
const {
handleSubmit,
setValue,
formState: { isSubmitting },
watch,
} = useForm<FormValues>({
resolver: zodResolver(validationSchema),
values: { value: variable?.value ?? null },
});
const currentValue = watch('value');
const hasValueChanged = currentValue !== variable?.value;
const isValueValid = !!(
variable &&
!variable.isEnvOnly &&
hasValueChanged &&
((typeof currentValue === 'string' && currentValue.trim() !== '') ||
typeof currentValue === 'boolean' ||
typeof currentValue === 'number' ||
(Array.isArray(currentValue) && currentValue.length > 0))
);
return {
handleSubmit,
setValue,
isSubmitting,
watch,
currentValue,
hasValueChanged,
isValueValid,
};
};

View File

@ -0,0 +1 @@
export type ConfigVariableFilterCategory = 'source' | 'group';

View File

@ -0,0 +1 @@
export type ConfigVariableGroupFilter = 'all' | string;

View File

@ -0,0 +1,3 @@
export type ConfigVariableOptions =
| readonly (string | number | boolean)[]
| Record<string, string>;

View File

@ -0,0 +1,5 @@
export type ConfigVariableSourceFilter =
| 'all'
| 'database'
| 'environment'
| 'default';

View File

@ -0,0 +1,29 @@
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { ConfigSource } from '~/generated/graphql';
export const useSourceContent = (source: ConfigSource) => {
const { t } = useLingui();
const theme = useTheme();
switch (source) {
case ConfigSource.DATABASE:
return {
text: t`Stored in database`,
color: theme.color.blue50,
};
case ConfigSource.ENVIRONMENT:
return {
text: t`Environment variable`,
color: theme.color.green50,
};
case ConfigSource.DEFAULT:
return {
text: t`Default value`,
color: theme.font.color.tertiary,
};
default:
throw new Error(`Unknown source: ${source}`);
}
};

View File

@ -1,5 +1,5 @@
export const SETTINGS_ADMIN_TABS = {
GENERAL: 'general',
ENV_VARIABLES: 'env-variables',
CONFIG_VARIABLES: 'config-variables',
HEALTH_STATUS: 'health-status',
};

View File

@ -3,11 +3,11 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { BOOLEAN_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/boolean/constants/BooleanDataModelSelectOptions';
import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/fields/forms/boolean/hooks/useBooleanSettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro';
import { IconCheck } from 'twenty-ui/display';
import { BOOLEAN_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/boolean/constants/BooleanDataModelSelectOptions';
export const settingsDataModelFieldBooleanFormSchema = z.object({
defaultValue: z.boolean(),

View File

@ -39,7 +39,7 @@ export enum SettingsPath {
AdminPanel = 'admin-panel',
AdminPanelHealthStatus = 'admin-panel#health-status',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
AdminPanelOtherEnvVariables = 'admin-panel/other-env-variables',
AdminPanelConfigVariableDetails = 'admin-panel/config-variables/:variableName',
Lab = 'lab',
Roles = 'roles',
RoleCreate = 'roles/create',