diff --git a/.claude/skills/commit.md b/.claude/skills/commit.md index 32810bc..9eaff50 100644 --- a/.claude/skills/commit.md +++ b/.claude/skills/commit.md @@ -20,6 +20,7 @@ When using Claude Code to commit changes: ## Examples ### Initial Commit + ```bash git add src/components/NewFeature.tsx make commit @@ -29,6 +30,7 @@ make commit ``` ### Amend Commit + ```bash git commit --amend # Edit the commit message to add: @@ -42,4 +44,4 @@ git commit --amend - Keep subject line under 50 characters - Use body to explain "why" not just "what" - Reference related issues if applicable -- Ensure commit passes linting and tests before pushing \ No newline at end of file +- Ensure commit passes linting and tests before pushing diff --git a/jest.js b/jest.js index ce9bb7e..644c048 100644 --- a/jest.js +++ b/jest.js @@ -5,13 +5,17 @@ jest.useFakeTimers(); jest.mock('zustand'); // Mocking Date -class MockDate extends Date { - constructor() { - super('2020-05-14T11:01:58.135'); // add whatever date you'll expect to get +const RealDate = Date; +class MockDate extends RealDate { + constructor(date) { + if (date) return new RealDate(date); + return new RealDate('2020-05-14T11:01:58.135Z'); } } +// @ts-ignore global.Date = MockDate; +Date.now = () => new RealDate('2020-05-14T11:01:58.135Z').getTime(); // Mocking Draft.js jest.mock('draft-js', () => ({ diff --git a/src/components/Window/__tests__/Windows.test.tsx b/src/components/Window/__tests__/Windows.test.tsx index 50b8908..9c2f197 100644 --- a/src/components/Window/__tests__/Windows.test.tsx +++ b/src/components/Window/__tests__/Windows.test.tsx @@ -1,24 +1,64 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; import Window from '../Window'; -import { ProgramType } from '@processStore'; +import { ProgramType, processStore, WindowSize } from '@processStore'; +import React from 'react'; +import { act } from '@testing-library/react-hooks'; + +// Shared variable to capture props +let capturedDraggableProps: any; +let capturedResizableProps: any; + +// Mock DraggableProvider and ResizableBox to trigger callbacks and capture props +jest.mock('@providers', () => ({ + DraggableProvider: (props: any) => { + capturedDraggableProps = props; + return ( +
props.onPositionChange({ x: 100, y: 100 })} + > + {props.children} +
+ ); + }, +})); + +jest.mock('react-resizable', () => ({ + ResizableBox: (props: any) => { + capturedResizableProps = props; + return ( +
+ props.onResize && + props.onResize({}, { size: { width: 800, height: 600 } }) + } + > + {props.children} +
+ ); + }, +})); describe('Windows', () => { const clickHandler = jest.fn(); beforeEach(() => { clickHandler.mockReset(); + jest.clearAllMocks(); + capturedDraggableProps = null; + capturedResizableProps = null; + // Reset store before each test + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps = {}; + }); + // Seed FINDER app to state so it can be updated + processStore.getState().ActiveApp.addApp(ProgramType.FINDER); + }); }); it('should render for default values', () => { - const { container } = render( - -
Test
-
, - ); - - expect(container).toMatchSnapshot(); - }); - - it('should render with top bar component values', () => { const { container } = render( Top bar}>
Test
@@ -65,5 +105,190 @@ describe('Windows', () => { expect(container).toMatchSnapshot(); expect(container.querySelector('.left-side')).toBeDefined(); + + // Verify it changed to MAX + const appState = processStore.getState().ActiveApp.apps[ProgramType.FINDER]; + expect(appState?.size).toBe(WindowSize.MAX); + }); + + it('should toggle maximize on double click on title bar', () => { + const { container } = render( + +
Test
+
, + ); + + const titleBar = container.querySelector('.handle'); + expect(titleBar).toBeTruthy(); + + if (titleBar) { + fireEvent.doubleClick(titleBar); + } + + // Should now be maximized + expect(container).toMatchSnapshot(); + const appState = processStore.getState().ActiveApp.apps[ProgramType.FINDER]; + expect(appState?.size).toBe(WindowSize.MAX); + }); + + it('should handle resize', () => { + render( + +
Test
+
, + ); + + const resizable = screen.getByTestId('resizable-box'); + fireEvent.click(resizable); + // onResize should have been called and updated componentDimension state + }); + + it('should handle position change', () => { + render( + +
Test
+
, + ); + + const draggable = screen.getByTestId('draggable-provider'); + fireEvent.click(draggable); + // onPositionChange should have been called and triggered updatePosition + }); + + it('should render maximized window', () => { + // We need to mock processStore to return a maximized app + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + app: ProgramType.FINDER, + size: WindowSize.MAX, + position: { x: 0, y: 0 }, + }; + }); + }); + + const { container } = render( + +
Test
+
, + ); + + expect(container).toMatchSnapshot(); + }); + + it('should memoize correctly', () => { + const children =
Test
; + const { rerender } = render( + {children}, + ); + + // Re-rendering with same children should not cause re-render of memoized component + rerender({children}); + + // Re-rendering with different children should cause re-render + rerender( + +
Different
+
, + ); + }); + + describe('Branch Coverage Edge Cases', () => { + it('should handle undefined currentApp (defaults to not maximized)', () => { + // Clear seeded app for this test + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps = {}; + }); + }); + + render( + +
Test Undefined
+
, + ); + + expect(capturedDraggableProps.maximized).toBe(false); + expect(capturedDraggableProps.position).toEqual({ x: 0, y: 0 }); + expect(capturedResizableProps.resizeHandles).toEqual(['se']); + }); + + it('should handle DEFAULT size app', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + app: ProgramType.FINDER, + size: WindowSize.DEFAULT, + position: { x: 50, y: 50 }, + }; + }); + }); + + render( + +
Test Default
+
, + ); + + expect(capturedDraggableProps.maximized).toBe(false); + expect(capturedDraggableProps.position).toEqual({ x: 50, y: 50 }); + expect(capturedResizableProps.resizeHandles).toEqual(['se']); + }); + + it('should handle MAX size app branches', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + app: ProgramType.FINDER, + size: WindowSize.MAX, + position: { x: 10, y: 10 }, + }; + }); + }); + + render( + +
Test Max
+
, + ); + + expect(capturedDraggableProps.maximized).toBe(true); + expect(capturedResizableProps.resizeHandles).toEqual([]); + expect(capturedResizableProps.style.transition).toBe('all 0.2s'); + + // Test toggle back to DEFAULT + fireEvent.click(screen.getByLabelText('maximize')); + const appState = + processStore.getState().ActiveApp.apps[ProgramType.FINDER]; + expect(appState?.size).toBe(WindowSize.DEFAULT); + }); + + it('should show/hide handle class based on maximized state', () => { + const { rerender } = render( + +
Test Class
+
, + ); + const handleContainer = + screen.getByText('Test Class').parentElement?.previousSibling; + expect(handleContainer).toHaveClass('handle'); + + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + app: ProgramType.FINDER, + size: WindowSize.MAX, + position: { x: 0, y: 0 }, + }; + }); + }); + + rerender( + +
Test Class
+
, + ); + expect(handleContainer).not.toHaveClass('handle'); + }); }); }); diff --git a/src/components/Window/__tests__/__snapshots__/Windows.test.tsx.snap b/src/components/Window/__tests__/__snapshots__/Windows.test.tsx.snap index 6e417e5..3adcfde 100644 --- a/src/components/Window/__tests__/__snapshots__/Windows.test.tsx.snap +++ b/src/components/Window/__tests__/__snapshots__/Windows.test.tsx.snap @@ -3,125 +3,186 @@ exports[`Windows should render for default values 1`] = `
-

- x -

-
-
-

+ x +

+
+
- - -

-
-
-

+ - +

+
+
+

+ o +

+
+
- o -

+
+ Top bar +
+
-
-
-
- Test + class="css-1qx9vnh" + > +
+ Test +
-
`; -exports[`Windows should render with top bar component values 1`] = ` +exports[`Windows should render maximized window 1`] = `
-

- x -

+

+ x +

+
+
+

+ - +

+
+
+

+ o +

+
+
-

- - -

+
+ Test +
+
+
+
+
+`; + +exports[`Windows should toggle maximize on double click on title bar 1`] = ` +
+
+
+
-

- o -

+

+ x +

+
+
+

+ - +

+
+
+

+ o +

+
+
- Top bar + Test
-
-
- Test -
-
-
`; @@ -129,60 +190,60 @@ exports[`Windows should render with top bar component values 1`] = ` exports[`Windows should trigger close on close button press 1`] = `
-

- x -

-
-
-

+ x +

+
+
- - -

-
-
-

+ - +

+
+
- o -

+

+ o +

+
+
-
-
-
- Test + class="css-1qx9vnh" + > +
+ Test +
-
`; @@ -190,60 +251,60 @@ exports[`Windows should trigger close on close button press 1`] = ` exports[`Windows should trigger maximize on maximize button press 1`] = `
-

- x -

-
-
-

+ x +

+
+
- - -

-
-
-

+ - +

+
+
- o -

+

+ o +

+
+
-
-
-
- Test + class="css-1qx9vnh" + > +
+ Test +
-
`; @@ -251,60 +312,60 @@ exports[`Windows should trigger maximize on maximize button press 1`] = ` exports[`Windows should trigger minimize on minimize button press 1`] = `
-

- x -

-
-
-

+ x +

+
+
- - -

-
-
-

+ - +

+
+
- o -

+

+ o +

+
+
-
-
-
- Test + class="css-1qx9vnh" + > +
+ Test +
-
`; diff --git a/src/providers/draggable/__tests__/DraggableProvider.test.tsx b/src/providers/draggable/__tests__/DraggableProvider.test.tsx index 4bb33c2..e360820 100644 --- a/src/providers/draggable/__tests__/DraggableProvider.test.tsx +++ b/src/providers/draggable/__tests__/DraggableProvider.test.tsx @@ -1,14 +1,27 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import DraggableProvider from '../DraggableProvider'; import { Box } from '@chakra-ui/react'; +import React from 'react'; + +// Mock hooks +jest.mock('@hooks', () => ({ + useWindowDimensions: () => ({ width: 1024, height: 768 }), +})); describe('Draggable provider', () => { - it('should render correctly', () => { + const mockOnPositionChange = jest.fn(); + + beforeEach(() => { + mockOnPositionChange.mockClear(); + jest.resetModules(); + }); + + it('should render correctly to match snapshot', () => { const { container } = render( test , @@ -22,7 +35,7 @@ describe('Draggable provider', () => { test , @@ -36,7 +49,7 @@ describe('Draggable provider', () => { test , @@ -45,22 +58,68 @@ describe('Draggable provider', () => { expect(container).toMatchSnapshot(); }); - it('should render correctly on dragging', () => { - const { container } = render( - { + // Use jest.doMock for local mocking + jest.doMock('react-draggable', () => { + const MockDraggable = (props: any) => { + return ( +
props.onStop({}, { x: 150, y: 250 })} + > + {props.children} +
+ ); + }; + MockDraggable.displayName = 'MockDraggable'; + return MockDraggable; + }); + + // Re-import after mocking + const DraggableProviderMocked = require('../DraggableProvider').default; + + render( + test -
, + , ); - fireEvent.drag(screen.getByText('test'), { - clientX: 10, - clientY: 10, + fireEvent.click(screen.getByText('test')); + expect(mockOnPositionChange).toHaveBeenCalledWith({ x: 150, y: 250 }); + }); + + it('should pass correct bounds based on window dimensions', () => { + let capturedProps: any; + jest.doMock('react-draggable', () => { + const MockDraggableProps = (props: any) => { + capturedProps = props; + return
{props.children}
; + }; + MockDraggableProps.displayName = 'MockDraggableProps'; + return MockDraggableProps; }); - expect(container).toMatchSnapshot(); + const DraggableProviderMocked = require('../DraggableProvider').default; + + render( + + test + , + ); + + expect(capturedProps.bounds).toEqual({ + left: 0, + top: 0, + right: 1024, + bottom: 768, + }); }); }); diff --git a/src/providers/draggable/__tests__/__snapshots__/DraggableProvider.test.tsx.snap b/src/providers/draggable/__tests__/__snapshots__/DraggableProvider.test.tsx.snap index b9a9fa6..11df2d4 100644 --- a/src/providers/draggable/__tests__/__snapshots__/DraggableProvider.test.tsx.snap +++ b/src/providers/draggable/__tests__/__snapshots__/DraggableProvider.test.tsx.snap @@ -1,17 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Draggable provider should render correctly 1`] = ` -
-
- test -
-
-`; - -exports[`Draggable provider should render correctly on dragging 1`] = ` +exports[`Draggable provider should render correctly to match snapshot 1`] = `
{ it('should render correctly', () => { const { result } = renderHook(() => uiStore()); - result.current.Modal.resetModalState(); + act(() => { + result.current.Modal.resetModalState(); + }); const { container } = render(App); - jest.runAllTimersAsync(); - expect(container).toMatchSnapshot(); expect(screen.getByText('App')).toBeDefined(); }); @@ -19,16 +21,84 @@ describe('ModalProvider', () => { it('should render correctly with children', () => { const { result } = renderHook(() => uiStore()); - result.current.Modal.resetModalState(); + act(() => { + result.current.Modal.resetModalState(); + }); const { container } = render(
App
, ); - jest.runAllTimersAsync(); - expect(container).toMatchSnapshot(); expect(screen.getByText('App')).toBeDefined(); }); + + it('should handle modal open state', () => { + const { result } = renderHook(() => uiStore()); + + render(App); + + act(() => { + result.current.Modal.openModal(ModalID.SEARCH, jest.fn()); + }); + + // The effect should trigger onOpen() + expect(screen.getByText('App')).toBeDefined(); + }); + + describe('Branch Coverage', () => { + it('should call onModalClose when closing modal', () => { + const { result } = renderHook(() => uiStore()); + const mockOnClose = jest.fn(); + + render(App); + + // 1. Open modal + act(() => { + result.current.Modal.openModal(ModalID.SEARCH, mockOnClose); + }); + + // 2. Close modal (transition from OPEN to CLOSE) + act(() => { + uiStore.setState((state) => { + state.Modal.modalOpenState = ModalOpenState.CLOSE; + }); + }); + + // Verify callback was called + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should handle transition to CLOSE when onModalClose is not provided', () => { + act(() => { + uiStore.setState((state) => { + state.Modal.modalOpenState = ModalOpenState.CLOSE; + state.Modal.modalID = ModalID.NONE; + state.Modal.modalData = undefined; + }); + }); + + render(App); + + // 1. Open modal without callback + act(() => { + uiStore.setState((state) => { + state.Modal.modalOpenState = ModalOpenState.OPEN; + state.Modal.modalID = ModalID.SEARCH; + state.Modal.modalData = undefined; + }); + }); + + // 2. Close modal + act(() => { + uiStore.setState((state) => { + state.Modal.modalOpenState = ModalOpenState.CLOSE; + }); + }); + + // Should not throw + expect(screen.getByText('App')).toBeDefined(); + }); + }); }); diff --git a/src/router/lazyRouting/lazy_screen/private/program/__tests__/__snapshots__/LazyNotesScreen.test.tsx.snap b/src/router/lazyRouting/lazy_screen/private/program/__tests__/__snapshots__/LazyNotesScreen.test.tsx.snap index 24fcf83..e6b15be 100644 --- a/src/router/lazyRouting/lazy_screen/private/program/__tests__/__snapshots__/LazyNotesScreen.test.tsx.snap +++ b/src/router/lazyRouting/lazy_screen/private/program/__tests__/__snapshots__/LazyNotesScreen.test.tsx.snap @@ -4,50 +4,253 @@ exports[`LazyNotesComponent should render correctly to match snapshot 1`] = `
- - +
+

+ All iCloud +

+

+ 0 + + notes +

+
+
+
- - + + +
+ + + + + +
+
+ + + +
+
+ + + + +
+ +
+
+ style="overflow: hidden; height: 100%; width: 260px; opacity: 1;" + > +
+
+
+

+ πŸ—’οΈ +

+

+ No notes yet +

+
+
+
+
-
- Editor +
+

+ πŸ—’οΈ +

+

+ Select or create a note +

diff --git a/src/screens/private/Home/BottomBar/__tests__/BottomBar.test.tsx b/src/screens/private/Home/BottomBar/__tests__/BottomBar.test.tsx index d2873aa..29bcc14 100644 --- a/src/screens/private/Home/BottomBar/__tests__/BottomBar.test.tsx +++ b/src/screens/private/Home/BottomBar/__tests__/BottomBar.test.tsx @@ -1,16 +1,56 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, act } from '@testing-library/react'; import BottomBar from '../BottomBar'; import { renderHook } from '@testing-library/react-hooks'; -import { ProgramType, processStore } from '@processStore'; +import { ProgramType, processStore, WindowSize } from '@processStore'; import { BottomBarProgramType } from '../components'; import { LaunchpadContext } from '../../../Mac'; +import React from 'react'; + +// Mock react-beautiful-dnd to capture onDragEnd +jest.mock('react-beautiful-dnd', () => ({ + DragDropContext: ({ children, onDragEnd }: any) => { + (global as any).triggerDragEnd = onDragEnd; + return
{children}
; + }, + Droppable: ({ children }: any) => + children( + { + innerRef: jest.fn(), + droppableProps: {}, + placeholder:
, + }, + {}, + ), + Draggable: ({ children }: any) => + children( + { + innerRef: jest.fn(), + draggableProps: {}, + dragHandleProps: {}, + }, + {}, + ), +})); describe('BottomBar', () => { beforeEach(() => { const { result } = renderHook(() => processStore()); - result.current.ActiveApp.removeApp(ProgramType.FINDER); - result.current.ActiveApp.removeApp(ProgramType.CHROME); + act(() => { + result.current.ActiveApp.removeApp(ProgramType.FINDER); + result.current.ActiveApp.removeApp(ProgramType.CHROME); + processStore.setState((state: any) => { + state.ActiveApp.apps = {}; + }); + }); + jest.clearAllMocks(); + delete (global as any).triggerDragEnd; + jest.useFakeTimers(); }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should render correctly to match snapshot', () => { const { container } = render(); @@ -19,15 +59,17 @@ describe('BottomBar', () => { it('should render invoke onclick on launch pad click', async () => { const setLaunchpad = jest.fn(); - renderHook(() => processStore()); + render(); render( , ); - fireEvent.click(screen.getByLabelText('program-button-launchPad')); - await jest.advanceTimersByTimeAsync(1000); + fireEvent.click(screen.getAllByLabelText('program-button-launchPad')[0]); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect(setLaunchpad).toHaveBeenCalled(); }); @@ -37,7 +79,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-finder')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.FINDER], @@ -49,7 +93,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-finder')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.FINDER], @@ -61,7 +107,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-chrome')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.CHROME], @@ -73,7 +121,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-spotify')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.SPOTIFY], @@ -85,7 +135,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-terminal')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.TERMINAL], @@ -97,7 +149,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-vscode')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.VSCODE], @@ -109,7 +163,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-github')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.GITHUB], @@ -121,7 +177,9 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-settings')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect( result.current.ActiveApp.apps[BottomBarProgramType.SETTINGS], @@ -133,8 +191,153 @@ describe('BottomBar', () => { render(); fireEvent.click(screen.getByLabelText('program-button-Bin')); - await jest.advanceTimersByTimeAsync(1000); + await act(async () => { + jest.advanceTimersByTime(1000); + }); expect(result.current.ActiveApp.apps[ProgramType.BIN]).toMatchSnapshot(); }); + + describe('Branch Coverage Extra Cases', () => { + it('should handle onDragEnd with valid destination', () => { + render(); + const onDragEnd = (global as any).triggerDragEnd; + + act(() => { + onDragEnd({ + source: { index: 0 }, + destination: { index: 1 }, + }); + }); + }); + + it('should handle onDragEnd with same index (no-op branch)', () => { + render(); + const onDragEnd = (global as any).triggerDragEnd; + + act(() => { + onDragEnd({ + source: { index: 0 }, + destination: { index: 0 }, + }); + }); + }); + + it('should handle onDragEnd with no destination (no-op branch)', () => { + render(); + const onDragEnd = (global as any).triggerDragEnd; + + act(() => { + onDragEnd({ + source: { index: 0 }, + destination: null, + }); + }); + }); + + it('should render and click running middle apps', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.CALENDAR] = { + position: { x: 0, y: 0 }, + size: WindowSize.MAX, + }; + }); + }); + + render(); + + const calendarButton = screen.getByLabelText('program-button-calendar'); + fireEvent.click(calendarButton); + + const appState = + processStore.getState().ActiveApp.apps[ProgramType.CALENDAR]; + expect(appState?.size).toBe(WindowSize.DEFAULT); + }); + + it('should open a middle app if it was not running', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.CALENDAR] = undefined as any; + }); + }); + + render(); + + const calendarButton = screen.getByLabelText('program-button-calendar'); + fireEvent.click(calendarButton); + + act(() => { + jest.advanceTimersByTime(500); + }); + + const appState = + processStore.getState().ActiveApp.apps[ProgramType.CALENDAR]; + expect(appState).toBeDefined(); + }); + + it('should set window size when clicking an already running bottom bar app', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + position: { x: 0, y: 0 }, + size: WindowSize.MAX, + }; + }); + }); + + render(); + + fireEvent.click(screen.getByLabelText('program-button-finder')); + + const appState = + processStore.getState().ActiveApp.apps[ProgramType.FINDER]; + expect(appState?.size).toBe(WindowSize.DEFAULT); + }); + + it('should set window size when clicking an already running Bin app', () => { + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.apps[ProgramType.FINDER] = { + position: { x: 0, y: 0 }, + size: WindowSize.DEFAULT, + }; + state.ActiveApp.apps[ProgramType.BIN] = { + position: { x: 0, y: 0 }, + size: WindowSize.MAX, + }; + }); + }); + + render(); + + fireEvent.click(screen.getByLabelText('program-button-Bin')); + + const appState = processStore.getState().ActiveApp.apps[ProgramType.BIN]; + expect(appState?.size).toBe(WindowSize.DEFAULT); + }); + + it('should close launchpad when activeAppRunning changes', () => { + const setLaunchpad = jest.fn(); + const { rerender } = render( + + + , + ); + + act(() => { + processStore.setState((state: any) => { + state.ActiveApp.activeApp = ProgramType.NOTES; + }); + }); + + rerender( + + + , + ); + + expect(setLaunchpad).toHaveBeenCalledWith(false); + }); + }); }); diff --git a/src/screens/private/Home/BottomBar/__tests__/__snapshots__/BottomBar.test.tsx.snap b/src/screens/private/Home/BottomBar/__tests__/__snapshots__/BottomBar.test.tsx.snap index f5ba425..21bd460 100644 --- a/src/screens/private/Home/BottomBar/__tests__/__snapshots__/BottomBar.test.tsx.snap +++ b/src/screens/private/Home/BottomBar/__tests__/__snapshots__/BottomBar.test.tsx.snap @@ -77,232 +77,128 @@ exports[`BottomBar should render correctly to match snapshot 1`] = ` class="css-9xph1p" >
- Launchpad + Launchpad +
+
+ Finder +
+
+ Notes +
+
+ Chrome +
+
+ VsCode +
+
+ Terminal +
+
+ Spotify +
+
+ Github +
+
+ Settings +
+
-
-
- Finder -
-
- Notes -
-
- Chrome -
-
- VsCode -
-
- Terminal -
-
- Spotify -
-
- Github -
-
- Settings -
-
-
- numbers -
-
- pages -
-
- xcode -
-
-
- Bin +
+ Bin +
diff --git a/src/screens/private/program/Notes/Notes.tsx b/src/screens/private/program/Notes/Notes.tsx index 4c21646..c3b8b92 100644 --- a/src/screens/private/program/Notes/Notes.tsx +++ b/src/screens/private/program/Notes/Notes.tsx @@ -1,160 +1,138 @@ -import { ContentState, Editor, EditorState } from 'draft-js'; -// import 'draft-js/dist/Draft.css'; -import { Box, Card, IconButton, Text } from '@chakra-ui/react'; - -import { NotesProps } from './type'; +import { Box } from '@chakra-ui/react'; import { appStore, notesSelector, useShallow } from '@appStore'; -import { useEffect, useRef, useState } from 'react'; -import { getAllStringExceptFirstLine, getFirstLineFromString } from './utils'; - -const color = '#cc99009f'; -const Notes = (props: NotesProps) => { - const editorRef = useRef(null); - const [editorState, setEditorState] = useState(() => - EditorState.createEmpty(), +import { useCallback, useEffect, useState } from 'react'; +import { Note } from '@appStore'; +import { darkModeColorSelector, settingsStore } from '@settingsStore'; +import { generateNoteId } from './utils'; +import { NotesProps } from './type'; +import { motion, AnimatePresence } from 'framer-motion'; + +import NoteToolbar from './component/NoteToolbar'; +import NoteList from './component/NoteList'; +import NoteEditor from './component/NoteEditor'; + +// ─── Component ─────────────────────────────────────────────────────────────── + +const Notes = (_props: NotesProps) => { + const [selectedId, setSelectedId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isSidebarVisible, setIsSidebarVisible] = useState(true); + + const { notes, notesList, addNote, deleteNote, editNote, pinNote } = appStore( + useShallow(notesSelector), ); - const [selectedNoteId, setSelectedNoteId] = useState('0'); + const { mainColor } = settingsStore(useShallow(darkModeColorSelector)); - const { notes, addNote, selectedNote, getCurrentId, deleteNote, editNote } = - appStore(useShallow(notesSelector)); - const currentNote = selectedNote(selectedNoteId); + const selectedNote = selectedId ? notes[selectedId] : undefined; + // Auto-select first note on initial load if none selected useEffect(() => { - if (selectedNoteId !== '0') { - setEditorState( - EditorState.createWithContent( - ContentState.createFromText( - selectedNoteId - ? currentNote.title + '\n' + currentNote.description - : '', - ), - ), - ); + if (!selectedId && notesList.length > 0) { + setSelectedId(notesList[0].id); } + }, [notesList, selectedId]); + + // ─── Handlers ────────────────────────────────────────────────────────────── + + const handleNewNote = useCallback(() => { + const id = generateNoteId(); + const now = new Date().toISOString(); + const newNote: Note = { + id, + title: 'New Note', + description: '', + date: now, + updatedAt: now, + content: '
New Note
', + pinned: false, + }; + addNote(newNote); + setSelectedId(id); + }, [addNote]); + + const handleDelete = useCallback(() => { + if (!selectedId) return; + deleteNote(selectedId); + // Select the next available note + const remaining = notesList.filter((n) => n.id !== selectedId); + setSelectedId(remaining.length > 0 ? remaining[0].id : null); + }, [deleteNote, notesList, selectedId]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedNoteId]); + const handlePin = useCallback(() => { + if (!selectedId) return; + pinNote(selectedId); + }, [pinNote, selectedId]); + + const handleUpdate = useCallback( + (updatedNote: Note) => { + editNote(updatedNote); + }, + [editNote], + ); + + const toggleSidebar = useCallback(() => { + setIsSidebarVisible((prev) => !prev); + }, []); + + // Keyboard shortcut: ⌘N for new note + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + e.preventDefault(); + handleNewNote(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleNewNote]); + + // ─── Render ──────────────────────────────────────────────────────────────── return ( - - +} - onClick={() => - addNote({ - id: (getCurrentId() + 1).toString(), - title: 'Title', - description: 'Description', - date: new Date().toLocaleDateString('en-GB'), - }) - } - /> - -} - onClick={() => { - if (selectedNoteId !== '0') { - deleteNote(selectedNoteId); - setSelectedNoteId('0'); - } - }} - /> - - - - {Object.values(notes).map((note) => ( - <> - { - if (selectedNoteId !== '0') { - editNote({ - id: selectedNoteId, - title: getFirstLineFromString( - editorState.getCurrentContent().getPlainText(), - ), - description: getAllStringExceptFirstLine( - editorState.getCurrentContent().getPlainText(), - ), - date: selectedNoteId ? currentNote?.date : '', - }); - } - setSelectedNoteId(note.id); - }} - _hover={{ - cursor: 'pointer', - }} - color={'#ffffff'} - borderBottom={'1px solid #000000'} - > - - {note.title} - - - {note.date} - - {' -> '} - {note.description} - - - - - ))} - - { - editorRef.current?.focus(); - }} - border={'1px solid #000000'} - > - + {/* Top toolbar */} + + + {/* Body: sidebar + editor */} + + + {isSidebarVisible && ( + + + + )} + + + diff --git a/src/screens/private/program/Notes/__tests__/Notes.test.tsx b/src/screens/private/program/Notes/__tests__/Notes.test.tsx index 88c2496..fb1377d 100644 --- a/src/screens/private/program/Notes/__tests__/Notes.test.tsx +++ b/src/screens/private/program/Notes/__tests__/Notes.test.tsx @@ -1,12 +1,49 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, act } from '@testing-library/react'; import Notes from '../Notes'; import { renderHook } from '@testing-library/react-hooks'; import { appStore } from '@appStore'; +// Mock generateNoteId to return predictable IDs +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + generateNoteId: jest.fn(() => '1'), +})); + describe('Notes', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-04-20T10:00:00Z')); + + // Seed the store with initial notes since the slice is now empty by default + const { result } = renderHook(() => appStore()); + act(() => { + // note-1 is special (seeds mock content) + result.current.Notes.addNote({ + id: 'note-1', + title: 'Teaching Holistic Health πŸ§˜β€β™€οΈ', + description: 'Brainstorm for first in-class session...', + content: '', + date: '2025-04-20T10:00:00Z', + updatedAt: '2025-04-20T10:00:00Z', + pinned: false, + }); + result.current.Notes.addNote({ + id: 'note-2', + title: 'Grocery List πŸ›’', + description: 'Milk, Eggs, Bread...', + content: '
Grocery List πŸ›’
', + date: '2025-04-19T15:30:00Z', + updatedAt: '2025-04-19T15:30:00Z', + pinned: true, + }); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should render correctly to match snapshot', () => { const { container } = render(); - expect(container).toMatchSnapshot(); }); @@ -14,14 +51,12 @@ describe('Notes', () => { const { result } = renderHook(() => appStore()); const { container } = render(); - fireEvent.click(screen.getByLabelText('add-note')); + fireEvent.click(screen.getByLabelText('New Note (⌘N)')); expect(container).toMatchSnapshot(); - expect(result.current.Notes.notes['1']).toEqual({ + expect(result.current.Notes.notes['1']).toMatchObject({ id: '1', - title: 'Title', - description: 'Description', - date: new Date().toLocaleDateString('en-GB'), + title: 'New Note', }); }); @@ -29,37 +64,28 @@ describe('Notes', () => { const { result } = renderHook(() => appStore()); const { container } = render(); - fireEvent.click(screen.getByLabelText('add-note')); + fireEvent.click(screen.getByLabelText('New Note (⌘N)')); + // The newly added note has ID '1' fireEvent.click(screen.getByLabelText('note-card-1')); - fireEvent.click(screen.getByLabelText('delete-note')); + fireEvent.click(screen.getByLabelText('Delete')); expect(container).toMatchSnapshot(); expect(result.current.Notes.notes['1']).toBeUndefined(); }); it('should select and deselect on clicking on card', () => { - renderHook(() => appStore()); const { container } = render(); - fireEvent.click(screen.getByLabelText('add-note')); - fireEvent.click(screen.getByLabelText('add-note')); - - fireEvent.click(screen.getByLabelText('note-card-2')); - fireEvent.click(screen.getByLabelText('note-card-1')); + fireEvent.click(screen.getByLabelText('note-card-note-2')); + fireEvent.click(screen.getByLabelText('note-card-note-1')); // Card 1 is selected expect(container).toMatchSnapshot(); }); - it('should select all on clicking select all', () => { - renderHook(() => appStore()); - const { container } = render(); - // Adding notes - fireEvent.click(screen.getByLabelText('add-note')); - // Clicking on text area to get focus on editor - fireEvent.click(screen.getByLabelText('note-editor-box')); - - // Card 1 is selected - expect(container).toMatchSnapshot(); + it('should show editor when a note is selected', () => { + render(); + fireEvent.click(screen.getByLabelText('note-card-note-2')); + expect(screen.getByLabelText('note-editor')).toBeDefined(); }); }); diff --git a/src/screens/private/program/Notes/__tests__/__snapshots__/Notes.test.tsx.snap b/src/screens/private/program/Notes/__tests__/__snapshots__/Notes.test.tsx.snap index bb4ae6b..6c40ec6 100644 --- a/src/screens/private/program/Notes/__tests__/__snapshots__/Notes.test.tsx.snap +++ b/src/screens/private/program/Notes/__tests__/__snapshots__/Notes.test.tsx.snap @@ -4,127 +4,433 @@ exports[`Notes should invoke add note on clicking add note button 1`] = `
- - -
-
+

- Title + All iCloud

- 14/05/2020 -

- -> - Description -

+ 3 + + notes

-
- Editor -
+ + +
+ + + + +
-
-
-
-`; - -exports[`Notes should invoke delete note on clicking delete note button 1`] = ` -
-
-
- - + + + +
+
+ + + + +
+ +
+
+ style="overflow: hidden; height: 100%; width: 260px; opacity: 1;" + > +
+
+
+

+ Pinned +

+
+
+
+

+ Grocery List πŸ›’ +

+
+

+ 4/19/25 +

+

+ Milk, Eggs, Bread... +

+
+
+ + + +
+
+
+
+
+
+

+ April +

+
+
+
+

+ New Note +

+
+

+ 4/20/25 +

+
+
+ + + +
+
+
+
+
+
+
+

+ Teaching Holistic Health πŸ§˜β€β™€οΈ +

+
+

+ 4/20/25 +

+

+ Brainstorm for first in-class session... +

+
+
+ + + +
+
+
+
+
+
+
+
-
- Editor +
+
+
+
+ New Note +
+
+
+
+
+

+ 4/20/2025 + + at + + 10:00 AM +

+

+ 2 + + words +

+
@@ -132,54 +438,389 @@ exports[`Notes should invoke delete note on clicking delete note button 1`] = `
`; -exports[`Notes should render correctly to match snapshot 1`] = ` +exports[`Notes should invoke delete note on clicking delete note button 1`] = `
- +
+

+ All iCloud +

+

+ 2 + + notes +

+
+
+
- - - + +
+ + + + + +
+
- - + + + +
+
+ + + + +
+ +
+
+ style="overflow: hidden; height: 100%; width: 260px; opacity: 1;" + > +
+
+
+

+ Pinned +

+
+
+
+

+ Grocery List πŸ›’ +

+
+

+ 4/19/25 +

+

+ Milk, Eggs, Bread... +

+
+
+ + + +
+
+
+
+
+
+

+ April +

+
+
+
+

+ Teaching Holistic Health πŸ§˜β€β™€οΈ +

+
+

+ 4/20/25 +

+

+ Brainstorm for first in-class session... +

+
+
+ + + +
+
+
+
+
+
+
+
-
- Editor +
+
+
+
+ Grocery List πŸ›’ +
+
+
+
+

+ 4/19/2025 + + at + + 03:30 PM +

+

+ 3 + + words +

+
@@ -187,76 +828,389 @@ exports[`Notes should render correctly to match snapshot 1`] = `
`; -exports[`Notes should select all on clicking select all 1`] = ` +exports[`Notes should render correctly to match snapshot 1`] = `
- +
+

+ All iCloud +

+

+ 2 + + notes +

+
+
+
- - - + +
+ + + + + +
+
- - + + + +
+
+ + + + +
+ +
+
-

- Title -

-

- 14/05/2020 -

- -> - Description -

-

+

+ Pinned +

+
+
+
+

+ Grocery List πŸ›’ +

+
+

+ 4/19/25 +

+

+ Milk, Eggs, Bread... +

+
+
+ + + +
+
+
+
+
+
+

+ April +

+
+
+
+

+ Teaching Holistic Health πŸ§˜β€β™€οΈ +

+
+

+ 4/20/25 +

+

+ Brainstorm for first in-class session... +

+
+
+ + + +
+
+
+
+
+
-
- Editor +
+
+
+
+ Grocery List πŸ›’ +
+
+
+
+

+ 4/19/2025 + + at + + 03:30 PM +

+

+ 3 + + words +

+
@@ -268,93 +1222,690 @@ exports[`Notes should select and deselect on clicking on card 1`] = `
- - -
-
+

- Title + All iCloud

- 14/05/2020 -

- -> - Description -

+ 2 + + notes

+
+
+ +
+ + + + +
+
+ + + +
+
+ - -> - Description -

-

+ + +
+
+ +
+
+
+
+
+
+
+
+

+ Pinned +

+
+
+
+

+ Grocery List πŸ›’ +

+
+

+ 4/19/25 +

+

+ Milk, Eggs, Bread... +

+
+
+ + + +
+
+
+
+
+
+

+ April +

+
+
+
+

+ Teaching Holistic Health πŸ§˜β€β™€οΈ +

+
+

+ 4/20/25 +

+

+ Brainstorm for first in-class session... +

+
+
+ + + +
+
+
+
+
+
-
- Editor +
+
+
+ + +
+ Teaching Holistic Health πŸ§˜β€β™€οΈ +
+ + +
+ + Brainstorm for first in-class session + +
+ + +
+ + #school + + + + #kine210 + + + + #practicum + +
+ + +
+ + Topic idea: Mind-Body Connection + +
+ + +
+ Getting class assignments next week but we should expect third, fourth, or fifth-graders. "Mind-Body Connection" could be a cool angle; + + lots of different ways to approach it, + + it's + + accessible to everyone, + + + + doesn't require any special gear, + + + + non-competitive, + + etc. We could + + start with a box breathing exercise + + to demonstrate the + + connection between thinking, doing, and feeling + + and use it as a segue into a lesson about + + thinking of ourselves as interconnected systems. + +
+ + +
+ + + + + + + + + + Box + + + + + Breathing + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + 1 Breathe in + + + + + 2 Hold + + + + + 3 Breathe out + + + + + 4 Hold + + + + + + +
+ + +
+ Box breathing is a technique used to center and destress. Studies show controlled breathing has myriad physiological benefitsβ€”it soothes the autonomic nervous system, reduces cortisol, etc. This might be a little advanced but I think we should try to find a way to explain these systems in a way that will make sense to elementary school students. +
+
+ + +
+ I think it would be great to start off by having the whole class try box breathing together and then talking about how it made everyone feel. Starting with something calming that gets everyone on the same page seems like a good idea in case we get a class right after recess, too. This is effectively an introduction to +
+ + +
+
+
+

+ 4/20/2025 + + at + + 10:00 AM +

+

+ 219 + + words +

+
diff --git a/src/screens/private/program/Notes/component/NoteCard.tsx b/src/screens/private/program/Notes/component/NoteCard.tsx new file mode 100644 index 0000000..26db8d9 --- /dev/null +++ b/src/screens/private/program/Notes/component/NoteCard.tsx @@ -0,0 +1,110 @@ +import { Box, Text } from '@chakra-ui/react'; +import { Note } from '@appStore'; +import { + darkModeColorSelector, + settingsStore, + useShallow, +} from '@settingsStore'; + +// ─── Props ─────────────────────────────────────────────────────────────────── + +interface NoteCardProps { + note: Note; + isSelected: boolean; + onClick: () => void; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +const NoteCard = ({ note, isSelected, onClick }: NoteCardProps) => { + const { textColor } = settingsStore(useShallow(darkModeColorSelector)); + const isDark = settingsStore((state) => state.Display.darkMode); + + const title = note.title || 'New Note'; + const preview = note.description || ''; + + // Custom format: 3/6/25 + const dateObj = new Date(note.updatedAt || note.date); + const formattedDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()}/${dateObj.getFullYear().toString().slice(-2)}`; + + const selectionBg = isDark ? 'rgba(255,255,255,0.15)' : '#e5e5e5'; + const hoverBg = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.04)'; + + return ( + e.key === 'Enter' && onClick()} + px={3} + py={2} + cursor="pointer" + position="relative" + borderRadius="10px" + bg={isSelected ? selectionBg : 'transparent'} + transition="background 0.15s ease" + _hover={{ + bg: isSelected ? selectionBg : hoverBg, + }} + display="flex" + justifyContent="space-between" + alignItems="center" + > + + + {title} + + + + + {formattedDate} + + {preview && ( + + {preview} + + )} + + + + {/* Folder Icon */} + + + + + + + ); +}; + +export default NoteCard; diff --git a/src/screens/private/program/Notes/component/NoteEditor.tsx b/src/screens/private/program/Notes/component/NoteEditor.tsx new file mode 100644 index 0000000..f82cb5a --- /dev/null +++ b/src/screens/private/program/Notes/component/NoteEditor.tsx @@ -0,0 +1,303 @@ +import { Box, Text } from '@chakra-ui/react'; +import { Note } from '@appStore'; +import { countWords, getTitleFromContent } from '../utils'; +import { useEffect, useRef } from 'react'; +import { + darkModeColorSelector, + settingsStore, + useShallow, +} from '@settingsStore'; + +// ─── Props ─────────────────────────────────────────────────────────────────── + +interface NoteEditorProps { + note: Note | undefined; + onUpdate: (note: Note) => void; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +const NoteEditor = ({ note, onUpdate }: NoteEditorProps) => { + const editorRef = useRef(null); + const saveTimer = useRef>(); + const currentNoteId = useRef(); + + const { mainColor, textColor } = settingsStore( + useShallow(darkModeColorSelector), + ); + const isDark = settingsStore((state) => state.Display.darkMode); + + // Dynamic Editor CSS + const editorCss = ` + .notes-editor { + outline: none; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif; + font-size: 15px; + line-height: 1.5; + color: ${textColor}; + caret-color: #fbbf24; + word-break: break-word; + height: 100%; + } + .notes-editor:empty:before { + content: attr(data-placeholder); + color: ${isDark ? 'rgba(255,255,255,0.3)' : '#c7c7cc'}; + pointer-events: none; + } + .notes-editor > div:first-child, + .notes-editor > p:first-child { + font-size: 26px; + font-weight: 700; + color: ${textColor}; + line-height: 1.2; + margin-bottom: 4px; + letter-spacing: -0.02em; + } + .notes-editor h2 { + font-size: 18px; + font-weight: 600; + color: ${textColor}; + margin: 12px 0 4px; + } + .notes-editor input[type="checkbox"] { + margin-right: 6px; + accent-color: #fbbf24; + } + .notes-editor ::selection { background: rgba(251,191,36,0.35); } + + .hashtag { + color: #ff9500; + font-weight: 500; + } + .highlight-cyan { + background-color: rgba(0, 255, 255, ${isDark ? '0.3' : '0.2'}); + border-radius: 4px; + padding: 0 2px; + } + .highlight-pink { + background-color: rgba(255, 105, 180, ${isDark ? '0.3' : '0.2'}); + border-radius: 4px; + padding: 0 2px; + } + .highlight-orange { + background-color: rgba(255, 165, 0, ${isDark ? '0.3' : '0.2'}); + border-radius: 4px; + padding: 0 2px; + } + `; + + useEffect(() => { + let style = document.getElementById( + 'notes-editor-styles', + ) as HTMLStyleElement; + if (!style) { + style = document.createElement('style'); + style.id = 'notes-editor-styles'; + document.head.appendChild(style); + } + style.textContent = editorCss; + }, [editorCss]); + + // Mock HTML Data (dynamic colors) + const mockSvg = ` + + + Box + Breathing + + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + 1 Breathe in + 2 Hold + 3 Breathe out + 4 Hold + + `; + + const mockHtml = ` +
Teaching Holistic Health πŸ§˜β€β™€οΈ
+
Brainstorm for first in-class session
+
#school #kine210 #practicum
+
Topic idea: Mind-Body Connection
+
Getting class assignments next week but we should expect third, fourth, or fifth-graders. "Mind-Body Connection" could be a cool angle; lots of different ways to approach it, it's accessible to everyone, doesn't require any special gear, non-competitive, etc. We could start with a box breathing exercise to demonstrate the connection between thinking, doing, and feeling and use it as a segue into a lesson about thinking of ourselves as interconnected systems.
+
${mockSvg}
+
Box breathing is a technique used to center and destress. Studies show controlled breathing has myriad physiological benefitsβ€”it soothes the autonomic nervous system, reduces cortisol, etc. This might be a little advanced but I think we should try to find a way to explain these systems in a way that will make sense to elementary school students.

+
I think it would be great to start off by having the whole class try box breathing together and then talking about how it made everyone feel. Starting with something calming that gets everyone on the same page seems like a good idea in case we get a class right after recess, too. This is effectively an introduction to
+ `; + + // Sync content when selected note changes + useEffect(() => { + if (!editorRef.current || !note) return; + if (currentNoteId.current === note.id) return; // avoid overwriting mid-edit + currentNoteId.current = note.id; + + const isMock = note.title === 'Teaching Holistic Health πŸ§˜β€β™€οΈ'; + let html = ''; + + if (isMock && (!note.content || !note.content.includes('highlight-cyan'))) { + html = mockHtml; + onUpdate({ ...note, content: mockHtml }); + } else { + html = + note.content || + `
${note.title || 'New Note'}
${note.description || ''}
`; + } + + editorRef.current.innerHTML = html; + }, [note?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleInput = () => { + if (!editorRef.current || !note) return; + const html = editorRef.current.innerHTML; + + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + const title = getTitleFromContent(html); + const div = document.createElement('div'); + div.innerHTML = html; + const fullText = (div.innerText || div.textContent || '').trim(); + const description = fullText + .split('\n') + .slice(1) + .join(' ') + .trim() + .slice(0, 120); + + onUpdate({ + ...note, + title, + description, + content: html, + updatedAt: new Date().toISOString(), + }); + }, 500); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + switch (e.key) { + case 'b': + e.preventDefault(); + document.execCommand('bold'); + break; + case 'i': + e.preventDefault(); + document.execCommand('italic'); + break; + case 'u': + e.preventDefault(); + document.execCommand('underline'); + break; + } + }; + + const wordCount = note?.content ? countWords(note.content) : 0; + + if (!note) { + return ( + + πŸ—’οΈ + + Select or create a note + + + ); + } + + return ( + + {/* Editor area */} + editorRef.current?.focus()} + css={{ + scrollbarWidth: 'thin', + scrollbarColor: isDark + ? 'rgba(255,255,255,0.1) transparent' + : '#e5e5e5 transparent', + }} + > + + + + {/* Status bar */} + + + {note.updatedAt ? new Date(note.updatedAt).toLocaleDateString() : ''}{' '} + at{' '} + {note.updatedAt + ? new Date(note.updatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + : ''} + + + {wordCount} {wordCount === 1 ? 'word' : 'words'} + + + + ); +}; + +export default NoteEditor; diff --git a/src/screens/private/program/Notes/component/NoteFormatBar.tsx b/src/screens/private/program/Notes/component/NoteFormatBar.tsx new file mode 100644 index 0000000..9d39829 --- /dev/null +++ b/src/screens/private/program/Notes/component/NoteFormatBar.tsx @@ -0,0 +1,168 @@ +import { Box } from '@chakra-ui/react'; +import { RefObject, useEffect, useState } from 'react'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const ACCENT = '#fbbf24'; + +// ─── Format button ─────────────────────────────────────────────────────────── + +const FmtBtn = ({ + label, + icon, + active, + onClick, +}: { + label: string; + icon: string; + active?: boolean; + onClick: () => void; +}) => ( + { + e.preventDefault(); + onClick(); + }} + display="flex" + alignItems="center" + justifyContent="center" + boxSize="26px" + borderRadius={5} + fontSize="12px" + fontWeight={700} + cursor="pointer" + background="none" + border="none" + color={active ? ACCENT : '#d1d1d1'} + bg={active ? 'rgba(251,191,36,0.15)' : 'transparent'} + transition="background 0.12s ease, color 0.12s ease" + _hover={{ bg: 'rgba(255,255,255,0.12)', color: 'white' }} + > + {icon} + +); + +const Divider = () => ( + +); + +// ─── Props ─────────────────────────────────────────────────────────────────── + +interface NoteFormatBarProps { + editorRef: RefObject; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +const NoteFormatBar = ({ editorRef }: NoteFormatBarProps) => { + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const [active, setActive] = useState({ + bold: false, + italic: false, + underline: false, + }); + + useEffect(() => { + const onSelectionChange = () => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { + setPos(null); + return; + } + const range = sel.getRangeAt(0); + if (!editorRef.current?.contains(range.commonAncestorContainer)) { + setPos(null); + return; + } + const rect = range.getBoundingClientRect(); + setPos({ + top: rect.top - 48, + left: rect.left + rect.width / 2, + }); + setActive({ + bold: document.queryCommandState('bold'), + italic: document.queryCommandState('italic'), + underline: document.queryCommandState('underline'), + }); + }; + + document.addEventListener('selectionchange', onSelectionChange); + return () => + document.removeEventListener('selectionchange', onSelectionChange); + }, [editorRef]); + + const exec = (cmd: string, value?: string) => { + document.execCommand(cmd, false, value); + editorRef.current?.focus(); + }; + + if (!pos) return null; + + return ( + e.preventDefault()} // don't steal focus + > + exec('bold')} + /> + exec('italic')} + /> + exec('underline')} + /> + + exec('formatBlock', 'h2')} + /> + exec('formatBlock', 'div')} + /> + + exec('insertUnorderedList')} + /> + exec('insertOrderedList')} + /> + + ); +}; + +export default NoteFormatBar; diff --git a/src/screens/private/program/Notes/component/NoteList.tsx b/src/screens/private/program/Notes/component/NoteList.tsx new file mode 100644 index 0000000..90cd3c0 --- /dev/null +++ b/src/screens/private/program/Notes/component/NoteList.tsx @@ -0,0 +1,151 @@ +import { Box, Text } from '@chakra-ui/react'; +import { Note } from '@appStore'; +import NoteCard from './NoteCard'; +import moment from 'moment'; +import { + darkModeColorSelector, + settingsStore, + useShallow, +} from '@settingsStore'; + +// ─── Props ─────────────────────────────────────────────────────────────────── + +interface NoteListProps { + notesList: Note[]; + selectedId: string; + searchQuery: string; + onSelect: (id: string) => void; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +const NoteList = ({ + notesList, + selectedId, + searchQuery, + onSelect, +}: NoteListProps) => { + const { mainColor, textColor } = settingsStore( + useShallow(darkModeColorSelector), + ); + const isDark = settingsStore((state) => state.Display.darkMode); + + const filtered = searchQuery.trim() + ? notesList.filter( + (n) => + n.title.toLowerCase().includes(searchQuery.toLowerCase()) || + n.description.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : notesList; + + // Split pinned and others + const pinnedNotes = filtered + .filter((n) => n.pinned) + .sort( + (a, b) => + new Date(b.updatedAt || b.date).getTime() - + new Date(a.updatedAt || a.date).getTime(), + ); + + const otherNotes = filtered + .filter((n) => !n.pinned) + .sort( + (a, b) => + new Date(b.updatedAt || b.date).getTime() - + new Date(a.updatedAt || a.date).getTime(), + ); + + // Group other notes by month + const groupedNotes: Record = {}; + otherNotes.forEach((note) => { + const month = moment(note.updatedAt || note.date).format('MMMM'); + if (!groupedNotes[month]) { + groupedNotes[month] = []; + } + groupedNotes[month].push(note); + }); + + const SectionHeader = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + return ( + + {/* Note list */} + + {filtered.length === 0 && ( + + πŸ—’οΈ + + {searchQuery ? 'No results' : 'No notes yet'} + + + )} + + {/* Pinned Section */} + {pinnedNotes.length > 0 && ( + + Pinned + {pinnedNotes.map((note) => ( + + onSelect(note.id)} + /> + + ))} + + )} + + {/* Other Sections (Monthly) */} + {Object.entries(groupedNotes).map(([month, notesInMonth]) => ( + 0 ? 4 : 2}> + {month} + {notesInMonth.map((note) => ( + + onSelect(note.id)} + /> + + ))} + + ))} + + + ); +}; + +export default NoteList; diff --git a/src/screens/private/program/Notes/component/NoteToolbar.tsx b/src/screens/private/program/Notes/component/NoteToolbar.tsx new file mode 100644 index 0000000..f9f7859 --- /dev/null +++ b/src/screens/private/program/Notes/component/NoteToolbar.tsx @@ -0,0 +1,293 @@ +import { Box, Tooltip, Input, Text } from '@chakra-ui/react'; +import { + darkModeColorSelector, + settingsStore, + useShallow, +} from '@settingsStore'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const ACCENT = '#fbbf24'; + +// ─── Props ─────────────────────────────────────────────────────────────────── + +interface NoteToolbarProps { + hasSelection: boolean; + isPinned: boolean; + onNew: () => void; + onDelete: () => void; + onPin: () => void; + searchQuery: string; + onSearchChange: (val: string) => void; + onToggleSidebar: () => void; + notesCount: number; +} + +// ─── Icon button ───────────────────────────────────────────────────────────── + +const ToolbarBtn = ({ + label, + icon, + onClick, + danger, + active, + disabled, +}: { + label: string; + icon: string | React.ReactNode; + onClick: () => void; + danger?: boolean; + active?: boolean; + disabled?: boolean; +}) => { + const { iconColor } = settingsStore(useShallow(darkModeColorSelector)); + + return ( + + + {icon} + + + ); +}; + +// ─── SVG Icons ───────────────────────────────────────────────────────────── +const SidebarIcon = () => ( + + + + +); + +const ComposeIcon = () => ( + + + + +); + +const SearchIcon = () => ( + + + + +); + +const PinIcon = ({ active }: { active: boolean }) => ( + + + + +); + +// ─── Component ─────────────────────────────────────────────────────────────── + +const NoteToolbar = ({ + hasSelection, + isPinned, + onNew, + onDelete, + onPin, + searchQuery, + onSearchChange, + onToggleSidebar, + notesCount, +}: NoteToolbarProps) => { + const { menuColor, textColor } = settingsStore( + useShallow(darkModeColorSelector), + ); + const isDark = settingsStore((state) => state.Display.darkMode); + + return ( + + {/* Left Area */} + + } + onClick={onToggleSidebar} + /> + + + All iCloud + + + {notesCount} {notesCount === 1 ? 'note' : 'notes'} + + + + + {/* Center Area (Formatting Pill) */} + + {}} /> + } + onClick={onNew} + /> + + + Aa + + } + onClick={() => {}} + /> + {}} /> + {}} /> + {}} /> + {}} /> + + + {/* Right Area */} + + } + onClick={onPin} + disabled={!hasSelection} + active={isPinned} + /> + {}} /> + + + + + + onSearchChange(e.target.value)} + variant="unstyled" + fontSize="13px" + h="auto" + color={textColor} + _placeholder={{ + color: isDark ? 'rgba(255,255,255,0.4)' : '#8e8e93', + }} + /> + + + + ); +}; + +export default NoteToolbar; diff --git a/src/screens/private/program/Notes/component/__tests__/NoteEditor.test.tsx b/src/screens/private/program/Notes/component/__tests__/NoteEditor.test.tsx new file mode 100644 index 0000000..2477685 --- /dev/null +++ b/src/screens/private/program/Notes/component/__tests__/NoteEditor.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import NoteEditor from '../NoteEditor'; +import { Note } from '@appStore'; +import React from 'react'; + +jest.useFakeTimers(); + +describe('NoteEditor', () => { + const mockOnUpdate = jest.fn(); + const mockNote: Note = { + id: '1', + title: 'Test Note', + description: 'Test description', + content: '
Test Note
Test description
', + updatedAt: '2020-05-14T11:01:58.135Z', + pinned: false, + date: '14th May 2020', + }; + + beforeEach(() => { + mockOnUpdate.mockClear(); + document.execCommand = jest.fn(); + }); + + it('should render empty state when no note is provided', () => { + render(); + expect(screen.getByText('Select or create a note')).toBeDefined(); + }); + + it('should render note content when provided', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + expect(editor.innerHTML).toContain('Test Note'); + // Flexible date check + expect(screen.getByText(/2020/)).toBeDefined(); + // Use function matcher for text that might be broken by elements + expect( + screen.getByText((content) => content.includes('words')), + ).toBeDefined(); + }); + + it('should handle input and trigger update with debounce', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + + act(() => { + // Use \n to ensure getTitleFromContent splits correctly in JSDOM + editor.innerText = 'New Title\nNew content'; + // We also need to set innerHTML because handleInput reads it + editor.innerHTML = '
New Title
New content
'; + fireEvent.input(editor); + }); + + // Should not have been called yet due to 500ms debounce + expect(mockOnUpdate).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockOnUpdate).toHaveBeenCalled(); + const updatedNote = mockOnUpdate.mock.calls[0][0]; + // In JSDOM innerText might still be "New TitleNew content" depending on version + // but we can adjust expectation or use a simpler title for testing + expect(updatedNote.title).toMatch(/New Title/); + }); + + it('should handle multiple inputs and debounce correctly', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + + act(() => { + editor.innerHTML = '
Update 1
'; + fireEvent.input(editor); + }); + + act(() => { + jest.advanceTimersByTime(200); + editor.innerHTML = '
Update 2
'; + fireEvent.input(editor); + }); + + act(() => { + jest.advanceTimersByTime(200); + }); + + // Should still not have been called because the second input reset the timer + expect(mockOnUpdate).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockOnUpdate).toHaveBeenCalledTimes(1); + expect(mockOnUpdate.mock.calls[0][0].title).toMatch(/Update 2/); + }); + + it('should handle keyboard shortcuts (metaKey and ctrlKey)', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + + // metaKey (Mac) + fireEvent.keyDown(editor, { key: 'b', metaKey: true }); + expect(document.execCommand).toHaveBeenCalledWith('bold'); + + // ctrlKey (Windows/Linux) + fireEvent.keyDown(editor, { key: 'i', ctrlKey: true }); + expect(document.execCommand).toHaveBeenCalledWith('italic'); + + fireEvent.keyDown(editor, { key: 'u', metaKey: true }); + expect(document.execCommand).toHaveBeenCalledWith('underline'); + }); + + it('should not call execCommand for other keys or without modifier', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + + fireEvent.keyDown(editor, { key: 'b' }); // No modifier + expect(document.execCommand).not.toHaveBeenCalled(); + + fireEvent.keyDown(editor, { key: 'x', metaKey: true }); // Unsupported key + expect(document.execCommand).not.toHaveBeenCalled(); + }); + + it('should focus editor when container is clicked', () => { + render(); + const editor = screen.getByLabelText('note-editor'); + const spy = jest.spyOn(editor, 'focus'); + + fireEvent.click(editor.parentElement!); + expect(spy).toHaveBeenCalled(); + }); + + it('should render mock content for special note title', async () => { + const specialNote: Note = { + ...mockNote, + id: 'special-note-id', // Use unique ID to avoid ref collision + title: 'Teaching Holistic Health πŸ§˜β€β™€οΈ', + content: '', + }; + + act(() => { + render(); + }); + + const editor = screen.getByLabelText('note-editor'); + expect(editor.innerHTML).toContain('πŸ§˜β€β™€οΈ'); + expect(mockOnUpdate).toHaveBeenCalled(); + }); +}); diff --git a/src/screens/private/program/Notes/component/__tests__/NoteFormatBar.test.tsx b/src/screens/private/program/Notes/component/__tests__/NoteFormatBar.test.tsx new file mode 100644 index 0000000..54eb187 --- /dev/null +++ b/src/screens/private/program/Notes/component/__tests__/NoteFormatBar.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import NoteFormatBar from '../NoteFormatBar'; +import React from 'react'; + +describe('NoteFormatBar', () => { + let editorRef: { current: HTMLDivElement }; + + beforeEach(() => { + editorRef = { current: document.createElement('div') }; + document.execCommand = jest.fn(); + document.queryCommandState = jest.fn().mockReturnValue(false); + + // Mock getSelection + window.getSelection = jest.fn().mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => ({ + commonAncestorContainer: editorRef.current, + getBoundingClientRect: () => ({ + top: 100, + left: 100, + width: 50, + }), + }), + } as any); + }); + + it('should not render when there is no selection', () => { + window.getSelection = jest.fn().mockReturnValue(null); + const { container } = render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + expect(container.firstChild).toBeNull(); + }); + + it('should not render when selection is outside editor', () => { + window.getSelection = jest.fn().mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => ({ + commonAncestorContainer: document.createElement('div'), + getBoundingClientRect: () => ({ top: 0, left: 0, width: 0 }), + }), + } as any); + + const { container } = render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + expect(container.firstChild).toBeNull(); + }); + + it('should render when there is a selection in the editor', () => { + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + expect(screen.getByLabelText('Bold (⌘B)')).toBeDefined(); + expect(screen.getByLabelText('Italic (⌘I)')).toBeDefined(); + expect(screen.getByLabelText('Underline (⌘U)')).toBeDefined(); + }); + + it('should call execCommand when a format button is clicked', () => { + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + fireEvent.click(screen.getByLabelText('Bold (⌘B)')); + expect(document.execCommand).toHaveBeenCalledWith('bold', false, undefined); + + fireEvent.click(screen.getByLabelText('Italic (⌘I)')); + expect(document.execCommand).toHaveBeenCalledWith( + 'italic', + false, + undefined, + ); + + fireEvent.click(screen.getByLabelText('Underline (⌘U)')); + expect(document.execCommand).toHaveBeenCalledWith( + 'underline', + false, + undefined, + ); + }); + + it('should call execCommand with values for block formatting', () => { + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + fireEvent.click(screen.getByLabelText('Heading')); + expect(document.execCommand).toHaveBeenCalledWith( + 'formatBlock', + false, + 'h2', + ); + + fireEvent.click(screen.getByLabelText('Body text')); + expect(document.execCommand).toHaveBeenCalledWith( + 'formatBlock', + false, + 'div', + ); + }); + + it('should call execCommand for lists', () => { + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + fireEvent.click(screen.getByLabelText('Bullet list')); + expect(document.execCommand).toHaveBeenCalledWith( + 'insertUnorderedList', + false, + undefined, + ); + + fireEvent.click(screen.getByLabelText('Numbered list')); + expect(document.execCommand).toHaveBeenCalledWith( + 'insertOrderedList', + false, + undefined, + ); + }); + + it('should show active state for formats', () => { + document.queryCommandState = jest + .fn() + .mockImplementation((cmd) => cmd === 'bold'); + + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + const boldBtn = screen.getByLabelText('Bold (⌘B)'); + // We can't easily check color in JSDOM style objects if they are handled by Chakra/emotion + // but the test confirms the logic branch is hit. + expect(boldBtn).toBeDefined(); + }); + + it('should prevent default on mouse down', () => { + render(); + + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + const bar = screen.getByLabelText('Bold (⌘B)').parentElement!; + const event = fireEvent.mouseDown(bar); + expect(event).toBe(false); // fireEvent returns false if preventDefault was called + }); +}); diff --git a/src/screens/private/program/Notes/utils.ts b/src/screens/private/program/Notes/utils.ts index dfca82f..7385632 100644 --- a/src/screens/private/program/Notes/utils.ts +++ b/src/screens/private/program/Notes/utils.ts @@ -1,9 +1,43 @@ -const getFirstLineFromString = (str: string) => { - return str.split('\n')[0]; +// ─── Note ID ────────────────────────────────────────────────────────────────── + +const generateNoteId = (): string => Date.now().toString(); + +// ─── Content helpers ────────────────────────────────────────────────────────── + +const getFirstLineFromString = (str: string) => str.split('\n')[0]; + +const getAllStringExceptFirstLine = (str: string) => + str.split('\n').slice(1).join('\n'); + +/** Extract plain-text title from note HTML content */ +const getTitleFromContent = (html: string): string => { + const div = document.createElement('div'); + div.innerHTML = html; + const text = div.innerText || div.textContent || ''; + return text.split('\n')[0].trim() || 'New Note'; +}; + +/** Extract plain-text preview (everything after the first line) */ +const getPreviewFromContent = (html: string): string => { + const div = document.createElement('div'); + div.innerHTML = html; + const text = div.innerText || div.textContent || ''; + return text.split('\n').slice(1).join(' ').trim().slice(0, 120); }; -const getAllStringExceptFirstLine = (str: string) => { - return str.split('\n').slice(1).join('\n'); +/** Count words in HTML content */ +const countWords = (html: string): number => { + const div = document.createElement('div'); + div.innerHTML = html; + const text = (div.innerText || div.textContent || '').trim(); + return text ? text.split(/\s+/).filter(Boolean).length : 0; }; -export { getFirstLineFromString, getAllStringExceptFirstLine }; +export { + generateNoteId, + getFirstLineFromString, + getAllStringExceptFirstLine, + getTitleFromContent, + getPreviewFromContent, + countWords, +}; diff --git a/src/store/appStore/selector/Notes/Notes.selector.ts b/src/store/appStore/selector/Notes/Notes.selector.ts index ab04587..e52ec34 100644 --- a/src/store/appStore/selector/Notes/Notes.selector.ts +++ b/src/store/appStore/selector/Notes/Notes.selector.ts @@ -1,12 +1,29 @@ import { AppStoreState } from '../../appStore'; -const notesSelector = (state: AppStoreState) => ({ - notes: state.Notes.notes, - getCurrentId: () => Object.keys(state.Notes.notes).length, - selectedNote: (id: string) => state.Notes.notes[id], - addNote: state.Notes.addNote, - deleteNote: state.Notes.deleteNote, - editNote: state.Notes.editNote, -}); +const notesSelector = (state: AppStoreState) => { + const notesList = Object.values(state.Notes.notes); + + // Pinned notes first, then both groups sorted by updatedAt desc + const byDate = ( + a: { updatedAt?: string; date: string }, + b: { updatedAt?: string; date: string }, + ) => + new Date(b.updatedAt ?? b.date).getTime() - + new Date(a.updatedAt ?? a.date).getTime(); + + const pinned = notesList.filter((n) => n.pinned).sort(byDate); + const unpinned = notesList.filter((n) => !n.pinned).sort(byDate); + + return { + notes: state.Notes.notes, + notesList: [...pinned, ...unpinned], + getCurrentId: () => Object.keys(state.Notes.notes).length, + selectedNote: (id: string) => state.Notes.notes[id], + addNote: state.Notes.addNote, + deleteNote: state.Notes.deleteNote, + editNote: state.Notes.editNote, + pinNote: state.Notes.pinNote, + }; +}; export { notesSelector }; diff --git a/src/store/appStore/selector/Notes/__tests__/Notes.selector.test.ts b/src/store/appStore/selector/Notes/__tests__/Notes.selector.test.ts index 7bf1804..ac29e94 100644 --- a/src/store/appStore/selector/Notes/__tests__/Notes.selector.test.ts +++ b/src/store/appStore/selector/Notes/__tests__/Notes.selector.test.ts @@ -1,126 +1,100 @@ import { renderHook } from '@testing-library/react-hooks'; import { appStore } from '../../../appStore'; import { notesSelector } from '../Notes.selector'; +import { act } from '@testing-library/react'; describe('Notes selector', () => { - it('should return default notes state', () => { + const setupNotes = (count = 6) => { const { result } = renderHook(() => appStore(notesSelector)); + act(() => { + for (let i = 1; i <= count; i++) { + result.current.addNote({ + id: `note-${i}`, + title: `Note ${i}`, + description: `Description ${i}`, + content: `
Note ${i}
`, + date: new Date().toISOString(), + updatedAt: new Date().toISOString(), + pinned: false, + }); + } + }); + return result; + }; - expect(result.current.notes).toEqual({}); + it('should return empty default notes state', () => { + const { result } = renderHook(() => appStore(notesSelector)); + expect(Object.keys(result.current.notes).length).toBe(0); }); - it('should return default current id', () => { + it('should return 0 for default current count', () => { const { result } = renderHook(() => appStore(notesSelector)); - expect(result.current.getCurrentId()).toBe(0); }); it('should add note', () => { const { result } = renderHook(() => appStore(notesSelector)); - result.current.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + act(() => { + result.current.addNote({ + id: 'test-1', + title: 'Title', + description: 'Description', + content: '
Content
', + date: '2021-09-01', + updatedAt: '2021-09-01', + pinned: false, + }); }); - expect(result.current.selectedNote('1')).toEqual({ - id: '1', + expect(result.current.selectedNote('test-1')).toMatchObject({ + id: 'test-1', title: 'Title', - description: 'Description', - date: '2021-09-01', }); }); it('should delete note', () => { - const { result } = renderHook(() => appStore(notesSelector)); + const result = setupNotes(1); - result.current.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + act(() => { + result.current.deleteNote('note-1'); }); - result.current.deleteNote('1'); - - expect(result.current.selectedNote('1')).toBeUndefined(); + expect(result.current.selectedNote('note-1')).toBeUndefined(); }); it('should edit note', () => { - const { result } = renderHook(() => appStore(notesSelector)); - - result.current.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }); - - result.current.editNote({ - id: '1', - title: 'New Title', - description: 'New Description', - date: '2021-09-02', + const result = setupNotes(1); + + act(() => { + result.current.editNote({ + id: 'note-1', + title: 'New Title', + description: 'New Description', + content: '
New Content
', + date: '2021-09-01', + updatedAt: '2021-09-02', + pinned: false, + }); }); - expect(result.current.selectedNote('1')).toEqual({ - id: '1', - title: 'New Title', - description: 'New Description', - date: '2021-09-02', - }); + expect(result.current.selectedNote('note-1').title).toBe('New Title'); }); it('should return selected note', () => { - const { result } = renderHook(() => appStore(notesSelector)); + const result = setupNotes(1); - result.current.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }); - - expect(result.current.selectedNote('1')).toEqual({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + expect(result.current.selectedNote('note-1')).toMatchObject({ + id: 'note-1', + title: 'Note 1', }); }); it('should return all notes', () => { - const { result } = renderHook(() => appStore(notesSelector)); - - result.current.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }); - - result.current.addNote({ - id: '2', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }); + const result = setupNotes(2); - expect(result.current.notes).toEqual({ - '1': { - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }, - '2': { - id: '2', - title: 'Title', - description: 'Description', - date: '2021-09-01', - }, - }); + expect(result.current.notes['note-1']).toBeDefined(); + expect(result.current.notes['note-2']).toBeDefined(); + expect(Object.keys(result.current.notes).length).toBe(2); }); }); diff --git a/src/store/appStore/slice/Notes/Notes.slice.ts b/src/store/appStore/slice/Notes/Notes.slice.ts index 51a4995..25f66f6 100644 --- a/src/store/appStore/slice/Notes/Notes.slice.ts +++ b/src/store/appStore/slice/Notes/Notes.slice.ts @@ -19,6 +19,12 @@ const createNotesSlice: AppStoreSlice = (set) => ({ set((state) => { state.Notes.notes[newNote.id] = newNote; }), + pinNote: (id) => + set((state) => { + if (state.Notes.notes[id]) { + state.Notes.notes[id].pinned = !state.Notes.notes[id].pinned; + } + }), }); export default createNotesSlice; diff --git a/src/store/appStore/slice/Notes/__tests__/Notes.slice.test.ts b/src/store/appStore/slice/Notes/__tests__/Notes.slice.test.ts index d258ba1..52d35a4 100644 --- a/src/store/appStore/slice/Notes/__tests__/Notes.slice.test.ts +++ b/src/store/appStore/slice/Notes/__tests__/Notes.slice.test.ts @@ -1,68 +1,112 @@ import { renderHook } from '@testing-library/react-hooks'; import { appStore } from '../../../appStore'; +import { act } from '@testing-library/react'; describe('Notes slice', () => { - it('should return default notes state', () => { + const setupNotes = (count = 6) => { const { result } = renderHook(() => appStore()); + act(() => { + for (let i = 1; i <= count; i++) { + result.current.Notes.addNote({ + id: `note-${i}`, + title: `Note ${i}`, + description: `Description ${i}`, + content: `
Note ${i}
`, + date: new Date().toISOString(), + updatedAt: new Date().toISOString(), + pinned: false, + }); + } + }); + return result; + }; - expect(result.current.Notes.notes).toEqual({}); + it('should return default notes state (empty)', () => { + const { result } = renderHook(() => appStore()); + expect(Object.keys(result.current.Notes.notes).length).toBe(0); }); it('should add note', () => { const { result } = renderHook(() => appStore()); - result.current.Notes.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + act(() => { + result.current.Notes.addNote({ + id: 'test-1', + title: 'Title', + description: 'Description', + content: '
Content
', + date: '2021-09-01', + updatedAt: '2021-09-01', + pinned: false, + }); }); - expect(result.current.Notes.notes['1']).toEqual({ - id: '1', + expect(result.current.Notes.notes['test-1']).toMatchObject({ + id: 'test-1', title: 'Title', - description: 'Description', - date: '2021-09-01', }); }); it('should delete note', () => { - const { result } = renderHook(() => appStore()); + const result = setupNotes(1); - result.current.Notes.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + act(() => { + result.current.Notes.deleteNote('note-1'); }); - result.current.Notes.deleteNote('1'); + expect(result.current.Notes.notes['note-1']).toBeUndefined(); + }); - expect(result.current.Notes.notes['1']).toBeUndefined(); + it('should not throw when deleting non-existent note', () => { + const { result } = renderHook(() => appStore()); + expect(() => { + act(() => { + result.current.Notes.deleteNote('non-existent'); + }); + }).not.toThrow(); }); it('should edit note', () => { - const { result } = renderHook(() => appStore()); + const result = setupNotes(1); - result.current.Notes.addNote({ - id: '1', - title: 'Title', - description: 'Description', - date: '2021-09-01', + act(() => { + result.current.Notes.editNote({ + id: 'note-1', + title: 'New Title', + description: 'New Description', + content: '
New Content
', + date: '2021-09-01', + updatedAt: '2021-09-02', + pinned: false, + }); }); - result.current.Notes.editNote({ - id: '1', - title: 'New Title', - description: 'New Description', - date: '2021-09-02', + expect(result.current.Notes.notes['note-1'].title).toBe('New Title'); + }); + + it('should toggle pin note', () => { + const result = setupNotes(1); + + // note-1 is initially not pinned + expect(result.current.Notes.notes['note-1'].pinned).toBe(false); + + act(() => { + result.current.Notes.pinNote('note-1'); }); + expect(result.current.Notes.notes['note-1'].pinned).toBe(true); - expect(result.current.Notes.notes['1']).toEqual({ - id: '1', - title: 'New Title', - description: 'New Description', - date: '2021-09-02', + act(() => { + result.current.Notes.pinNote('note-1'); }); + expect(result.current.Notes.notes['note-1'].pinned).toBe(false); + }); + + it('should not throw when pinning non-existent note', () => { + const { result } = renderHook(() => appStore()); + expect(() => { + act(() => { + result.current.Notes.pinNote('non-existent'); + }); + }).not.toThrow(); }); }); diff --git a/src/store/appStore/slice/Notes/types.ts b/src/store/appStore/slice/Notes/types.ts index e4135e8..48e83b5 100644 --- a/src/store/appStore/slice/Notes/types.ts +++ b/src/store/appStore/slice/Notes/types.ts @@ -1,8 +1,11 @@ export type Note = { id: string; title: string; - description: string; - date: string; + description: string; // preview snippet (backward compat) + date: string; // creation date + updatedAt: string; // last modification ISO string + content: string; // HTML content of the editor + pinned: boolean; }; export type NotesState = { @@ -13,6 +16,7 @@ export interface NotesAppAction { addNote: (note: Note) => void; deleteNote: (id: string) => void; editNote: (note: Note) => void; + pinNote: (id: string) => void; } export type NotesStateSlice = NotesState & NotesAppAction;