Fix maintenance mode banner button color, timezone and date picker UX (#19255)

## Summary
- Set `accent="blue"` on InformationBanner action button so it renders
blue instead of default gray
- Add Banner storybook stories for all color × variant combinations
(BluePrimary, BlueSecondary, DangerPrimary, DangerSecondary)
- Use `useUserTimezone()` in `SettingsDatePickerInput` instead of
browser timezone (`Temporal.Now.timeZoneId()`) so dates respect the
admin's profile timezone preference
- Separate `onChange` from `onClose` in `SettingsDatePickerInput` so
changing the hour no longer forces the date picker to close
This commit is contained in:
Charles Bochet
2026-04-02 14:58:03 +02:00
committed by GitHub
parent 8581c11d56
commit 45c1c60acc
6 changed files with 244 additions and 102 deletions
@@ -22,10 +22,6 @@ 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;
@@ -62,10 +58,7 @@ export const InformationBanner = ({
);
const isPrimary = variant === 'primary';
const CloseIconButton = isPrimary
? StyledInvertedIconButton
: StyledSecondaryIconButton;
const buttonAccent = color === 'danger' ? 'danger' : 'blue';
return (
<InformationBannerComponentInstanceContext.Provider
@@ -80,6 +73,7 @@ export const InformationBanner = ({
{buttonTitle && buttonOnClick && (
<Button
variant="secondary"
accent={buttonAccent}
title={buttonTitle}
Icon={buttonIcon}
size="small"
@@ -89,15 +83,25 @@ export const InformationBanner = ({
/>
)}
</StyledContent>
{onClose && (
<CloseIconButton
Icon={IconX}
size="small"
variant="tertiary"
onClick={onClose}
ariaLabel={t`Close banner`}
/>
)}
{onClose &&
(isPrimary ? (
<StyledInvertedIconButton
Icon={IconX}
size="small"
variant="tertiary"
onClick={onClose}
ariaLabel={t`Close banner`}
/>
) : (
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
accent={buttonAccent}
onClick={onClose}
ariaLabel={t`Close banner`}
/>
))}
</Banner>
)}
</InformationBannerComponentInstanceContext.Provider>
@@ -17,6 +17,7 @@ import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsO
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 { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
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';
@@ -39,6 +40,7 @@ export const SettingsAdminMaintenanceMode = () => {
const maintenanceMode = useAtomStateValue(maintenanceModeState);
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
const { userTimezone } = useUserTimezone();
const { enqueueErrorSnackBar } = useSnackBar();
const [setMaintenanceModeMutation] = useMutation(SET_MAINTENANCE_MODE);
@@ -171,6 +173,7 @@ export const SettingsAdminMaintenanceMode = () => {
month: '2-digit',
day: '2-digit',
year: 'numeric',
timeZone: userTimezone,
})
: '';
@@ -15,6 +15,7 @@ import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
@@ -81,6 +82,7 @@ export const SettingsDatePickerInput = ({
const { t } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { userTimezone } = useUserTimezone();
const { refs, floatingStyles } = useFloating({
open: isOpen,
@@ -105,10 +107,13 @@ export const SettingsDatePickerInput = ({
],
});
const handleDateTimeSelect = (newDateTime: Temporal.ZonedDateTime | null) => {
const handleDateTimeChange = (newDateTime: Temporal.ZonedDateTime | null) => {
if (isDefined(newDateTime)) {
onChange(new Date(newDateTime.epochMilliseconds));
}
};
const handleDateTimeClose = (_newDateTime: Temporal.ZonedDateTime | null) => {
handleClose();
};
@@ -128,13 +133,14 @@ export const SettingsDatePickerInput = ({
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: userTimezone,
});
};
const zonedDateTime = isDefined(value)
? Temporal.Instant.fromEpochMilliseconds(
value.getTime(),
).toZonedDateTimeISO(Temporal.Now.timeZoneId())
).toZonedDateTimeISO(userTimezone)
: null;
return (
@@ -161,8 +167,8 @@ export const SettingsDatePickerInput = ({
<DateTimePicker
instanceId={`settings-date-picker-${label}`}
date={zonedDateTime}
onChange={handleDateTimeSelect}
onClose={handleClose}
onChange={handleDateTimeChange}
onClose={handleDateTimeClose}
onClear={handleClear}
clearable
/>
@@ -22,7 +22,7 @@ describe('KeyValuePairService', () => {
service = new KeyValuePairService(keyValuePairRepository);
});
it('should insert a global null/null key when missing', async () => {
it('should upsert a global null/null key', async () => {
await service.set({
userId: null,
workspaceId: null,
@@ -31,45 +31,24 @@ describe('KeyValuePairService', () => {
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(keyValuePairRepository.findOne).toHaveBeenCalledWith({
where: {
userId: expect.any(Object),
workspaceId: expect.any(Object),
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
{
userId: null,
workspaceId: null,
key: 'MAINTENANCE_MODE',
value: { startAt: '2026-04-02T10:00:00.000Z' },
type: KeyValuePairType.CONFIG_VARIABLE,
},
});
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
userId: null,
workspaceId: null,
key: 'MAINTENANCE_MODE',
value: { startAt: '2026-04-02T10:00:00.000Z' },
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
});
it('should update a global null/null key when present', async () => {
keyValuePairRepository.findOne.mockResolvedValue({
id: 'existing-id',
} as KeyValuePairEntity);
await service.set({
userId: null,
workspaceId: null,
key: 'MAINTENANCE_MODE',
value: { startAt: '2026-04-02T10:00:00.000Z' },
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(keyValuePairRepository.update).toHaveBeenCalledWith('existing-id', {
value: { startAt: '2026-04-02T10:00:00.000Z' },
});
{
conflictPaths: ['key'],
indexPredicate: '"userId" IS NULL AND "workspaceId" IS NULL',
},
);
expect(keyValuePairRepository.findOne).not.toHaveBeenCalled();
expect(keyValuePairRepository.insert).not.toHaveBeenCalled();
expect(keyValuePairRepository.upsert).not.toHaveBeenCalled();
});
it('should keep the existing workspace-null index behavior', async () => {
it('should upsert with userId-null index when workspaceId is null', async () => {
await service.set({
userId: 'user-id',
workspaceId: null,
@@ -87,8 +66,56 @@ describe('KeyValuePairService', () => {
type: KeyValuePairType.USER_VARIABLE,
},
{
conflictPaths: ['userId', 'workspaceId', 'key'],
indexPredicate: '"workspaceId" is NULL',
conflictPaths: ['key', 'userId'],
indexPredicate: '"workspaceId" IS NULL',
},
);
});
it('should upsert with workspaceId-null index when userId is null', async () => {
await service.set({
userId: null,
workspaceId: 'workspace-id',
key: 'WORKSPACE_SETTING',
value: 'test',
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
{
userId: null,
workspaceId: 'workspace-id',
key: 'WORKSPACE_SETTING',
value: 'test',
type: KeyValuePairType.CONFIG_VARIABLE,
},
{
conflictPaths: ['key', 'workspaceId'],
indexPredicate: '"userId" IS NULL',
},
);
});
it('should upsert with full conflict paths when both ids are present', async () => {
await service.set({
userId: 'user-id',
workspaceId: 'workspace-id',
key: 'USER_WORKSPACE_SETTING',
value: 42,
type: KeyValuePairType.USER_VARIABLE,
});
expect(keyValuePairRepository.upsert).toHaveBeenCalledWith(
{
userId: 'user-id',
workspaceId: 'workspace-id',
key: 'USER_WORKSPACE_SETTING',
value: 42,
type: KeyValuePairType.USER_VARIABLE,
},
{
conflictPaths: ['key', 'userId', 'workspaceId'],
indexPredicate: undefined,
},
);
});
@@ -82,43 +82,21 @@ export class KeyValuePairService<
type,
};
const conflictPaths: string[] = ['key'];
let indexPredicate: string | undefined;
if (hasNullUserAndWorkspace) {
const existingKeyValuePair = await keyValuePairRepository.findOne({
where: {
userId: IsNull(),
workspaceId: IsNull(),
key,
type,
},
});
if (existingKeyValuePair) {
await keyValuePairRepository.update(existingKeyValuePair.id, {
value,
});
return;
}
await keyValuePairRepository.insert(upsertData);
return;
indexPredicate = '"userId" IS NULL AND "workspaceId" IS NULL';
} else if (normalizedUserId === null) {
conflictPaths.push('workspaceId');
indexPredicate = '"userId" IS NULL';
} else if (normalizedWorkspaceId === null) {
conflictPaths.push('userId');
indexPredicate = '"workspaceId" IS NULL';
} else {
conflictPaths.push('userId', 'workspaceId');
}
const conflictPaths = Object.keys(upsertData).filter(
(conflictPath) =>
['userId', 'workspaceId', 'key'].includes(conflictPath) &&
// @ts-expect-error legacy noImplicitAny
upsertData[conflictPath] !== undefined,
);
const indexPredicate =
normalizedUserId === null
? '"userId" is NULL'
: normalizedWorkspaceId === null
? '"workspaceId" is NULL'
: undefined;
await keyValuePairRepository.upsert(upsertData, {
conflictPaths,
indexPredicate,
@@ -1,22 +1,146 @@
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { ComponentDecorator } from '@ui/testing';
import { Banner } from '../Banner';
import { IconX } from '@ui/display';
import {
CatalogDecorator,
type CatalogStory,
ComponentDecorator,
} from '@ui/testing';
import { themeCssVariables } from '@ui/theme-constants';
import { Button } from '../../../../input/button/components/Button/Button';
import { IconButton } from '../../../../input/button/components/IconButton';
import { Banner, type BannerColor, type BannerVariant } from '../Banner';
const StyledBannerContent = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: center;
`;
const StyledContainer = styled.div`
width: 100%;
`;
const StyledInvertedIconButton = styled(IconButton)`
color: ${themeCssVariables.font.color.inverted} !important;
`;
const getButtonAccent = (color?: BannerColor) =>
color === 'danger' ? 'danger' : 'blue';
const BannerCloseButton = ({
color,
variant,
}: {
color?: BannerColor;
variant?: BannerVariant;
}) =>
variant === 'primary' ? (
<StyledInvertedIconButton
Icon={IconX}
size="small"
variant="tertiary"
ariaLabel="Close"
/>
) : (
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
accent={getButtonAccent(color)}
ariaLabel="Close"
/>
);
const meta: Meta<typeof Banner> = {
title: 'UI/Layout/Banner/Banner',
component: Banner,
decorators: [ComponentDecorator],
render: (args) => (
// oxlint-disable-next-line react/jsx-props-no-spreading
<Banner {...args}>
Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
</Banner>
),
argTypes: {},
argTypes: {
color: {
control: 'select',
options: ['blue', 'danger'] satisfies BannerColor[],
},
variant: {
control: 'select',
options: ['primary', 'secondary'] satisfies BannerVariant[],
},
},
};
export default meta;
type Story = StoryObj<typeof Banner>;
export const Default: Story = {};
export const Default: Story = {
args: {
color: 'blue',
variant: 'primary',
},
render: (args) => (
<StyledContainer>
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<Banner {...args}>
<StyledBannerContent>
Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
<Button
variant="secondary"
accent={getButtonAccent(args.color)}
title="Reconnect"
size="small"
inverted={args.variant === 'primary'}
/>
</StyledBannerContent>
<BannerCloseButton color={args.color} variant={args.variant} />
</Banner>
</StyledContainer>
),
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Banner> = {
args: {},
argTypes: {
color: { control: false },
variant: { control: false },
},
render: (args) => (
// oxlint-disable-next-line react/jsx-props-no-spreading
<Banner {...args}>
<StyledBannerContent>
Sync lost with mailbox hello@twenty.com. Please reconnect for updates:
<Button
variant="secondary"
accent={getButtonAccent(args.color)}
title="Reconnect"
size="small"
inverted={args.variant === 'primary'}
/>
</StyledBannerContent>
<BannerCloseButton color={args.color} variant={args.variant} />
</Banner>
),
parameters: {
catalog: {
dimensions: [
{
name: 'variant',
values: ['primary', 'secondary'] satisfies BannerVariant[],
props: (variant: BannerVariant) => ({ variant }),
},
{
name: 'color',
values: ['blue', 'danger'] satisfies BannerColor[],
props: (color: BannerColor) => ({ color }),
},
],
options: {
elementContainer: {
width: 700,
},
},
},
},
decorators: [CatalogDecorator],
};