Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@tiptap/starter-kit": "3.14.0",
"@tiptap/suggestion": "3.14.0",
"@types/d3": "^7.4.3",
"@types/sanitize-html": "^2.16.0",
"@uidotdev/usehooks": "^2.4.1",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
Expand Down Expand Up @@ -105,6 +106,7 @@
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"requireindex": "^1.2.0",
"sanitize-html": "^2.17.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tippy.js": "^6.3.7",
Expand Down Expand Up @@ -165,7 +167,6 @@
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"react-fast-compare": "^3.2.2",
"sanitize-html": "^2.17.0",
"storybook": "^10.1.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.3",
Expand Down
115 changes: 115 additions & 0 deletions client/src/components/DateTimePicker/DateTimePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import '@testing-library/jest-dom';
import {fireEvent, render, screen, userEvent} from '@/shared/util/test-utils';
import {format} from 'date-fns';
import {describe, expect, it, vi} from 'vitest';

import DateTimePicker from './DateTimePicker';

describe('DateTimePicker', () => {
it('should render the trigger button with placeholder when no value is provided', () => {
render(<DateTimePicker onChange={vi.fn()} />);

expect(screen.getByText('Pick a date and time')).toBeInTheDocument();
});

it('should render the trigger button with formatted date when value is provided', () => {
const date = new Date(2023, 11, 25, 10, 30);

render(<DateTimePicker onChange={vi.fn()} value={date} />);

expect(screen.getByText(format(date, 'PPP HH:mm'))).toBeInTheDocument();
});

it('should open popover and allow date selection', async () => {
const onChange = vi.fn();
const initialDate = new Date(2023, 11, 25, 10, 30);

render(<DateTimePicker onChange={onChange} value={initialDate} />);

const trigger = screen.getByRole('button');

await userEvent.click(trigger);

// Calendar should be visible.
// We look for a day, e.g., 26th.
const day26 = screen.getByText('26');

await userEvent.click(day26);

expect(onChange).toHaveBeenCalled();

const calledDate = onChange.mock.calls[0][0];

expect(calledDate.getDate()).toBe(26);
// Time should be preserved
expect(calledDate.getHours()).toBe(10);
expect(calledDate.getMinutes()).toBe(30);
});

it('should allow time change', async () => {
const onChange = vi.fn();
const initialDate = new Date(2023, 11, 25, 10, 30);

render(<DateTimePicker onChange={onChange} value={initialDate} />);

await userEvent.click(screen.getByRole('button'));

const timeInput = document.querySelector('input[type="time"]') as HTMLInputElement;

fireEvent.change(timeInput, {target: {value: '15:45'}});

expect(onChange).toHaveBeenCalled();

const calledDate = onChange.mock.calls[0][0];

expect(calledDate.getHours()).toBe(15);
expect(calledDate.getMinutes()).toBe(45);
// Date should be preserved
expect(calledDate.getDate()).toBe(25);
});

it('should ignore invalid time inputs', async () => {
const onChange = vi.fn();
const initialDate = new Date(2023, 11, 25, 10, 30);

render(<DateTimePicker onChange={onChange} value={initialDate} />);

await userEvent.click(screen.getByRole('button'));

const timeInput = document.querySelector('input[type="time"]') as HTMLInputElement;

// Invalid hours
fireEvent.change(timeInput, {target: {value: '25:00'}});

expect(onChange).not.toHaveBeenCalled();

// Invalid minutes
fireEvent.change(timeInput, {target: {value: '10:60'}});

expect(onChange).not.toHaveBeenCalled();

// Non-numeric
fireEvent.change(timeInput, {target: {value: 'ab:cd'}});

expect(onChange).not.toHaveBeenCalled();
});

it('should handle time change when no initial date is selected', async () => {
const onChange = vi.fn();

render(<DateTimePicker onChange={onChange} />);

await userEvent.click(screen.getByRole('button'));

const timeInput = document.querySelector('input[type="time"]') as HTMLInputElement;

fireEvent.change(timeInput, {target: {value: '12:00'}});

expect(onChange).toHaveBeenCalled();

const calledDate = onChange.mock.calls[0][0];

expect(calledDate.getHours()).toBe(12);
expect(calledDate.getMinutes()).toBe(0);
});
});
73 changes: 73 additions & 0 deletions client/src/components/DateTimePicker/DateTimePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';

import {Button} from '@/components/ui/button';
import {Calendar} from '@/components/ui/calendar';
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
import {cn} from '@/shared/util/cn-utils';
import {format} from 'date-fns';
import {CalendarIcon} from 'lucide-react';
import {ChangeEvent, useEffect, useState} from 'react';

export default function DateTimePicker({onChange, value}: {onChange: (date: Date | undefined) => void; value?: Date}) {
const [date, setDate] = useState<Date | undefined>(value);

const handleSelect = (selectedDate: Date | undefined) => {
if (selectedDate && date && !isNaN(date.getTime())) {
selectedDate.setHours(date.getHours());
selectedDate.setMinutes(date.getMinutes());
}

setDate(selectedDate);

onChange(selectedDate);
};

const handleTimeChange = (e: ChangeEvent<HTMLInputElement>) => {
const [hours, minutes] = e.target.value.split(':').map(Number);

if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return;
}

const newDate = date ? new Date(date) : new Date();

newDate.setHours(hours);
newDate.setMinutes(minutes);

setDate(newDate);

onChange(newDate);
};

useEffect(() => {
setDate(value);
}, [value]);

return (
<Popover>
<PopoverTrigger asChild>
<Button
className={cn('w-full justify-start text-left font-normal', !date && 'text-muted-foreground')}
variant="outline"
>
<CalendarIcon className="mr-2 size-4" />

{date ? format(date, 'PPP HH:mm') : <span>Pick a date and time</span>}
</Button>
</PopoverTrigger>

<PopoverContent align="start" className="w-auto p-0">
<Calendar autoFocus mode="single" onSelect={handleSelect} selected={date} />

<div className="border-t p-3">
<input
className="w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onChange={handleTimeChange}
type="time"
value={date ? format(date, 'HH:mm') : ''}
/>
</div>
</PopoverContent>
</Popover>
);
}
12 changes: 10 additions & 2 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ if (process.env.NODE_ENV === 'mock') {
renderApp();
}

const publicRoutes = ['/activate', '/register', '/password-reset', '/password-reset/finish', '/verify-email'];
const publicRoutes = [
'/activate',
'/chat',
'/form',
'/register',
'/password-reset',
'/password-reset/finish',
'/verify-email',
];

async function renderApp() {
const container = document.getElementById('root') as HTMLDivElement;
Expand All @@ -67,7 +75,7 @@ async function renderApp() {

if (
!isEmbeddedWorkflowBuilder &&
!publicRoutes.includes(window.location.pathname) &&
!publicRoutes.find((publicRoute) => window.location.pathname.startsWith(publicRoute)) &&
!authenticationStore.getState().sessionHasBeenFetched
) {
const result = await authenticationStore.getState().getAccount();
Expand Down
Loading
Loading