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

@ -301,6 +301,7 @@ export type ClientConfig = {
defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String'];
isAttachmentPreviewEnabled: Scalars['Boolean'];
isConfigVariablesInDbEnabled: Scalars['Boolean'];
isEmailVerificationRequired: Scalars['Boolean'];
isGoogleCalendarEnabled: Scalars['Boolean'];
isGoogleMessagingEnabled: Scalars['Boolean'];
@ -318,14 +319,32 @@ export type ComputeStepOutputSchemaInput = {
step: Scalars['JSON'];
};
export enum ConfigSource {
DATABASE = 'DATABASE',
DEFAULT = 'DEFAULT',
ENVIRONMENT = 'ENVIRONMENT'
}
export type ConfigVariable = {
__typename?: 'ConfigVariable';
description: Scalars['String'];
isEnvOnly: Scalars['Boolean'];
isSensitive: Scalars['Boolean'];
name: Scalars['String'];
value: Scalars['String'];
options?: Maybe<Scalars['JSON']>;
source: ConfigSource;
type: ConfigVariableType;
value?: Maybe<Scalars['JSON']>;
};
export enum ConfigVariableType {
ARRAY = 'ARRAY',
BOOLEAN = 'BOOLEAN',
ENUM = 'ENUM',
NUMBER = 'NUMBER',
STRING = 'STRING'
}
export enum ConfigVariablesGroup {
AnalyticsConfig = 'AnalyticsConfig',
BillingConfig = 'BillingConfig',
@ -868,6 +887,7 @@ export type Mutation = {
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@ -880,6 +900,7 @@ export type Mutation = {
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteOneField: Field;
deleteOneObject: Object;
deleteOneRole: Scalars['String'];
@ -914,6 +935,7 @@ export type Mutation = {
switchToYearlyInterval: BillingUpdateOutput;
track: Analytics;
trackAnalytics: Analytics;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field;
updateOneObject: Object;
@ -971,6 +993,12 @@ export type MutationCreateApprovedAccessDomainArgs = {
};
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@ -1016,6 +1044,11 @@ export type MutationDeleteApprovedAccessDomainArgs = {
};
export type MutationDeleteDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@ -1162,6 +1195,12 @@ export type MutationTrackAnalyticsArgs = {
};
export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationUpdateLabPublicFeatureFlagArgs = {
input: UpdateLabPublicFeatureFlagInput;
};
@ -1485,6 +1524,7 @@ export type Query = {
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON'];
getConfigVariablesGrouped: ConfigVariablesOutput;
getDatabaseConfigVariable: ConfigVariable;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
getPostgresCredentials?: Maybe<PostgresCredentials>;
@ -1540,6 +1580,11 @@ export type QueryGetAvailablePackagesArgs = {
};
export type QueryGetDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type QueryGetIndicatorHealthStatusArgs = {
indicatorId: HealthIndicatorId;
};
@ -2665,7 +2710,7 @@ export type SwitchSubscriptionToYearlyIntervalMutation = { __typename?: 'Mutatio
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } };
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, isConfigVariablesInDbEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } };
export type SearchQueryVariables = Exact<{
searchInput: Scalars['String'];
@ -2683,6 +2728,41 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['JSON'];
}>;
export type CreateDatabaseConfigVariableMutation = { __typename?: 'Mutation', createDatabaseConfigVariable: boolean };
export type DeleteDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
}>;
export type DeleteDatabaseConfigVariableMutation = { __typename?: 'Mutation', deleteDatabaseConfigVariable: boolean };
export type UpdateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['JSON'];
}>;
export type UpdateDatabaseConfigVariableMutation = { __typename?: 'Mutation', updateDatabaseConfigVariable: boolean };
export type GetConfigVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>;
export type GetConfigVariablesGroupedQuery = { __typename?: 'Query', getConfigVariablesGrouped: { __typename?: 'ConfigVariablesOutput', groups: Array<{ __typename?: 'ConfigVariablesGroupData', name: ConfigVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'ConfigVariable', name: string, description: string, value?: any | null, isSensitive: boolean, isEnvOnly: boolean, type: ConfigVariableType, options?: any | null, source: ConfigSource }> }> } };
export type GetDatabaseConfigVariableQueryVariables = Exact<{
key: Scalars['String'];
}>;
export type GetDatabaseConfigVariableQuery = { __typename?: 'Query', getDatabaseConfigVariable: { __typename?: 'ConfigVariable', name: string, description: string, value?: any | null, isSensitive: boolean, isEnvOnly: boolean, type: ConfigVariableType, options?: any | null, source: ConfigSource } };
export type UpdateWorkspaceFeatureFlagMutationVariables = Exact<{
workspaceId: Scalars['String'];
featureFlag: Scalars['String'];
@ -2699,11 +2779,6 @@ export type UserLookupAdminPanelMutationVariables = Exact<{
export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } };
export type GetConfigVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>;
export type GetConfigVariablesGroupedQuery = { __typename?: 'Query', getConfigVariablesGrouped: { __typename?: 'ConfigVariablesOutput', groups: Array<{ __typename?: 'ConfigVariablesGroupData', name: ConfigVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'ConfigVariable', name: string, description: string, value: string, isSensitive: boolean }> }> } };
export type GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>;
@ -4510,6 +4585,7 @@ export const GetClientConfigDocument = gql`
isMicrosoftCalendarEnabled
isGoogleMessagingEnabled
isGoogleCalendarEnabled
isConfigVariablesInDbEnabled
}
}
`;
@ -4622,6 +4698,191 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const CreateDatabaseConfigVariableDocument = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)
}
`;
export type CreateDatabaseConfigVariableMutationFn = Apollo.MutationFunction<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
/**
* __useCreateDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useCreateDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateDatabaseConfigVariableMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createDatabaseConfigVariableMutation, { data, loading, error }] = useCreateDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* value: // value for 'value'
* },
* });
*/
export function useCreateDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>(CreateDatabaseConfigVariableDocument, options);
}
export type CreateDatabaseConfigVariableMutationHookResult = ReturnType<typeof useCreateDatabaseConfigVariableMutation>;
export type CreateDatabaseConfigVariableMutationResult = Apollo.MutationResult<CreateDatabaseConfigVariableMutation>;
export type CreateDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
export const DeleteDatabaseConfigVariableDocument = gql`
mutation DeleteDatabaseConfigVariable($key: String!) {
deleteDatabaseConfigVariable(key: $key)
}
`;
export type DeleteDatabaseConfigVariableMutationFn = Apollo.MutationFunction<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
/**
* __useDeleteDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useDeleteDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteDatabaseConfigVariableMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteDatabaseConfigVariableMutation, { data, loading, error }] = useDeleteDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* },
* });
*/
export function useDeleteDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>(DeleteDatabaseConfigVariableDocument, options);
}
export type DeleteDatabaseConfigVariableMutationHookResult = ReturnType<typeof useDeleteDatabaseConfigVariableMutation>;
export type DeleteDatabaseConfigVariableMutationResult = Apollo.MutationResult<DeleteDatabaseConfigVariableMutation>;
export type DeleteDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
export const UpdateDatabaseConfigVariableDocument = gql`
mutation UpdateDatabaseConfigVariable($key: String!, $value: JSON!) {
updateDatabaseConfigVariable(key: $key, value: $value)
}
`;
export type UpdateDatabaseConfigVariableMutationFn = Apollo.MutationFunction<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
/**
* __useUpdateDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useUpdateDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateDatabaseConfigVariableMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateDatabaseConfigVariableMutation, { data, loading, error }] = useUpdateDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* value: // value for 'value'
* },
* });
*/
export function useUpdateDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>(UpdateDatabaseConfigVariableDocument, options);
}
export type UpdateDatabaseConfigVariableMutationHookResult = ReturnType<typeof useUpdateDatabaseConfigVariableMutation>;
export type UpdateDatabaseConfigVariableMutationResult = Apollo.MutationResult<UpdateDatabaseConfigVariableMutation>;
export type UpdateDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
export const GetConfigVariablesGroupedDocument = gql`
query GetConfigVariablesGrouped {
getConfigVariablesGrouped {
groups {
name
description
isHiddenOnLoad
variables {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
}
}
`;
/**
* __useGetConfigVariablesGroupedQuery__
*
* To run a query within a React component, call `useGetConfigVariablesGroupedQuery` and pass it any options that fit your needs.
* When your component renders, `useGetConfigVariablesGroupedQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetConfigVariablesGroupedQuery({
* variables: {
* },
* });
*/
export function useGetConfigVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export function useGetConfigVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export type GetConfigVariablesGroupedQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedQuery>;
export type GetConfigVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedLazyQuery>;
export type GetConfigVariablesGroupedQueryResult = Apollo.QueryResult<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>;
export const GetDatabaseConfigVariableDocument = gql`
query GetDatabaseConfigVariable($key: String!) {
getDatabaseConfigVariable(key: $key) {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
`;
/**
* __useGetDatabaseConfigVariableQuery__
*
* To run a query within a React component, call `useGetDatabaseConfigVariableQuery` and pass it any options that fit your needs.
* When your component renders, `useGetDatabaseConfigVariableQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetDatabaseConfigVariableQuery({
* variables: {
* key: // value for 'key'
* },
* });
*/
export function useGetDatabaseConfigVariableQuery(baseOptions: Apollo.QueryHookOptions<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>(GetDatabaseConfigVariableDocument, options);
}
export function useGetDatabaseConfigVariableLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>(GetDatabaseConfigVariableDocument, options);
}
export type GetDatabaseConfigVariableQueryHookResult = ReturnType<typeof useGetDatabaseConfigVariableQuery>;
export type GetDatabaseConfigVariableLazyQueryHookResult = ReturnType<typeof useGetDatabaseConfigVariableLazyQuery>;
export type GetDatabaseConfigVariableQueryResult = Apollo.QueryResult<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>;
export const UpdateWorkspaceFeatureFlagDocument = gql`
mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) {
updateWorkspaceFeatureFlag(
@ -4714,50 +4975,6 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
export const GetConfigVariablesGroupedDocument = gql`
query GetConfigVariablesGrouped {
getConfigVariablesGrouped {
groups {
name
description
isHiddenOnLoad
variables {
name
description
value
isSensitive
}
}
}
}
`;
/**
* __useGetConfigVariablesGroupedQuery__
*
* To run a query within a React component, call `useGetConfigVariablesGroupedQuery` and pass it any options that fit your needs.
* When your component renders, `useGetConfigVariablesGroupedQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetConfigVariablesGroupedQuery({
* variables: {
* },
* });
*/
export function useGetConfigVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export function useGetConfigVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export type GetConfigVariablesGroupedQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedQuery>;
export type GetConfigVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedLazyQuery>;
export type GetConfigVariablesGroupedQueryResult = Apollo.QueryResult<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>;
export const GetVersionInfoDocument = gql`
query GetVersionInfo {
versionInfo {

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',

View File

@ -0,0 +1,211 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { Form, useParams } from 'react-router-dom';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { ConfigVariableHelpText } from '@/settings/admin-panel/config-variables/components/ConfigVariableHelpText';
import { ConfigVariableValueInput } from '@/settings/admin-panel/config-variables/components/ConfigVariableValueInput';
import { useConfigVariableActions } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableActions';
import { useConfigVariableForm } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableForm';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useRecoilValue } from 'recoil';
import { ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { H3Title, IconCheck, IconPencil, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
ConfigSource,
useGetDatabaseConfigVariableQuery,
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledForm = styled(Form)`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledH3Title = styled(H3Title)`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledRow = styled.div`
display: flex;
align-items: flex-end;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonContainer = styled.div`
display: flex;
& > :not(:first-of-type) > button {
border-left: none;
}
`;
export const SettingsAdminConfigVariableDetails = () => {
const { variableName } = useParams();
const { t } = useLingui();
const [isEditing, setIsEditing] = useState(false);
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const { data: configVariableData, loading } =
useGetDatabaseConfigVariableQuery({
variables: { key: variableName ?? '' },
fetchPolicy: 'network-only',
});
const variable = configVariableData?.getDatabaseConfigVariable;
const { handleUpdateVariable, handleDeleteVariable } =
useConfigVariableActions(variable?.name ?? '');
const {
handleSubmit,
setValue,
isSubmitting,
watch,
hasValueChanged,
isValueValid,
} = useConfigVariableForm(variable);
if (loading === true || isDefined(variable) === false) {
return <SettingsSkeletonLoader />;
}
const isEnvOnly = variable.isEnvOnly;
const isFromDatabase = variable.source === ConfigSource.DATABASE;
const onSubmit = async (formData: { value: ConfigVariableValue }) => {
await handleUpdateVariable(formData.value, isFromDatabase);
setIsEditing(false);
};
const handleEditClick = () => {
setIsEditing(true);
};
const handleXButtonClick = () => {
if (isFromDatabase && hasValueChanged) {
setValue('value', variable.value);
setIsEditing(false);
return;
}
if (isFromDatabase && !hasValueChanged) {
setIsConfirmationModalOpen(true);
return;
}
setValue('value', variable.value);
setIsEditing(false);
};
const handleConfirmReset = () => {
handleDeleteVariable();
setIsEditing(false);
};
return (
<>
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Config Variables`,
href: getSettingsPath(
SettingsPath.AdminPanel,
undefined,
undefined,
'config-variables',
),
},
{
children: variable.name,
},
]}
>
<SettingsPageContainer>
<StyledH3Title
title={variable.name}
description={variable.description}
/>
<StyledForm onSubmit={handleSubmit(onSubmit)}>
<StyledRow>
<ConfigVariableValueInput
variable={variable}
value={watch('value')}
onChange={(value) => setValue('value', value)}
disabled={isEnvOnly || !isEditing}
/>
{!isEditing ? (
<Button
Icon={IconPencil}
variant="primary"
onClick={handleEditClick}
type="button"
disabled={isEnvOnly || !isConfigVariablesInDbEnabled}
/>
) : (
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
position="left"
type="submit"
disabled={isSubmitting || !isValueValid || !hasValueChanged}
/>
<Button
Icon={IconX}
variant="secondary"
position="right"
onClick={handleXButtonClick}
type="button"
disabled={isSubmitting}
/>
</StyledButtonContainer>
)}
</StyledRow>
<ConfigVariableHelpText
variable={variable}
hasValueChanged={hasValueChanged}
/>
</StyledForm>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
isOpen={isConfirmationModalOpen}
setIsOpen={(isOpen) => {
setIsConfirmationModalOpen(isOpen);
if (!isOpen) {
setIsEditing(false);
}
}}
title={t`Reset variable`}
subtitle={t`This will revert the database value to environment/default value. The database override will be removed and the system will use the environment settings.`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</>
);
};

View File

@ -1,60 +0,0 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { useGetConfigVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div``;
export const SettingsAdminSecondaryEnvVariables = () => {
const {
data: secondaryConfigVariables,
loading: secondaryConfigVariablesLoading,
} = useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const hiddenGroups =
secondaryConfigVariables?.getConfigVariablesGrouped.groups.filter(
(group) => group.isHiddenOnLoad,
) ?? [];
if (secondaryConfigVariablesLoading) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
title={t`Other Environment Variables`}
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Other Environment Variables`,
},
]}
>
<SettingsPageContainer>
{hiddenGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H2Title title={group.name} description={group.description} />
{group.variables.length > 0 && (
<SettingsAdminEnvVariablesTable variables={group.variables} />
)}
</StyledGroupContainer>
))}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -58,4 +58,5 @@ export const mockedClientConfig: ClientConfig = {
isGoogleMessagingEnabled: true,
isGoogleCalendarEnabled: true,
isAttachmentPreviewEnabled: true,
isConfigVariablesInDbEnabled: false,
};

View File

@ -10,6 +10,7 @@ export const getSettingsPath = <T extends SettingsPath>(
[key in PathParam<`/${AppPath.Settings}/${T}`>]: string | null;
},
queryParams?: Record<string, any>,
hash?: string,
) => {
let path = `/${AppPath.Settings}/${to}`;
@ -32,5 +33,9 @@ export const getSettingsPath = <T extends SettingsPath>(
}
}
if (isDefined(hash)) {
path += `#${hash.replace(/^#/, '')}`;
}
return path;
};