81f10c586f
## 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.
176 lines
4.8 KiB
TypeScript
176 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
};
|