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:
+21
-17
@@ -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>
|
||||
|
||||
+3
@@ -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
|
||||
/>
|
||||
|
||||
+63
-36
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+12
-34
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user