Skip to content

Commit 5f038a8

Browse files
authored
feat(calendar): replace select with listbox for adding entries (#131)
* feat(calendar): replace select with listbox for adding entries * style: improve code * ci: increase node version for Object.groupBy support
1 parent 31d7251 commit 5f038a8

File tree

4 files changed

+69
-45
lines changed

4 files changed

+69
-45
lines changed

.github/workflows/code-health.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
- main
77

88
env:
9-
NODE_VERSION: 20
9+
NODE_VERSION: 22
1010

1111
jobs:
1212
setup:

src/components/calendar/DayHabitModalDialog.test.tsx src/components/calendar/AddOccurrenceDialog.test.tsx

+24-13
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { fireEvent, render, waitFor } from '@testing-library/react';
44
import { makeTestHabit } from '@tests';
55
import { format } from 'date-fns';
66
import React from 'react';
7+
import { BrowserRouter } from 'react-router-dom';
78

8-
import DayHabitModalDialog from './DayHabitModalDialog';
9+
import AddOccurrenceDialog from './AddOccurrenceDialog';
910

1011
jest.mock('@stores', () => ({
1112
useHabitsStore: jest.fn(),
@@ -20,7 +21,7 @@ jest.mock('date-fns', () => ({
2021
format: jest.fn(),
2122
}));
2223

23-
describe(DayHabitModalDialog.name, () => {
24+
describe(AddOccurrenceDialog.name, () => {
2425
const mockOnClose = jest.fn();
2526
const date = new Date(2021, 1, 1, 12);
2627

@@ -42,7 +43,11 @@ describe(DayHabitModalDialog.name, () => {
4243
addOccurrence: jest.fn(),
4344
addingOccurrence: false,
4445
});
45-
const { getByText } = render(<DayHabitModalDialog {...props} />);
46+
const { getByText } = render(
47+
<BrowserRouter>
48+
<AddOccurrenceDialog {...props} />
49+
</BrowserRouter>
50+
);
4651
expect(getByText('Add habit entries for 2021-01-01')).toBeInTheDocument();
4752
});
4853

@@ -55,7 +60,7 @@ describe(DayHabitModalDialog.name, () => {
5560
addingOccurrence: false,
5661
});
5762
const { container } = render(
58-
<DayHabitModalDialog {...props} date={null} />
63+
<AddOccurrenceDialog {...props} date={null} />
5964
);
6065
expect(container.firstChild).toBeNull();
6166
});
@@ -69,7 +74,7 @@ describe(DayHabitModalDialog.name, () => {
6974
addingOccurrence: false,
7075
});
7176
const { container } = render(
72-
<DayHabitModalDialog {...props} open={false} />
77+
<AddOccurrenceDialog {...props} open={false} />
7378
);
7479
expect(container.firstChild).toBeNull();
7580
});
@@ -83,7 +88,7 @@ describe(DayHabitModalDialog.name, () => {
8388
addingOccurrence: false,
8489
});
8590
const { container } = render(
86-
<DayHabitModalDialog {...props} date={null} />
91+
<AddOccurrenceDialog {...props} date={null} />
8792
);
8893
expect(container.firstChild).toBeNull();
8994
});
@@ -96,8 +101,14 @@ describe(DayHabitModalDialog.name, () => {
96101
addOccurrence: jest.fn(),
97102
addingOccurrence: false,
98103
});
99-
const { getAllByText } = render(<DayHabitModalDialog {...props} />);
100-
expect(getAllByText('No habits yet')).toHaveLength(2);
104+
const { getByText } = render(
105+
<BrowserRouter>
106+
<AddOccurrenceDialog {...props} />
107+
</BrowserRouter>
108+
);
109+
expect(
110+
getByText('No habits yet. Create a habit to get started.')
111+
).toBeInTheDocument();
101112
});
102113

103114
it('should render habit options', () => {
@@ -110,7 +121,7 @@ describe(DayHabitModalDialog.name, () => {
110121
addOccurrence: jest.fn(),
111122
addingOccurrence: false,
112123
});
113-
const { getByText } = render(<DayHabitModalDialog {...props} />);
124+
const { getByText } = render(<AddOccurrenceDialog {...props} />);
114125
expect(getByText('Test Habit')).toBeInTheDocument();
115126
});
116127

@@ -125,7 +136,7 @@ describe(DayHabitModalDialog.name, () => {
125136
addingOccurrence: false,
126137
});
127138
const { container, getAllByText, getByTestId } = render(
128-
<DayHabitModalDialog {...props} />
139+
<AddOccurrenceDialog {...props} />
129140
);
130141
fireEvent.click(getByTestId('habit-select'));
131142
fireEvent.click(getAllByText('Test Habit')[1]);
@@ -145,7 +156,7 @@ describe(DayHabitModalDialog.name, () => {
145156
addOccurrence: jest.fn(),
146157
addingOccurrence: false,
147158
});
148-
const { getByRole } = render(<DayHabitModalDialog {...props} />);
159+
const { getByRole } = render(<AddOccurrenceDialog {...props} />);
149160
fireEvent.click(getByRole('button', { name: 'Close' }));
150161
expect(mockOnClose).toHaveBeenCalledTimes(1);
151162
});
@@ -160,7 +171,7 @@ describe(DayHabitModalDialog.name, () => {
160171
addOccurrence: jest.fn(),
161172
addingOccurrence: false,
162173
});
163-
const { getByRole, getByText } = render(<DayHabitModalDialog {...props} />);
174+
const { getByRole, getByText } = render(<AddOccurrenceDialog {...props} />);
164175
fireEvent.click(getByRole('habit-select'));
165176
fireEvent.click(getByText('Test Habit'));
166177
expect(
@@ -183,7 +194,7 @@ describe(DayHabitModalDialog.name, () => {
183194
addOccurrence: mockAddOccurrence,
184195
addingOccurrence: false,
185196
});
186-
const { getByRole, getByText } = render(<DayHabitModalDialog {...props} />);
197+
const { getByRole, getByText } = render(<AddOccurrenceDialog {...props} />);
187198
fireEvent.click(getByRole('habit-select'));
188199
fireEvent.click(getByText('Test Habit'));
189200
expect(

src/components/calendar/DayHabitModalDialog.tsx src/components/calendar/AddOccurrenceDialog.tsx

+42-29
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,46 @@ import {
55
ModalHeader,
66
ModalBody,
77
ModalFooter,
8-
Select,
9-
SelectItem,
8+
Listbox,
9+
ListboxSection,
10+
ListboxItem,
1011
} from '@nextui-org/react';
1112
import { useHabitsStore, useOccurrencesStore } from '@stores';
1213
import { useUser } from '@supabase/auth-helpers-react';
1314
import { format } from 'date-fns';
1415
import React, { type MouseEventHandler } from 'react';
16+
import { Link } from 'react-router-dom';
1517

16-
type DayHabitModalDialogProps = {
18+
type AddOccurrenceDialogProps = {
1719
open: boolean;
1820
onClose: () => void;
1921
date: Date | null;
2022
};
2123

22-
const DayHabitModalDialog = ({
24+
const AddOccurrenceDialog = ({
2325
open,
2426
onClose,
2527
date,
26-
}: DayHabitModalDialogProps) => {
28+
}: AddOccurrenceDialogProps) => {
2729
const { habits } = useHabitsStore();
2830
const user = useUser();
2931
const { addOccurrence, addingOccurrence } = useOccurrencesStore();
3032
const [selectedHabitIds, setSelectedHabitIds] = React.useState<string[]>([]);
3133

34+
const habitsByTraitName = React.useMemo(() => {
35+
return Object.groupBy(habits, (habit) => habit.trait?.name || 'Unknown');
36+
}, [habits]);
37+
3238
if (!date || !open) {
3339
return null;
3440
}
3541

42+
const hasHabits = habits.length > 0;
43+
3644
const handleSubmit: MouseEventHandler<HTMLButtonElement> = async (event) => {
3745
event.preventDefault();
3846

39-
if (!user) {
47+
if (!user || !hasHabits) {
4048
return null;
4149
}
4250

@@ -68,8 +76,6 @@ const DayHabitModalDialog = ({
6876
}
6977
};
7078

71-
const hasHabits = habits.length > 0;
72-
7379
return (
7480
<Modal
7581
role="add-occurrence-modal"
@@ -82,43 +88,50 @@ const DayHabitModalDialog = ({
8288
Add habit entries for {format(date, 'iii, LLL d, y')}
8389
</ModalHeader>
8490
<ModalBody>
85-
<Select
91+
<Listbox
92+
variant="flat"
93+
color="primary"
8694
selectionMode="multiple"
87-
label={hasHabits ? 'Habits' : 'No habits yet'}
8895
selectedKeys={selectedHabitIds}
89-
description="Select from your habits"
90-
data-testid="habit-select"
96+
disabledKeys={['none']}
97+
emptyContent="No habits yet. Create a habit to get started."
98+
className="max-h-80 overflow-auto rounded border border-neutral-200 p-2 dark:border-neutral-800"
9199
>
92-
{habits.map((habit) => (
93-
<SelectItem
94-
key={habit.id.toString()}
95-
onClick={() => handleHabitSelect(habit.id.toString())}
96-
textValue={habit.name}
97-
>
98-
<span>{habit.name}</span>
99-
{habit.trait && (
100-
<span className="font-regular ml-2 text-neutral-400">
101-
{habit.trait.name}
102-
</span>
100+
{Object.keys(habitsByTraitName).map((traitName) => (
101+
<ListboxSection key={traitName} showDivider title={traitName}>
102+
{habitsByTraitName[traitName] ? (
103+
habitsByTraitName[traitName].map((habit) => (
104+
<ListboxItem
105+
key={habit.id.toString()}
106+
onClick={() => handleHabitSelect(habit.id.toString())}
107+
>
108+
{habit.name}
109+
</ListboxItem>
110+
))
111+
) : (
112+
<ListboxItem key="none">No habits</ListboxItem>
103113
)}
104-
</SelectItem>
114+
</ListboxSection>
105115
))}
106-
</Select>
116+
</Listbox>
107117
</ModalBody>
108118
<ModalFooter>
109119
<Button
120+
as={hasHabits ? Button : Link}
110121
type="submit"
111122
color="primary"
112123
isLoading={addingOccurrence}
113-
isDisabled={!hasHabits}
114-
onClick={handleSubmit}
124+
onClick={hasHabits ? handleSubmit : undefined}
125+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
126+
// @ts-ignore
127+
to={hasHabits ? undefined : '/habits'}
115128
>
116-
Submit
129+
{hasHabits ? 'Add' : 'Go to Habits'}
117130
</Button>
118131
</ModalFooter>
119132
</ModalContent>
120133
</Modal>
121134
);
122135
};
123136

124-
export default DayHabitModalDialog;
137+
export default AddOccurrenceDialog;

src/components/calendar/CalendarGrid.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import React from 'react';
22
import { useCalendarGrid } from 'react-aria';
33
import { type CalendarState } from 'react-stately';
44

5+
import AddOccurrenceDialog from './AddOccurrenceDialog';
56
import CalendarMonthGrid from './CalendarMonthGrid';
6-
import DayHabitModalDialog from './DayHabitModalDialog';
77

88
type CalendarGridProps = {
99
state: CalendarState;
@@ -50,7 +50,7 @@ const CalendarGrid = ({ state }: CalendarGridProps) => {
5050
state={state}
5151
/>
5252

53-
<DayHabitModalDialog
53+
<AddOccurrenceDialog
5454
open={dayModalDialogOpen}
5555
onClose={handleDayModalDialogClose}
5656
date={activeDate}

0 commit comments

Comments
 (0)