diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx index 2078444a24..81768fe6f9 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx @@ -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 ( )} - {onClose && ( - - )} + {onClose && + (isPrimary ? ( + + ) : ( + + ))} )} diff --git a/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx b/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx index 57cbbab1a5..9ff68f0444 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode.tsx @@ -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, }) : ''; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx b/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx index cb3c8b9d2d..fba6ea5e7b 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsDatePickerInput.tsx @@ -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(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 = ({ diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts index 28bbb40522..21e045780f 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.spec.ts @@ -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, }, ); }); diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts index 82a1449f35..afc7f5c780 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts @@ -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, diff --git a/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx b/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx index d4bcbf0f45..e333d2b387 100644 --- a/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx +++ b/packages/twenty-ui/src/display/banner/components/__stories__/Banner.stories.tsx @@ -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' ? ( + + ) : ( + + ); const meta: Meta = { title: 'UI/Layout/Banner/Banner', component: Banner, - decorators: [ComponentDecorator], - render: (args) => ( - // oxlint-disable-next-line react/jsx-props-no-spreading - - Sync lost with mailbox hello@twenty.com. Please reconnect for updates: - - ), - argTypes: {}, + argTypes: { + color: { + control: 'select', + options: ['blue', 'danger'] satisfies BannerColor[], + }, + variant: { + control: 'select', + options: ['primary', 'secondary'] satisfies BannerVariant[], + }, + }, }; export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: { + color: 'blue', + variant: 'primary', + }, + render: (args) => ( + + {/* oxlint-disable-next-line react/jsx-props-no-spreading */} + + + Sync lost with mailbox hello@twenty.com. Please reconnect for updates: +