Add workspace DDL lock env var and maintenance mode UI (#19130)
## Summary - Add `WORKSPACE_SCHEMA_DDL_LOCKED` env-only boolean config variable that blocks all workspace schema DDL changes when set to `true`. This is intended for hot upgrades where logical replication cannot handle DDL changes. Enforced at two chokepoints: - `WorkspaceMigrationRunnerService.run` — blocks all metadata-driven DDL (object/field/index CRUD, app sync/uninstall, standard app sync, upgrade commands) - `WorkspaceDataSourceService.createWorkspaceDBSchema` / `deleteWorkspaceDBSchema` — blocks workspace creation (sign-up) and hard deletion. Uses a dedicated `WorkspaceDataSourceException` (not ForbiddenException) - Add maintenance mode feature with Admin Panel UI and user-facing banner: - **Backend**: `MaintenanceModeService` stores maintenance window (startAt, endAt, optional link) in `core.keyValuePair` as `CONFIG_VARIABLE`. Validates endAt > startAt. Uses `GraphQLISODateTime` scalar for date fields. Exposed via `clientConfig` REST endpoint and admin GraphQL mutations (`setMaintenanceMode`, `clearMaintenanceMode`) - **Admin Panel**: New "Maintenance Mode" section in Health tab with UTC datetime pickers and activate/deactivate controls - **Banner**: `InformationBannerMaintenance` displayed at the top of `DefaultLayout` for all users, using Temporal API for timezone-aware formatting with an optional "Learn more" link These two features are **independent** — the DDL lock is controlled via env var for operational use, while maintenance mode is a UI notification mechanism controlled from the admin panel.
This commit is contained in:
+17
-13
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/auth/states/signInUpStepState';
|
||||
import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
@@ -136,6 +137,7 @@ const StyledForgotPasswordLinkContainer = styled.div`
|
||||
export const SignInUpGlobalScopeForm = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const authProviders = useAtomStateValue(authProvidersState);
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
const signInUpStep = useAtomStateValue(signInUpStepState);
|
||||
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
|
||||
const { signOut } = useAuth();
|
||||
@@ -206,19 +208,21 @@ export const SignInUpGlobalScopeForm = () => {
|
||||
</StyledWorkspaceItem>
|
||||
</UndecoratedLink>
|
||||
))}
|
||||
<StyledWorkspaceItem onClick={() => createWorkspace()}>
|
||||
<StyledWorkspaceContent>
|
||||
<StyledWorkspaceLogo>
|
||||
<IconPlus size={theme.icon.size.lg} />
|
||||
</StyledWorkspaceLogo>
|
||||
<StyledWorkspaceTextContainer>
|
||||
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
|
||||
</StyledWorkspaceTextContainer>
|
||||
<StyledChevronIcon>
|
||||
<IconChevronRight size={theme.icon.size.md} />
|
||||
</StyledChevronIcon>
|
||||
</StyledWorkspaceContent>
|
||||
</StyledWorkspaceItem>
|
||||
{!isDDLLocked && (
|
||||
<StyledWorkspaceItem onClick={() => createWorkspace()}>
|
||||
<StyledWorkspaceContent>
|
||||
<StyledWorkspaceLogo>
|
||||
<IconPlus size={theme.icon.size.lg} />
|
||||
</StyledWorkspaceLogo>
|
||||
<StyledWorkspaceTextContainer>
|
||||
<StyledWorkspaceName>{t`Create a workspace`}</StyledWorkspaceName>
|
||||
</StyledWorkspaceTextContainer>
|
||||
<StyledChevronIcon>
|
||||
<IconChevronRight size={theme.icon.size.md} />
|
||||
</StyledChevronIcon>
|
||||
</StyledWorkspaceContent>
|
||||
</StyledWorkspaceItem>
|
||||
)}
|
||||
</StyledWorkspaceContainer>
|
||||
<StyledActionLinkContainer>
|
||||
<ClickToActionLink onClick={signOut}>
|
||||
|
||||
+14
-1
@@ -15,6 +15,7 @@ import { AuthenticatedMethod } from '@/auth/types/AuthenticatedMethod.enum';
|
||||
import { SignInUpMode } from '@/auth/types/signInUpMode';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { captchaState } from '@/client-config/states/captchaState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -22,6 +23,7 @@ import { useFormContext } from 'react-hook-form';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
@@ -29,6 +31,7 @@ const StyledForm = styled.form`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@@ -43,6 +46,7 @@ export const SignInUpWithCredentials = ({
|
||||
const [signInUpStep, setSignInUpStep] = useAtomState(signInUpStepState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const captcha = useAtomStateValue(captchaState);
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
const isRequestingCaptchaToken = useAtomStateValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
@@ -129,9 +133,15 @@ export const SignInUpWithCredentials = ({
|
||||
form.formState.isSubmitting ||
|
||||
shouldWaitForCaptchaToken);
|
||||
|
||||
const isSignUpBlockedByDDLLock =
|
||||
isDDLLocked &&
|
||||
signInUpMode === SignInUpMode.SignUp &&
|
||||
signInUpStep === SignInUpStep.Password;
|
||||
|
||||
const isSubmitButtonDisabled =
|
||||
isEmailStepSubmitButtonDisabledCondition ||
|
||||
isPasswordStepSubmitButtonDisabledCondition;
|
||||
isPasswordStepSubmitButtonDisabledCondition ||
|
||||
isSignUpBlockedByDDLLock;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -163,6 +173,9 @@ export const SignInUpWithCredentials = ({
|
||||
fullWidth
|
||||
/>
|
||||
{isLastUsed && <LastUsedPill />}
|
||||
{isSignUpBlockedByDDLLock && (
|
||||
<InputHint>{t`Sign-up is temporarily unavailable during maintenance.`}</InputHint>
|
||||
)}
|
||||
</StyledSSOButtonContainer>
|
||||
</StyledForm>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConf
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
|
||||
@@ -117,6 +119,10 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
isClickHouseConfiguredState,
|
||||
);
|
||||
|
||||
const setIsDDLLocked = useSetAtomState(isDDLLockedState);
|
||||
|
||||
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
|
||||
|
||||
const setAppVersion = useSetAtomState(appVersionState);
|
||||
|
||||
const fetchClientConfig = useCallback(async () => {
|
||||
@@ -195,6 +201,8 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
clientConfig?.isCloudflareIntegrationEnabled,
|
||||
);
|
||||
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
|
||||
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
|
||||
setMaintenanceMode(clientConfig?.maintenance ?? null);
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error('Failed to fetch client config');
|
||||
@@ -229,7 +237,9 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsEmailingDomainsEnabled,
|
||||
setIsClickHouseConfigured,
|
||||
setIsCloudflareIntegrationEnabled,
|
||||
setIsDDLLocked,
|
||||
setLabPublicFeatureFlags,
|
||||
setMaintenanceMode,
|
||||
setIsMicrosoftCalendarEnabled,
|
||||
setIsMicrosoftMessagingEnabled,
|
||||
setSentryConfig,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isDDLLockedState = createAtomState<boolean>({
|
||||
key: 'isDDLLocked',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { type ClientConfigMaintenanceMode } from '~/generated-metadata/graphql';
|
||||
|
||||
type MaintenanceModeState = Pick<
|
||||
ClientConfigMaintenanceMode,
|
||||
'startAt' | 'endAt' | 'link'
|
||||
>;
|
||||
|
||||
export const maintenanceModeState =
|
||||
createAtomState<MaintenanceModeState | null>({
|
||||
key: 'maintenanceModeState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Billing,
|
||||
type Captcha,
|
||||
type ClientAiModelConfig,
|
||||
type ClientConfigMaintenanceMode,
|
||||
type PublicFeatureFlag,
|
||||
type Sentry,
|
||||
type Support,
|
||||
@@ -33,10 +34,12 @@ export type ClientConfig = {
|
||||
isEmailingDomainsEnabled: boolean;
|
||||
isCloudflareIntegrationEnabled: boolean;
|
||||
isClickHouseConfigured: boolean;
|
||||
isWorkspaceSchemaDDLLocked: boolean;
|
||||
publicFeatureFlags: Array<PublicFeatureFlag>;
|
||||
sentry: Sentry;
|
||||
signInPrefilled: boolean;
|
||||
support: Support;
|
||||
isTwoFactorAuthenticationEnabled: boolean;
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
maintenance?: ClientConfigMaintenanceMode;
|
||||
};
|
||||
|
||||
+17
-4
@@ -5,6 +5,7 @@ import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Banner,
|
||||
type BannerColor,
|
||||
type BannerVariant,
|
||||
type IconComponent,
|
||||
IconX,
|
||||
@@ -21,6 +22,10 @@ const StyledInvertedIconButton = styled(IconButton)`
|
||||
color: ${themeCssVariables.font.color.inverted} !important;
|
||||
`;
|
||||
|
||||
const StyledSecondaryIconButton = styled(IconButton)`
|
||||
color: inherit !important;
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div<{ hasCloseButton: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -32,7 +37,8 @@ const StyledContent = styled.div<{ hasCloseButton: boolean }>`
|
||||
|
||||
export const InformationBanner = ({
|
||||
message,
|
||||
variant = 'default',
|
||||
color = 'blue',
|
||||
variant = 'primary',
|
||||
buttonTitle,
|
||||
buttonIcon,
|
||||
buttonOnClick,
|
||||
@@ -41,6 +47,7 @@ export const InformationBanner = ({
|
||||
componentInstanceId,
|
||||
}: {
|
||||
message: string;
|
||||
color?: BannerColor;
|
||||
variant?: BannerVariant;
|
||||
buttonTitle?: string;
|
||||
buttonIcon?: IconComponent;
|
||||
@@ -54,6 +61,12 @@ export const InformationBanner = ({
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const isPrimary = variant === 'primary';
|
||||
|
||||
const CloseIconButton = isPrimary
|
||||
? StyledInvertedIconButton
|
||||
: StyledSecondaryIconButton;
|
||||
|
||||
return (
|
||||
<InformationBannerComponentInstanceContext.Provider
|
||||
value={{
|
||||
@@ -61,7 +74,7 @@ export const InformationBanner = ({
|
||||
}}
|
||||
>
|
||||
{informationBannerIsOpen && (
|
||||
<Banner variant={variant}>
|
||||
<Banner color={color} variant={variant}>
|
||||
<StyledContent hasCloseButton={!!onClose}>
|
||||
<StyledText>{message}</StyledText>
|
||||
{buttonTitle && buttonOnClick && (
|
||||
@@ -70,14 +83,14 @@ export const InformationBanner = ({
|
||||
title={buttonTitle}
|
||||
Icon={buttonIcon}
|
||||
size="small"
|
||||
inverted
|
||||
inverted={isPrimary}
|
||||
onClick={buttonOnClick}
|
||||
disabled={isButtonDisabled}
|
||||
/>
|
||||
)}
|
||||
</StyledContent>
|
||||
{onClose && (
|
||||
<StyledInvertedIconButton
|
||||
<CloseIconButton
|
||||
Icon={IconX}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import { InformationBannerEndTrialPeriod } from '@/information-banner/components
|
||||
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
|
||||
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
|
||||
import { InformationBannerLegacyEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey';
|
||||
import { InformationBannerMaintenance } from '@/information-banner/components/maintenance/InformationBannerMaintenance';
|
||||
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
|
||||
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
@@ -53,6 +54,7 @@ export const InformationBannerWrapper = () => {
|
||||
|
||||
return (
|
||||
<StyledInformationBannerWrapper>
|
||||
<InformationBannerMaintenance />
|
||||
<InformationBannerLegacyEnterpriseKey />
|
||||
{isAccountSyncEnabled && (
|
||||
<InformationBannerReconnectAccountInsufficientPermissions />
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ export const InformationBannerBillingSubscriptionPaused = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-billing-subscription-paused"
|
||||
variant="danger"
|
||||
color="danger"
|
||||
variant="secondary"
|
||||
message={
|
||||
hasPermissionToUpdateBillingDetails
|
||||
? t`Trial expired. Please update your billing details.`
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ export const InformationBannerEndTrialPeriod = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-end-trial-period"
|
||||
variant="danger"
|
||||
color="danger"
|
||||
variant="secondary"
|
||||
message={
|
||||
hasPermissionToEndTrialPeriod
|
||||
? t`End trial period to continue using Workflow or AI features.`
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ export const InformationBannerFailPaymentInfo = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-fail-payment-info"
|
||||
variant="danger"
|
||||
color="danger"
|
||||
variant="secondary"
|
||||
message={
|
||||
hasPermissionToUpdateBillingDetails
|
||||
? t`Last payment failed. Please update your billing details.`
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ export const InformationBannerNoBillingSubscription = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-no-billing-subscription"
|
||||
variant="danger"
|
||||
color="danger"
|
||||
variant="secondary"
|
||||
message={
|
||||
hasPermissionToSubscribe
|
||||
? t`Your workspace doesn't have an active subscription.`
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ export const InformationBannerDeletedRecord = ({
|
||||
<StyledInformationBannerDeletedRecord>
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-deleted-record"
|
||||
variant="danger"
|
||||
color="danger"
|
||||
message={t`This record has been deleted`}
|
||||
buttonTitle={t`Restore`}
|
||||
buttonIcon={IconRefresh}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export const InformationBannerLegacyEnterpriseKey = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId={COMPONENT_INSTANCE_ID}
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
message={t`Your enterprise key format is deprecated. Please activate a new key to keep enterprise features.`}
|
||||
buttonTitle={t`Activate`}
|
||||
buttonIcon={IconKey}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { useMaintenanceModeBannerDismissal } from '@/information-banner/hooks/useMaintenanceModeBannerDismissal';
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconExternalLink } from 'twenty-ui/display';
|
||||
|
||||
const formatMaintenanceDateTime = (
|
||||
isoString: string,
|
||||
timeZone: string,
|
||||
): string => {
|
||||
const zonedDateTime =
|
||||
Temporal.Instant.from(isoString).toZonedDateTimeISO(timeZone);
|
||||
|
||||
return zonedDateTime.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
export const InformationBannerMaintenance = () => {
|
||||
const maintenanceMode = useAtomStateValue(maintenanceModeState);
|
||||
const { timeZone } = useDateTimeFormat();
|
||||
const { dismissBanner, isDismissed, isLoading } =
|
||||
useMaintenanceModeBannerDismissal({
|
||||
enabled: isDefined(maintenanceMode),
|
||||
maintenanceStartAt: maintenanceMode?.startAt,
|
||||
});
|
||||
|
||||
if (!isDefined(maintenanceMode) || isLoading || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startFormatted = formatMaintenanceDateTime(
|
||||
maintenanceMode.startAt,
|
||||
timeZone,
|
||||
);
|
||||
const endFormatted = formatMaintenanceDateTime(
|
||||
maintenanceMode.endAt,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const message = t`Scheduled maintenance: ${startFormatted} — ${endFormatted}`;
|
||||
const maintenanceLink = isNonEmptyString(maintenanceMode.link?.trim())
|
||||
? maintenanceMode.link.trim()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-maintenance"
|
||||
variant="secondary"
|
||||
message={message}
|
||||
buttonTitle={isDefined(maintenanceLink) ? t`Learn more` : undefined}
|
||||
buttonIcon={isDefined(maintenanceLink) ? IconExternalLink : undefined}
|
||||
buttonOnClick={
|
||||
isDefined(maintenanceLink)
|
||||
? () => window.open(maintenanceLink, '_blank', 'noopener,noreferrer')
|
||||
: undefined
|
||||
}
|
||||
onClose={dismissBanner}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
@@ -32,6 +32,7 @@ export const InformationBannerReconnectAccountEmailAliases = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId={COMPONENT_INSTANCE_ID}
|
||||
variant="secondary"
|
||||
message={t`Please reconnect your mailbox ${mailboxHandle} to update your email aliases:`}
|
||||
buttonTitle={t`Reconnect`}
|
||||
buttonIcon={IconRefresh}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ export const InformationBannerReconnectAccountInsufficientPermissions = () => {
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId={COMPONENT_INSTANCE_ID}
|
||||
variant="secondary"
|
||||
message={t`Sync lost with mailbox ${mailboxHandle}. Please reconnect for updates:`}
|
||||
buttonTitle={t`Reconnect`}
|
||||
buttonIcon={IconRefresh}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DISMISS_MAINTENANCE_MODE_BANNER = gql`
|
||||
mutation DismissMaintenanceModeBanner {
|
||||
dismissMaintenanceModeBanner
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const IS_MAINTENANCE_MODE_BANNER_DISMISSED = gql`
|
||||
query IsMaintenanceModeBannerDismissed {
|
||||
isMaintenanceModeBannerDismissed
|
||||
}
|
||||
`;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DISMISS_MAINTENANCE_MODE_BANNER } from '@/information-banner/graphql/mutations/dismissMaintenanceModeBanner';
|
||||
import { IS_MAINTENANCE_MODE_BANNER_DISMISSED } from '@/information-banner/graphql/queries/isMaintenanceModeBannerDismissed';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
|
||||
export const useMaintenanceModeBannerDismissal = ({
|
||||
enabled,
|
||||
maintenanceStartAt,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
maintenanceStartAt?: string;
|
||||
}) => {
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const { data, loading, refetch } = useQuery<{
|
||||
isMaintenanceModeBannerDismissed: boolean;
|
||||
}>(IS_MAINTENANCE_MODE_BANNER_DISMISSED, {
|
||||
client: apolloCoreClient,
|
||||
skip: !enabled,
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const [mutate] = useMutation<{
|
||||
dismissMaintenanceModeBanner: boolean;
|
||||
}>(DISMISS_MAINTENANCE_MODE_BANNER, {
|
||||
client: apolloCoreClient,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsDismissed(false);
|
||||
|
||||
return;
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDismissed(false);
|
||||
void refetch();
|
||||
}, [enabled, maintenanceStartAt, refetch]);
|
||||
|
||||
const dismissBanner = async () => {
|
||||
await mutate();
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return {
|
||||
dismissBanner,
|
||||
isDismissed: isDismissed || data?.isMaintenanceModeBannerDismissed === true,
|
||||
isLoading: enabled ? loading : false,
|
||||
};
|
||||
};
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
|
||||
import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusListCard';
|
||||
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
|
||||
import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
@@ -22,6 +24,7 @@ export const SettingsAdminHealthStatus = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsAdminMaintenanceModeFetchEffect />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Health Status`}
|
||||
@@ -32,6 +35,7 @@ export const SettingsAdminHealthStatus = () => {
|
||||
loading={loadingHealthStatus}
|
||||
/>
|
||||
</Section>
|
||||
<SettingsAdminMaintenanceMode />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconLink, IconTool, Status } from 'twenty-ui/display';
|
||||
import { Card, CardContent, Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { CLEAR_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/clearMaintenanceMode';
|
||||
import { SET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/setMaintenanceMode';
|
||||
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
|
||||
import { SettingsDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const StyledFormContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledStatusRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const SettingsAdminMaintenanceMode = () => {
|
||||
const [adminPanelMaintenanceMode, setAdminPanelMaintenanceMode] =
|
||||
useAtomState(adminPanelMaintenanceModeState);
|
||||
|
||||
const maintenanceMode = useAtomStateValue(maintenanceModeState);
|
||||
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [setMaintenanceModeMutation] = useMutation(SET_MAINTENANCE_MODE);
|
||||
const [clearMaintenanceModeMutation] = useMutation(CLEAR_MAINTENANCE_MODE);
|
||||
|
||||
const isEnabled = isDefined(adminPanelMaintenanceMode);
|
||||
|
||||
const startDate =
|
||||
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.startAt)
|
||||
? new Date(adminPanelMaintenanceMode.startAt)
|
||||
: undefined;
|
||||
|
||||
const endDate =
|
||||
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.endAt)
|
||||
? new Date(adminPanelMaintenanceMode.endAt)
|
||||
: undefined;
|
||||
|
||||
const hasValidDateRange =
|
||||
isDefined(startDate) &&
|
||||
isDefined(endDate) &&
|
||||
endDate.getTime() > startDate.getTime();
|
||||
|
||||
const isScheduled =
|
||||
isDefined(maintenanceMode) &&
|
||||
isNonEmptyString(maintenanceMode.startAt) &&
|
||||
isNonEmptyString(maintenanceMode.endAt);
|
||||
|
||||
const saveToBackend = useCallback(
|
||||
async (startAt: string, endAt: string, link?: string) => {
|
||||
if (!isNonEmptyString(startAt) || !isNonEmptyString(endAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setMaintenanceModeMutation({
|
||||
variables: { startAt, endAt, link },
|
||||
});
|
||||
|
||||
setMaintenanceMode({
|
||||
startAt,
|
||||
endAt,
|
||||
link,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
enqueueErrorSnackBar({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t`Failed to set maintenance mode.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setMaintenanceModeMutation, setMaintenanceMode, enqueueErrorSnackBar],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAdminPanelMaintenanceMode({ startAt: '', endAt: '' });
|
||||
} else {
|
||||
await clearMaintenanceModeMutation();
|
||||
setAdminPanelMaintenanceMode(null);
|
||||
setMaintenanceMode(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
clearMaintenanceModeMutation,
|
||||
setAdminPanelMaintenanceMode,
|
||||
setMaintenanceMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(field: 'startAt' | 'endAt') => (value: Date | undefined) => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isoValue = isDefined(value) ? value.toISOString() : '';
|
||||
const nextState = { ...adminPanelMaintenanceMode, [field]: isoValue };
|
||||
|
||||
setAdminPanelMaintenanceMode(nextState);
|
||||
saveToBackend(
|
||||
nextState.startAt,
|
||||
nextState.endAt,
|
||||
isNonEmptyString(nextState.link) ? nextState.link : undefined,
|
||||
);
|
||||
},
|
||||
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode, saveToBackend],
|
||||
);
|
||||
|
||||
const handleLinkChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminPanelMaintenanceMode({
|
||||
...adminPanelMaintenanceMode,
|
||||
link: value,
|
||||
});
|
||||
},
|
||||
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode],
|
||||
);
|
||||
|
||||
const handleLinkBlur = useCallback(() => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveToBackend(
|
||||
adminPanelMaintenanceMode.startAt,
|
||||
adminPanelMaintenanceMode.endAt,
|
||||
isNonEmptyString(adminPanelMaintenanceMode.link)
|
||||
? adminPanelMaintenanceMode.link
|
||||
: undefined,
|
||||
);
|
||||
}, [adminPanelMaintenanceMode, saveToBackend]);
|
||||
|
||||
const scheduledStartDate = isScheduled
|
||||
? new Date(maintenanceMode.startAt)
|
||||
: undefined;
|
||||
|
||||
const formattedStartDate = isDefined(scheduledStartDate)
|
||||
? scheduledStartDate.toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '';
|
||||
|
||||
const toggleDescription = isScheduled
|
||||
? t`Planned for ${formattedStartDate}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Maintenance`}
|
||||
description={t`Schedule a maintenance window and notify all users`}
|
||||
/>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconTool}
|
||||
title={t`Maintenance mode`}
|
||||
description={toggleDescription}
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
{isEnabled && (
|
||||
<CardContent>
|
||||
<StyledFormContainer>
|
||||
<SettingsDatePickerInput
|
||||
label={t`Start date`}
|
||||
value={startDate}
|
||||
onChange={handleDateChange('startAt')}
|
||||
/>
|
||||
<div>
|
||||
<SettingsDatePickerInput
|
||||
label={t`End date`}
|
||||
value={endDate}
|
||||
onChange={handleDateChange('endAt')}
|
||||
/>
|
||||
{isDefined(startDate) &&
|
||||
isDefined(endDate) &&
|
||||
!hasValidDateRange && (
|
||||
<InputHint>{t`End date must be after start date.`}</InputHint>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<TextInput
|
||||
label={t`Link`}
|
||||
type="url"
|
||||
value={adminPanelMaintenanceMode.link ?? ''}
|
||||
onChange={handleLinkChange}
|
||||
onBlur={handleLinkBlur}
|
||||
LeftIcon={IconLink}
|
||||
placeholder="https://status.example.com"
|
||||
fullWidth
|
||||
/>
|
||||
<InputHint>
|
||||
{t`If there's no link, no button will appear.`}
|
||||
</InputHint>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<StyledStatusRow>
|
||||
<Status
|
||||
color="orange"
|
||||
text={t`Planned for ${formattedStartDate}`}
|
||||
/>
|
||||
</StyledStatusRow>
|
||||
)}
|
||||
</StyledFormContainer>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/queries/getMaintenanceMode';
|
||||
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { type GetMaintenanceModeQuery } from '~/generated-metadata/graphql';
|
||||
|
||||
export const SettingsAdminMaintenanceModeFetchEffect = () => {
|
||||
const { data } = useQuery<GetMaintenanceModeQuery>(GET_MAINTENANCE_MODE, {
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const setAdminPanelMaintenanceMode = useSetAtomState(
|
||||
adminPanelMaintenanceModeState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const maintenanceMode = data?.getMaintenanceMode;
|
||||
|
||||
if (!isDefined(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminPanelMaintenanceMode(maintenanceMode ?? null);
|
||||
}, [data, setAdminPanelMaintenanceMode]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CLEAR_MAINTENANCE_MODE = gql`
|
||||
mutation ClearMaintenanceMode {
|
||||
clearMaintenanceMode
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SET_MAINTENANCE_MODE = gql`
|
||||
mutation SetMaintenanceMode(
|
||||
$startAt: DateTime!
|
||||
$endAt: DateTime!
|
||||
$link: String
|
||||
) {
|
||||
setMaintenanceMode(startAt: $startAt, endAt: $endAt, link: $link)
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MAINTENANCE_MODE = gql`
|
||||
query GetMaintenanceMode {
|
||||
getMaintenanceMode {
|
||||
startAt
|
||||
endAt
|
||||
link
|
||||
}
|
||||
}
|
||||
`;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
type AdminPanelMaintenanceMode = {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
link?: string | null;
|
||||
};
|
||||
|
||||
export const adminPanelMaintenanceModeState =
|
||||
createAtomState<AdminPanelMaintenanceMode | null>({
|
||||
key: 'adminPanelMaintenanceModeState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
autoUpdate,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
flip,
|
||||
useFloating,
|
||||
} from '@floating-ui/react';
|
||||
|
||||
import {
|
||||
DateTimePicker,
|
||||
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
|
||||
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
|
||||
} from '@/ui/input/components/internal/date/components/DateTimePicker';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCalendar } from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID = 'settings-date-picker-floating';
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.div<{ hasValue: boolean }>`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
color: ${({ hasValue }) =>
|
||||
hasValue
|
||||
? themeCssVariables.font.color.primary
|
||||
: themeCssVariables.font.color.light};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
height: 32px;
|
||||
padding: 0 ${themeCssVariables.spacing[2]};
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: ${themeCssVariables.border.color.strong};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.div`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledFloatingContainer = styled.div`
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
export type SettingsDatePickerInputProps = {
|
||||
label: string;
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const SettingsDatePickerInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: SettingsDatePickerInputProps) => {
|
||||
const { t } = useLingui();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
open: isOpen,
|
||||
placement: 'bottom-start',
|
||||
middleware: [offset(4), flip()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
listenerId: `settings-date-picker-${label}`,
|
||||
callback: handleClose,
|
||||
enabled: isOpen,
|
||||
excludedClickOutsideIds: [
|
||||
SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID,
|
||||
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
|
||||
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
|
||||
],
|
||||
});
|
||||
|
||||
const handleDateTimeSelect = (newDateTime: Temporal.ZonedDateTime | null) => {
|
||||
if (isDefined(newDateTime)) {
|
||||
onChange(new Date(newDateTime.epochMilliseconds));
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(undefined);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const formatDisplayValue = (date: Date | undefined): string => {
|
||||
if (!isDefined(date)) {
|
||||
return placeholder ?? t`Select date & time`;
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const zonedDateTime = isDefined(value)
|
||||
? Temporal.Instant.fromEpochMilliseconds(
|
||||
value.getTime(),
|
||||
).toZonedDateTimeISO(Temporal.Now.timeZoneId())
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledInputContainer ref={containerRef}>
|
||||
<StyledLabel>{label}</StyledLabel>
|
||||
<StyledInput
|
||||
ref={refs.setReference}
|
||||
hasValue={isDefined(value)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<StyledIconContainer>
|
||||
<IconCalendar size={16} />
|
||||
</StyledIconContainer>
|
||||
{formatDisplayValue(value)}
|
||||
</StyledInput>
|
||||
{isOpen && (
|
||||
<FloatingPortal>
|
||||
<StyledFloatingContainer
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
data-click-outside-id={SETTINGS_DATE_PICKER_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<OverlayContainer>
|
||||
<DateTimePicker
|
||||
instanceId={`settings-date-picker-${label}`}
|
||||
date={zonedDateTime}
|
||||
onChange={handleDateTimeSelect}
|
||||
onClose={handleClose}
|
||||
onClear={handleClear}
|
||||
clearable
|
||||
/>
|
||||
</OverlayContainer>
|
||||
</StyledFloatingContainer>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
);
|
||||
};
|
||||
+8
-3
@@ -2,6 +2,7 @@ import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDelete
|
||||
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
||||
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
|
||||
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
|
||||
@@ -13,6 +14,7 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
|
||||
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
@@ -84,9 +86,12 @@ export const SettingsObjectFieldItemTableRow = ({
|
||||
const { fieldMetadataItem, objectMetadataItem } =
|
||||
settingsObjectDetailTableItem;
|
||||
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
|
||||
+8
-3
@@ -3,6 +3,7 @@ import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataIt
|
||||
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
|
||||
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
|
||||
@@ -10,6 +11,7 @@ import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -109,9 +111,12 @@ export const SettingsObjectRelationItemTableRow = ({
|
||||
[fieldMetadataItem, getRelationMetadata],
|
||||
) ?? {};
|
||||
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
|
||||
const { activateMetadataField } = useFieldMetadataItem();
|
||||
const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem();
|
||||
|
||||
+8
-3
@@ -1,5 +1,6 @@
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { computeUpdatedNavigationMemorizedUrlAfterObjectNamePluralChange } from '@/settings/data-model/object-details/utils/computeUpdatedNavigationMemorizedUrlAfterObjectNamePluralChange';
|
||||
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
settingsDataModelObjectAboutFormSchema,
|
||||
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -23,9 +25,12 @@ type SettingsUpdateDataModelObjectAboutFormProps = {
|
||||
export const SettingsUpdateDataModelObjectAboutForm = ({
|
||||
objectMetadataItem,
|
||||
}: SettingsUpdateDataModelObjectAboutFormProps) => {
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
const navigate = useNavigateSettings();
|
||||
const setUpdatedObjectNamePlural = useSetAtomState(
|
||||
updatedObjectNamePluralState,
|
||||
|
||||
+8
-3
@@ -1,8 +1,10 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { SettingsObjectRelationsTable } from '@/settings/data-model/object-details/components/SettingsObjectRelationsTable';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
@@ -25,9 +27,12 @@ type ObjectFieldsProps = {
|
||||
|
||||
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
|
||||
const { t } = useLingui();
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
|
||||
const objectLabelSingular = objectMetadataItem.labelSingular;
|
||||
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
|
||||
|
||||
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
|
||||
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
|
||||
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
|
||||
@@ -11,6 +12,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { H2Title, IconArchive, IconTrash } from 'twenty-ui/display';
|
||||
@@ -56,7 +58,10 @@ export const ObjectSettings = ({
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { openModal, closeModal } = useModal();
|
||||
|
||||
const isReadOnly = isObjectMetadataReadOnly({ objectMetadataItem });
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const isReadOnly =
|
||||
isObjectMetadataReadOnly({ objectMetadataItem }) || isDDLLocked;
|
||||
|
||||
const handleDisable = async () => {
|
||||
const result = await updateOneObjectMetadataItem({
|
||||
|
||||
+8
-3
@@ -2,9 +2,11 @@ import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdat
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
|
||||
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo } from 'react';
|
||||
@@ -43,9 +45,12 @@ const StyledContainer = styled.div`
|
||||
export const SettingsDataModelObjectIdentifiersForm = ({
|
||||
objectMetadataItem,
|
||||
}: SettingsDataModelObjectIdentifiersFormProps) => {
|
||||
const readonly = isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
});
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const readonly =
|
||||
isObjectMetadataReadOnly({
|
||||
objectMetadataItem,
|
||||
}) || isDDLLocked;
|
||||
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema),
|
||||
|
||||
Reference in New Issue
Block a user