Skip to content

Commit

Permalink
Merge pull request #43 from bartkessels/feature/notes
Browse files Browse the repository at this point in the history
Feature/notes
  • Loading branch information
bartkessels authored Dec 21, 2024
2 parents 92f0194 + 2ed35b6 commit aeb1af3
Show file tree
Hide file tree
Showing 39 changed files with 864 additions and 116 deletions.
21 changes: 8 additions & 13 deletions src/components/calendar.component.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {Event} from 'src/domain/events/event';
import {Month} from 'src/domain/models/month';
import {Year} from 'src/domain/models/year';
import {Week} from 'src/domain/models/week';
import 'src/extensions/extensions';
import { CalendarViewModel } from './viewmodels/calendar.view-model';
import {useCalendarViewModel} from 'src/components/viewmodels/calendar.view-model.provider';
import {createCalendarUiModel} from 'src/components/models/calendar.ui-model';
import 'src/extensions/extensions';

jest.mock('src/components/viewmodels/calendar.view-model.provider');

Expand Down Expand Up @@ -60,48 +60,48 @@ describe('CalendarComponent', () => {
});

it('displays the current month and year', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

expect(screen.getByText('December')).toBeInTheDocument();
expect(screen.getByText('2024')).toBeInTheDocument();
});

it('calls navigateToPreviousMonth when the previous month button is clicked', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

fireEvent.click(document.querySelector('.lucide-chevron-left')!);
expect(mockViewModel.navigateToPreviousMonth).toHaveBeenCalled();
});

it('calls navigateToCurrentMonth when the current month button is clicked', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

fireEvent.click(document.querySelector('.lucide-calendar-heart')!);
expect(mockViewModel.navigateToCurrentMonth).toHaveBeenCalled();
});

it('calls navigateToNextMonth when the next month button is clicked', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

fireEvent.click(document.querySelector('.lucide-chevron-right')!);
expect(mockViewModel.navigateToNextMonth).toHaveBeenCalled();
});

it('displays the quarter of the current month', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

expect(screen.getByText('Q4')).toBeInTheDocument();
});

it('emits the quarterly note event when the quarter is clicked', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

fireEvent.click(screen.getByText('Q4'));
expect(mockQuarterlyNoteEvent.emitEvent).toHaveBeenCalledWith(mockViewModel.viewState.uiModel?.currentMonth?.month);
});

it('displays all days and week numbers of the current month grouped into weeks', () => {
render(setupContent(mockViewModel, mockQuarterlyNoteEvent));
render(setupContent(mockQuarterlyNoteEvent));

expect(screen.getByText('40')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
Expand All @@ -114,13 +114,8 @@ describe('CalendarComponent', () => {
});

function setupContent(
viewModel: CalendarViewModel,
quarterlyNoteEvent: Event<Month>
): React.ReactElement {
jest.mock('src/components/viewmodels/calendar.view-model.provider', () => ({
useCalendarViewModel: jest.fn(() => viewModel)
}));

return (
<QuarterlyNoteEventContext.Provider value={quarterlyNoteEvent}>
<CalendarComponent />
Expand Down
44 changes: 44 additions & 0 deletions src/components/models/note.ui-model.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createNoteUiModel, NoteUiModel } from 'src/components/models/note.ui-model';
import { Note } from 'src/domain/models/note';
import 'src/extensions/extensions';

describe('createNoteUiModel', () => {
it('should create a NoteUiModel with the correct properties', () => {
const note: Note = {
path: 'path/to/note.md',
name: 'note.md',
createdOn: new Date('2023-10-01T00:00:00Z')
};

const noteUiModel: NoteUiModel = createNoteUiModel(note);

expect(noteUiModel.note).toBe(note);
expect(noteUiModel.displayDate).toBe(note.createdOn.toLocaleTimeString());
expect(noteUiModel.displayName).toBe('note');
expect(noteUiModel.displayFilePath).toBe(note.path);
});

it('should remove the markdown extension from the displayName', () => {
const note: Note = {
path: 'path/to/note.md',
name: 'note.md',
createdOn: new Date('2023-10-01T00:00:00Z')
};

const noteUiModel: NoteUiModel = createNoteUiModel(note);

expect(noteUiModel.displayName).toBe('note');
});

it('should format the displayDate correctly', () => {
const note: Note = {
path: 'path/to/note.md',
name: 'note.md',
createdOn: new Date(2023, 9, 2, 12, 23)
};

const noteUiModel: NoteUiModel = createNoteUiModel(note);

expect(noteUiModel.displayDate).toBe('12:23:00 PM');
});
});
17 changes: 17 additions & 0 deletions src/components/models/note.ui-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Note} from 'src/domain/models/note';

export interface NoteUiModel {
note: Note;
displayDate: string;
displayName: string;
displayFilePath: string;
}

export function createNoteUiModel(note: Note): NoteUiModel {
return <NoteUiModel>{
note: note,
displayDate: note.createdOn.toLocaleTimeString(),
displayName: note.name.removeMarkdownExtension(),
displayFilePath: note.path
};
}
140 changes: 117 additions & 23 deletions src/components/notes.component.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
import React from 'react';
import {render, waitFor, screen} from '@testing-library/react';
import { NotesComponent } from './notes.component';
import { Note } from 'src/domain/models/note';
import { Event } from 'src/domain/events/event';
import React, {act} from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import {NotesComponent} from './notes.component';
import {Note} from 'src/domain/models/note';
import {Event} from 'src/domain/events/event';
import {RefreshNotesEvent} from 'src/implementation/events/refresh-notes.event';
import {RefreshNotesEventContext} from 'src/components/providers/refresh-notes-event.context';
import {NotesViewModel} from 'src/components/viewmodels/notes.view-model';
import {NoteEventContext} from 'src/components/providers/note-event.context';
import 'src/extensions/extensions';
import {createNoteUiModel} from 'src/components/models/note.ui-model';
import {NotesViewState} from 'src/components/viewmodels/notes.view-state';
import {useNotesViewModel} from 'src/components/viewmodels/notes.view-model.provider';

jest.mock('src/components/viewmodels/notes.view-model.provider');

describe('NotesComponent', () => {
const mockNoteEvent = {
onEvent: jest.fn(),
emitEvent: jest.fn()
} as unknown as Event<Note>;
let refreshNotesEvent: Event<Note[]>;

beforeEach(() => {
refreshNotesEvent = new RefreshNotesEvent();
});

it('should render notes when a refresh notes event is emitted', async () => {
it('should render notes when the view state has uiModels', async () => {
const notes: Note[] = [
{ name: 'Note 1', createdOn: new Date(2023, 9, 2, 10), path: 'path/to/note1' },
{ name: 'Note 2', createdOn: new Date(2023, 9, 2, 11), path: 'path/to/note2' },
{name: 'Note 1', createdOn: new Date(2023, 9, 2, 10, 10), path: 'path/to/note1'},
{name: 'Note 2', createdOn: new Date(2023, 9, 2, 11, 59), path: 'path/to/note2'},
];
const viewModel: NotesViewModel = {
viewState: {
notes: notes.map(note => createNoteUiModel(note))
} as NotesViewState,
refreshNotes: jest.fn()
};

render(
<RefreshNotesEventContext.Provider value={refreshNotesEvent}>
<NotesComponent />
</RefreshNotesEventContext.Provider>
);

React.act(() => refreshNotesEvent.emitEvent(notes));
(useNotesViewModel as jest.Mock).mockReturnValue(viewModel);
render(setupContent(mockNoteEvent, refreshNotesEvent));

await waitFor(() => expect(screen.findAllByRole('listitem')).not.toBeNull());
for (const note of notes) {
Expand All @@ -35,15 +48,96 @@ describe('NotesComponent', () => {
}
});

it('should render nothing when the refresh notes event emits an empty list', async () => {
render(
<RefreshNotesEventContext.Provider value={refreshNotesEvent}>
<NotesComponent />
</RefreshNotesEventContext.Provider>
);
it('should render nothing when the view state has no uiModels', async () => {
const viewModel: NotesViewModel = {
viewState: {
notes: []
} as NotesViewState,
refreshNotes: jest.fn()
};

React.act(() => refreshNotesEvent.emitEvent([]));
(useNotesViewModel as jest.Mock).mockReturnValue(viewModel);
render(setupContent(mockNoteEvent, refreshNotesEvent));

await waitFor(() => expect(screen.queryByRole('listitem')).toBeNull());
});
});

it('should emit the note event when the name of a note is clicked', async () => {
const note: Note = {
name: 'Note 1',
createdOn: new Date(2023, 9, 2, 10, 10),
path: 'path/to/note1'
};
const viewModel: NotesViewModel = {
viewState: {
notes: [createNoteUiModel(note)]
} as NotesViewState,
refreshNotes: jest.fn()
};

(useNotesViewModel as jest.Mock).mockReturnValue(viewModel);
render(setupContent(mockNoteEvent, refreshNotesEvent));

await waitFor(() => expect(screen.findAllByRole('listitem')).not.toBeNull());
act(() => screen.getByText(note.name).click());

expect(mockNoteEvent.emitEvent).toBeCalledWith(note);
});

it('should emit the note event when the created time of a note is clicked', async () => {
const note: Note = {
name: 'Note 1',
createdOn: new Date(2023, 9, 2, 10, 10),
path: 'path/to/note1'
};
const viewModel: NotesViewModel = {
viewState: {
notes: [createNoteUiModel(note)]
} as NotesViewState,
refreshNotes: jest.fn()
};

(useNotesViewModel as jest.Mock).mockReturnValue(viewModel);
render(setupContent(mockNoteEvent, refreshNotesEvent));

await waitFor(() => expect(screen.findAllByRole('listitem')).not.toBeNull());
act(() => screen.getByText(`Created at ${note.createdOn.toLocaleTimeString()}`).click());

expect(mockNoteEvent.emitEvent).toBeCalledWith(note);
});

it('should emit the note event when the path of a note is clicked', async () => {
const note: Note = {
name: 'Note 1',
createdOn: new Date(2023, 9, 2, 10, 10),
path: 'path/to/note1'
};
const viewModel: NotesViewModel = {
viewState: {
notes: [createNoteUiModel(note)]
} as NotesViewState,
refreshNotes: jest.fn()
};

(useNotesViewModel as jest.Mock).mockReturnValue(viewModel);
render(setupContent(mockNoteEvent, refreshNotesEvent));

await waitFor(() => expect(screen.findAllByRole('listitem')).not.toBeNull());
act(() => screen.getByText(note.path).click());

expect(mockNoteEvent.emitEvent).toBeCalledWith(note);
});
});

function setupContent(
noteEvent: Event<Note>,
refreshNotesEvent: Event<Note[]>
): React.ReactElement {
return (
<NoteEventContext.Provider value={noteEvent}>
<RefreshNotesEventContext.Provider value={refreshNotesEvent}>
<NotesComponent/>
</RefreshNotesEventContext.Provider>
</NoteEventContext.Provider>
);
}
23 changes: 9 additions & 14 deletions src/components/notes.component.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import {useState} from 'react';
import {Note} from 'src/domain/models/note';
import {getRefreshNotesEvent} from 'src/components/providers/refresh-notes-event.context';
import {getNoteEvent} from 'src/components/providers/note-event.context';
import {useNotesViewModel} from 'src/components/viewmodels/notes.view-model.provider';
import {NoteUiModel} from 'src/components/models/note.ui-model';

export const NotesComponent = () => {
const [notes, setNotes] = useState<Note[]>([]);
const viewModel = useNotesViewModel();
const noteEvent = getNoteEvent();
const refreshNotesEvent = getRefreshNotesEvent();

refreshNotesEvent?.onEvent('NotesComponent', (notes: Note[]) => {
setNotes(notes);
});
const uiModel = viewModel?.viewState.notes;

return (
<div className="dnc">
<ul>
{notes.map((note: Note) => (
<li key={note.path} title={note.path} onClick={() => noteEvent?.emitEvent(note)}>
<span className="note-title">{note.name}</span><br/>
<span className="note-date">Created at {note.createdOn.toLocaleTimeString()}</span><br/>
<span className="note-path">{note.path}</span>
{uiModel?.map((note: NoteUiModel) => (
<li key={note.displayFilePath} title={note.displayFilePath} onClick={() => noteEvent?.emitEvent(note?.note)}>
<span className="note-title">{note.displayName}</span><br/>
<span className="note-date">Created at {note.displayDate}</span><br/>
<span className="note-path">{note.displayFilePath}</span>
</li>
))}
</ul>
Expand Down
29 changes: 29 additions & 0 deletions src/components/providers/notes-enhancer.context.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import {renderHook} from '@testing-library/react';
import {Enhancer} from 'src/domain/enhancers/enhancer';
import {NoteUiModel} from 'src/components/models/note.ui-model';
import {NotesEnhancerContext, useNotesEnhancer} from './notes-enhancer.context';

describe('NotesEnhancerContext', () => {
const mockEnhancer = {
enhance: jest.fn()
} as unknown as Enhancer<NoteUiModel[]>;

it('provides the enhancer instance', () => {
const wrapper = ({children}: { children: React.ReactNode }) => (
<NotesEnhancerContext.Provider value={mockEnhancer}>
{children}
</NotesEnhancerContext.Provider>
);

const {result} = renderHook(() => useNotesEnhancer(), {wrapper});

expect(result.current).toBe(mockEnhancer);
});

it('returns null when no enhancer is provided', () => {
const {result} = renderHook(() => useNotesEnhancer());

expect(result.current).toBeNull();
});
});
8 changes: 8 additions & 0 deletions src/components/providers/notes-enhancer.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {createContext, useContext} from 'react';
import {Enhancer} from 'src/domain/enhancers/enhancer';
import {NoteUiModel} from 'src/components/models/note.ui-model';

export const NotesEnhancerContext = createContext<Enhancer<NoteUiModel[]> | null>(null);
export const useNotesEnhancer = (): Enhancer<NoteUiModel[]> | null => {
return useContext(NotesEnhancerContext);
}
Loading

0 comments on commit aeb1af3

Please sign in to comment.