From 423e6c59ddb635a6f765bd97f1253d868fe9c11c Mon Sep 17 00:00:00 2001 From: Stefan du Fresne Date: Mon, 19 Jan 2026 11:50:39 +0000 Subject: [PATCH 1/3] Convert remaining JavaScript files to TypeScript - Convert source files: About.js, RepeatableListItem.jsx, TemplateListItem.jsx - Convert test files: TemplateListItem.test.jsx, Repeatable.test.jsx - Convert config files: vitest.config.js (client/server), inject-manifest.mjs - Remove legacy jest.config.js (project uses Vitest) - Add MockDatabase interface to db mock for better test typing - Add RepeatableListItemProps interface with type guard for template prop Co-Authored-By: Claude Opus 4.5 --- jest.config.js | 23 ----- package.json | 6 +- src/client/App.test.tsx | 2 +- src/client/db/__mocks__/index.ts | 19 +++- .../Repeatable/RepeatableListItem.jsx | 58 ----------- .../Repeatable/RepeatableListItem.test.tsx | 24 ++--- .../Repeatable/RepeatableListItem.tsx | 95 +++++++++++++++++++ .../features/Repeatable/RepeatableSlug.tsx | 14 +-- ...tem.test.jsx => TemplateListItem.test.tsx} | 3 +- ...plateListItem.jsx => TemplateListItem.tsx} | 7 +- src/client/pages/{About.js => About.tsx} | 22 +++-- src/client/pages/Home.test.tsx | 43 ++++++--- ...epeatable.test.jsx => Repeatable.test.tsx} | 94 ++++++++++-------- src/client/pages/Template.tsx | 19 ++-- .../{vitest.config.js => vitest.config.ts} | 0 .../{vitest.config.js => vitest.config.ts} | 0 src/shared/types.ts | 32 ++++++- 17 files changed, 276 insertions(+), 185 deletions(-) delete mode 100644 jest.config.js delete mode 100644 src/client/features/Repeatable/RepeatableListItem.jsx create mode 100644 src/client/features/Repeatable/RepeatableListItem.tsx rename src/client/features/Template/{TemplateListItem.test.jsx => TemplateListItem.test.tsx} (83%) rename src/client/features/Template/{TemplateListItem.jsx => TemplateListItem.tsx} (90%) rename src/client/pages/{About.js => About.tsx} (82%) rename src/client/pages/{Repeatable.test.jsx => Repeatable.test.tsx} (68%) rename src/client/{vitest.config.js => vitest.config.ts} (100%) rename src/server/{vitest.config.js => vitest.config.ts} (100%) diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 19f06a3..0000000 --- a/jest.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import { TextDecoder, TextEncoder } from 'node:util'; - -export default { - verbose: true, - globals: { - TextDecoder, - TextEncoder, - }, - clearMocks: true, - setupFilesAfterEnv: ['/setupTests.ts'], - moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], - moduleDirectories: ['node_modules', 'src'], - // moduleNameMapper: { - // '\\.(css|less|scss)$': 'identity-obj-proxy', - // }, - moduleNameMapper: { - axios: 'axios/dist/node/axios.cjs', - 'react-markdown': 'react-markdown/react-markdown.min.js', - }, - transform: { - '\\.[jt]sx?$': 'babel-jest', - }, -}; diff --git a/package.json b/package.json index 8670379..830b32f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,6 @@ "version": "0.5.0", "packageManager": "yarn@4.0.2", "//": [ - " TODO update everything else thing by thing", - " TODO migrate off heroku (fly.io?)", " TODO redux -> https://github.com/pmndrs/zustand" ], "scripts": { @@ -19,8 +17,8 @@ "build:server": "parcel build --target server --reporter @parcel/reporter-bundle-analyzer", "build": "yarn clean && parcel build --reporter @parcel/reporter-bundle-analyzer && node ./scripts/inject-manifest.mjs", "dev": "parcel --target client", - "test:client": "vitest --config src/client/vitest.config.js src/client", - "test:server": "vitest --config src/server/vitest.config.js src/server", + "test:client": "vitest --config src/client/vitest.config.ts src/client", + "test:server": "vitest --config src/server/vitest.config.ts src/server", "test": "yarn test:client --run && yarn test:server --run" }, "targets": { diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx index eeceb82..44fb927 100644 --- a/src/client/App.test.tsx +++ b/src/client/App.test.tsx @@ -140,7 +140,7 @@ describe('App Routing', () => { title: 'Test Template', markdown: 'Test markdown', values: [], - slug: { type: SlugType.Date, placeholder: '' }, + slug: { type: SlugType.Date }, created: Date.now(), updated: Date.now(), versioned: Date.now(), diff --git a/src/client/db/__mocks__/index.ts b/src/client/db/__mocks__/index.ts index cc2c1e5..0f5ff60 100644 --- a/src/client/db/__mocks__/index.ts +++ b/src/client/db/__mocks__/index.ts @@ -1,11 +1,26 @@ -import { vi } from 'vitest'; +import { type Mock, vi } from 'vitest'; +import type { Doc } from '../../../shared/types'; -const mockDb = { +export interface MockDatabase { + find: Mock<(options?: unknown) => Promise<{ docs: unknown[] }>>; + get: Mock<(docId: string) => Promise>; + userPut: Mock<(doc: Doc) => Promise>; +} + +const mockDb: MockDatabase = { find: vi.fn(), get: vi.fn(), userPut: vi.fn(), }; +/** + * Get the mock database handle. Use this in tests after vi.mock('../db'). + * Returns the same mock instance regardless of the user argument. + */ +export function getMockDb(): MockDatabase { + return mockDb; +} + const db = () => mockDb; export default db; diff --git a/src/client/features/Repeatable/RepeatableListItem.jsx b/src/client/features/Repeatable/RepeatableListItem.jsx deleted file mode 100644 index 2ee1a88..0000000 --- a/src/client/features/Repeatable/RepeatableListItem.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Link, ListItem, ListItemButton, ListItemText } from '@mui/material'; -import React from 'react'; -import { useNavigate } from 'react-router-dom'; -import RelativeTime from '../../components/RelativeTime'; - -/** - * Display an instance of a repeatable in a list - * - * @param {string} _id id of the document - * @param {object} slug value of the slug - * @param {timestamp} timestamp timestamp you want to display and sort by - * @param {object} template the template this repeatable uses. Specific needs below - * @param {string} template.title title of the repeatable - * @param {string} template.slug.type datatype of the slug - */ -function RepeatableListItem(props) { - const navigate = useNavigate(); - const { _id, slug, timestamp, template } = props; - const { - title, - slug: { type }, - } = template || { slug: {} }; // weird reloading edge cases can sometimes generate calls to empty items - - let displaySlug; - if (type === 'string') { - displaySlug = slug; - } else if (type === 'url') { - displaySlug = ( - - {slug} - - ); - } else if (type === 'date') { - displaySlug = new Date(slug).toLocaleDateString(); - } else if (type === 'timestamp') { - displaySlug = new Date(slug).toLocaleString(); - } - - return ( - - { - // To let URL slugs (displayed inside this "button") have links that don't also trigger - // this navigation - if (e.target.nodeName !== 'A') { - navigate(`/repeatable/${_id}`); - } - }} - > - } /> - {displaySlug} - - - ); -} - -export default React.memo(RepeatableListItem); diff --git a/src/client/features/Repeatable/RepeatableListItem.test.tsx b/src/client/features/Repeatable/RepeatableListItem.test.tsx index 9e60565..71850f3 100644 --- a/src/client/features/Repeatable/RepeatableListItem.test.tsx +++ b/src/client/features/Repeatable/RepeatableListItem.test.tsx @@ -3,8 +3,9 @@ import userEvent from '@testing-library/user-event'; import { type NavigateFunction, useNavigate } from 'react-router-dom'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { SlugType } from '../../../shared/types'; import { render } from '../../test-utils'; -import RepeatableListItem from './RepeatableListItem'; +import RepeatableListItem, { type RepeatableListItemProps } from './RepeatableListItem'; vi.mock('react-router-dom'); @@ -18,15 +19,14 @@ describe('Repeatable', () => { }); it('renders without crashing', async () => { - const params = { + const params: RepeatableListItemProps = { _id: 'abc', timestamp: Date.now(), slug: 'http://example.com', template: { title: 'Checklist ListItem', slug: { - type: 'url', - label: 'For', + type: SlugType.URL, }, }, }; @@ -38,14 +38,14 @@ describe('Repeatable', () => { }); it('url slug renders (and does not effect slug click)', async () => { - const params = { + const params: RepeatableListItemProps = { _id: 'abc', timestamp: Date.now(), slug: 'http://example.com', template: { title: 'URL Test', slug: { - type: 'url', + type: SlugType.URL, }, }, }; @@ -58,14 +58,14 @@ describe('Repeatable', () => { }); it('date slug', async () => { - const params = { + const params: RepeatableListItemProps = { _id: 'abc', timestamp: Date.now(), slug: new Date(2020, 0, 1).getTime(), template: { title: 'Date test', slug: { - type: 'date', + type: SlugType.Date, }, }, }; @@ -77,14 +77,14 @@ describe('Repeatable', () => { }); it('timestamp slug', async () => { - const params = { + const params: RepeatableListItemProps = { _id: 'abc', timestamp: Date.now(), slug: new Date(2020, 0, 1, 10, 20).getTime(), template: { title: 'Timestamp test', slug: { - type: 'timestamp', + type: SlugType.Timestamp, }, }, }; @@ -95,14 +95,14 @@ describe('Repeatable', () => { expect(screen.getByText(expectedTimestamp)).toBeTruthy(); }); it('plain text slug', async () => { - const params = { + const params: RepeatableListItemProps = { _id: 'abc', timestamp: Date.now(), slug: 'some text for you', template: { title: 'Plain Text test', slug: { - type: 'string', + type: SlugType.String, }, }, }; diff --git a/src/client/features/Repeatable/RepeatableListItem.tsx b/src/client/features/Repeatable/RepeatableListItem.tsx new file mode 100644 index 0000000..ea9b84c --- /dev/null +++ b/src/client/features/Repeatable/RepeatableListItem.tsx @@ -0,0 +1,95 @@ +import { Link, ListItem, ListItemButton, ListItemText } from '@mui/material'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { type SlugConfig, type SlugData, SlugType, type TemplateDoc } from '../../../shared/types'; +import RelativeTime from '../../components/RelativeTime'; + +type TemplateInfo = Pick; + +export interface RepeatableListItemProps { + _id: string; + slug: string | number | undefined; + timestamp?: number; + // template can be string during loading, but should be TemplateInfo when rendered + template: string | TemplateInfo | undefined; +} + +function isTemplateInfo(template: string | TemplateInfo | undefined): template is TemplateInfo { + return typeof template === 'object' && template !== null; +} + +/** + * Construct a type-safe SlugData from config and value. + * Returns undefined if the value type doesn't match the config type. + */ +function toSlugData(config: SlugConfig, value: string | number | undefined): SlugData | undefined { + if (value === undefined) return undefined; + + switch (config.type) { + case SlugType.String: + return typeof value === 'string' + ? { type: SlugType.String, placeholder: config.placeholder, value } + : undefined; + case SlugType.URL: + return typeof value === 'string' + ? { type: SlugType.URL, placeholder: config.placeholder, value } + : undefined; + case SlugType.Date: + return typeof value === 'number' ? { type: SlugType.Date, value } : undefined; + case SlugType.Timestamp: + return typeof value === 'number' ? { type: SlugType.Timestamp, value } : undefined; + } +} + +function renderSlug(slugData: SlugData) { + switch (slugData.type) { + case SlugType.String: + return slugData.value; + case SlugType.URL: + return ( + + {slugData.value} + + ); + case SlugType.Date: + return new Date(slugData.value).toLocaleDateString(); + case SlugType.Timestamp: + return new Date(slugData.value).toLocaleString(); + } +} + +/** + * Display an instance of a repeatable in a list + */ +function RepeatableListItem(props: RepeatableListItemProps) { + const navigate = useNavigate(); + const { _id, slug, timestamp, template } = props; + + // Handle case where template is still a string (loading) or undefined + const templateObj = isTemplateInfo(template) ? template : undefined; + const slugData = templateObj ? toSlugData(templateObj.slug, slug) : undefined; + const displaySlug = slugData ? renderSlug(slugData) : null; + + return ( + + { + // To let URL slugs (displayed inside this "button") have links that don't also trigger + // this navigation + if ((e.target as HTMLElement).nodeName !== 'A') { + navigate(`/repeatable/${_id}`); + } + }} + > + } + /> + {displaySlug} + + + ); +} + +export default React.memo(RepeatableListItem); diff --git a/src/client/features/Repeatable/RepeatableSlug.tsx b/src/client/features/Repeatable/RepeatableSlug.tsx index e130f21..db451ae 100644 --- a/src/client/features/Repeatable/RepeatableSlug.tsx +++ b/src/client/features/Repeatable/RepeatableSlug.tsx @@ -1,6 +1,6 @@ import { Input } from '@mui/material'; import { type ChangeEvent, useState } from 'react'; -import type { RepeatableDoc, TemplateDoc } from '../../../shared/types'; +import { type RepeatableDoc, SlugType, type TemplateDoc } from '../../../shared/types'; import db from '../../db'; import { setRepeatable } from '../../state/docsSlice'; import { type RootState, useDispatch, useSelector } from '../../store'; @@ -25,9 +25,11 @@ function RepeatableSlug() { // @ts-expect-error FIXME: check if nodeValue works const targetValue = target.value; // we know that if anyone calls this function template has a value - const value = ['date', 'timestamp'].includes((template as TemplateDoc).slug.type) - ? new Date(targetValue).getTime() - : targetValue; + const slugType = (template as TemplateDoc).slug.type; + const value = + slugType === SlugType.Date || slugType === SlugType.Timestamp + ? new Date(targetValue).getTime() + : targetValue; setSlug(value); } @@ -47,7 +49,7 @@ function RepeatableSlug() { } let slugInput: React.ReactElement; - if (['url', 'string'].includes(template.slug.type)) { + if (template.slug.type === SlugType.URL || template.slug.type === SlugType.String) { slugInput = ( ); - } else if ('date' === template.slug.type) { + } else if (template.slug.type === SlugType.Date) { // FIXME: Clean This Up! The format required for the native date input type cannot // be manufactured from the native JavaScript date type. If we were in raw HTML // we could post-set it with Javascript by using valueAsNumber, but not in situ diff --git a/src/client/features/Template/TemplateListItem.test.jsx b/src/client/features/Template/TemplateListItem.test.tsx similarity index 83% rename from src/client/features/Template/TemplateListItem.test.jsx rename to src/client/features/Template/TemplateListItem.test.tsx index 6d0d92d..4ab2a24 100644 --- a/src/client/features/Template/TemplateListItem.test.jsx +++ b/src/client/features/Template/TemplateListItem.test.tsx @@ -1,7 +1,6 @@ import { screen } from '@testing-library/react'; -// biome-ignore lint/correctness/noUnusedImports: React is required for JSX transform in .jsx files -import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { expect, test } from 'vitest'; import { render } from '../../test-utils'; import TemplateListItem from './TemplateListItem'; diff --git a/src/client/features/Template/TemplateListItem.jsx b/src/client/features/Template/TemplateListItem.tsx similarity index 90% rename from src/client/features/Template/TemplateListItem.jsx rename to src/client/features/Template/TemplateListItem.tsx index 9fc3bbf..86809ea 100644 --- a/src/client/features/Template/TemplateListItem.jsx +++ b/src/client/features/Template/TemplateListItem.tsx @@ -9,7 +9,12 @@ const horizontal = { width: 'auto', }; -function TemplateListItem(props) { +interface TemplateListItemProps { + _id?: string; + title?: string; +} + +function TemplateListItem(props: TemplateListItemProps) { const { _id, title } = props; if (!_id) { diff --git a/src/client/pages/About.js b/src/client/pages/About.tsx similarity index 82% rename from src/client/pages/About.js rename to src/client/pages/About.tsx index b5fc986..29a7197 100644 --- a/src/client/pages/About.js +++ b/src/client/pages/About.tsx @@ -1,6 +1,6 @@ import { Link } from '@mui/material'; import axios from 'axios'; -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, type ReactNode, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import db from '../db'; @@ -10,10 +10,12 @@ import SyncPanel from '../features/Sync/SyncPanel'; import UpdatePanel from '../features/Update/UpdatePanel'; import { useSelector } from '../store'; -function mapProps(parent, info) { +type InfoRow = [ReactNode, ReactNode?]; + +function mapProps(parent: string, info: Record): InfoRow[] { return Object.keys(info) .sort() - .map((k) => [`${parent}.${k}`, info[k]]); + .map((k) => [`${parent}.${k}`, String(info[k])]); } function About() { @@ -23,11 +25,13 @@ function About() { // eslint-disable-next-line no-unused-vars const handle = db(loggedInUser); // pull it in to force the caching to happen if this is a fresh refresh - const [idbInfo, setIdbInfo] = useState([]); - const [serverInfo, setServerInfo] = useState([]); + const [idbInfo, setIdbInfo] = useState([]); + const [serverInfo, setServerInfo] = useState([]); useEffect(() => { - handle.info().then((info) => setIdbInfo(mapProps('db', info))); + handle + .info() + .then((info) => setIdbInfo(mapProps('db', info as unknown as Record))); }, [handle]); useEffect(() => { @@ -41,7 +45,7 @@ function About() { deploy_commit: hash, } = response.data; - const data = [ + const data: InfoRow[] = [ ['Deploy Version', deployVersion], ['Release', releaseVersion], [ @@ -71,9 +75,9 @@ function About() { ); }, [dispatch]); - const vars = [ + const vars: InfoRow[] = [ [

SERVER DETAILS

], - ['Deployment Type', {process.env.NODE_ENV.toUpperCase()}], + ['Deployment Type', {process.env.NODE_ENV?.toUpperCase()}], ...serverInfo, [

LOCAL DETAILS

], ...idbInfo, diff --git a/src/client/pages/Home.test.tsx b/src/client/pages/Home.test.tsx index 5b77c7a..28d8fd4 100644 --- a/src/client/pages/Home.test.tsx +++ b/src/client/pages/Home.test.tsx @@ -2,8 +2,8 @@ import type { Store } from '@reduxjs/toolkit'; import { screen, waitFor } from '@testing-library/react'; import type { JSX } from 'react/jsx-runtime'; import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, describe, expect, it, type Mocked, vi } from 'vitest'; -import db, { type Database } from '../db'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMockDb, type MockDatabase } from '../db/__mocks__'; import { setUserAsLoggedIn } from '../features/User/userSlice'; import { createStore } from '../store'; import { withStore, render as wrappedRender } from '../test-utils'; @@ -11,14 +11,24 @@ import Home from './Home'; vi.mock('../db'); +interface FindOptions { + selector?: { + _id?: { + $gt?: string; + $lt?: string; + $in?: string[]; + }; + }; +} + describe('Home', () => { const user = { id: 1, name: 'Tester Test' }; let store: Store; - let handle: Mocked; + let handle: MockDatabase; beforeEach(() => { store = createStore(); store.dispatch(setUserAsLoggedIn({ user })); - handle = db(user) as Mocked; + handle = getMockDb(); }); function render(children: JSX.Element) { @@ -27,12 +37,13 @@ describe('Home', () => { it('renders without crashing', async () => { handle.find.mockImplementation((options) => { - if (!(options?.selector?._id instanceof Object)) { + const opts = options as FindOptions; + if (!(opts?.selector?._id instanceof Object)) { throw Error('noimplementation'); } // Repeatable list needs - if (options?.selector?._id?.$gt === 'repeatable:instance:') { + if (opts?.selector?._id?.$gt === 'repeatable:instance:') { return Promise.resolve({ docs: [ { @@ -47,8 +58,8 @@ describe('Home', () => { } if ( - options?.selector?._id?.$in?.length === 1 && - options?.selector?._id?.$in[0] === 'repeatable:template:test:1' + opts?.selector?._id?.$in?.length === 1 && + opts?.selector?._id?.$in[0] === 'repeatable:template:test:1' ) { return Promise.resolve({ docs: [ @@ -63,7 +74,7 @@ describe('Home', () => { } // Template list needs - if (options?.selector?._id?.$gt === 'repeatable:template:') { + if (opts?.selector?._id?.$gt === 'repeatable:template:') { return Promise.resolve({ docs: [ { @@ -91,17 +102,18 @@ describe('Home', () => { describe('template visibility', () => { it('when there are more than one version only show the latest', async () => { handle.find.mockImplementation((options) => { - if (!(options?.selector?._id instanceof Object)) { + const opts = options as FindOptions; + if (!(opts?.selector?._id instanceof Object)) { throw Error('noimplementation'); } // No repeatables - if (options?.selector?._id?.$gt === 'repeatable:instance:') { + if (opts?.selector?._id?.$gt === 'repeatable:instance:') { return Promise.resolve({ docs: [] }); } // Multiple versions of the same template - if (options?.selector?._id?.$gt === 'repeatable:template:') { + if (opts?.selector?._id?.$gt === 'repeatable:template:') { return Promise.resolve({ docs: [ { _id: 'repeatable:template:test:1', _rev: '1-abc', title: 'Old template version' }, @@ -129,17 +141,18 @@ describe('Home', () => { it('if the latest version is deleted, do not display any versions of that template', async () => { handle.find.mockImplementation((options) => { - if (!(options?.selector?._id instanceof Object)) { + const opts = options as FindOptions; + if (!(opts?.selector?._id instanceof Object)) { throw Error('noimplementation'); } // No repeatables - if (options?.selector?._id?.$gt === 'repeatable:instance:') { + if (opts?.selector?._id?.$gt === 'repeatable:instance:') { return Promise.resolve({ docs: [] }); } // Multiple versions of the same template - if (options?.selector?._id?.$gt === 'repeatable:template:') { + if (opts?.selector?._id?.$gt === 'repeatable:template:') { return Promise.resolve({ docs: [ { _id: 'repeatable:template:test:1', _rev: '1-abc', title: 'Old template version' }, diff --git a/src/client/pages/Repeatable.test.jsx b/src/client/pages/Repeatable.test.tsx similarity index 68% rename from src/client/pages/Repeatable.test.jsx rename to src/client/pages/Repeatable.test.tsx index 6247c90..03d00b9 100644 --- a/src/client/pages/Repeatable.test.jsx +++ b/src/client/pages/Repeatable.test.tsx @@ -1,34 +1,45 @@ +import type { Store } from '@reduxjs/toolkit'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -// biome-ignore lint/correctness/noUnusedImports: React is required for JSX transform in .jsx files -import React from 'react'; +import type { JSX } from 'react/jsx-runtime'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import db from '../db'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { type RepeatableDoc, SlugType, type TemplateDoc } from '../../shared/types'; +import { getMockDb, type MockDatabase } from '../db/__mocks__'; import { setUserAsLoggedIn } from '../features/User/userSlice'; import { createStore } from '../store'; import { withStore, render as wrappedRender } from '../test-utils'; import Repeatable from './Repeatable'; -vi.mock('react-router-dom'); +vi.mock('react-router-dom', async () => { + return { + useNavigate: vi.fn(), + useLocation: vi.fn(), + useParams: vi.fn(), + }; +}); vi.mock('../db'); +const mockUseNavigate = useNavigate as Mock; +const mockUseLocation = useLocation as Mock; +const mockUseParams = useParams as Mock; + describe('Repeatable', () => { const user = { id: 1, name: 'Tester Test' }; - let navigate; - let store; - let handle; + let navigate: Mock; + let store: Store; + let handle: MockDatabase; beforeEach(() => { navigate = vi.fn(); - useNavigate.mockReturnValue(navigate); + mockUseNavigate.mockReturnValue(navigate); store = createStore(); store.dispatch(setUserAsLoggedIn({ user })); - handle = db(user); + handle = getMockDb(); }); - function render(children) { + function render(children: JSX.Element) { wrappedRender(withStore(store, children)); } @@ -38,14 +49,14 @@ describe('Repeatable', () => { _id: 'repeatable:instance:1234', template: 'repeatable:template:5678', values: [], - }) + } satisfies Partial) .mockResolvedValueOnce({ _id: 'repeatable:template:5678', title: 'A Repeatable', markdown: 'Some text', - }); - useLocation.mockReturnValue(); - useParams.mockReturnValue({ repeatableId: '1234' }); + } satisfies Partial); + mockUseLocation.mockReturnValue(undefined); + mockUseParams.mockReturnValue({ repeatableId: '1234' }); render(); @@ -59,24 +70,24 @@ describe('Repeatable', () => { _id: 'repeatable:template:1234', _rev: '42-abc', slug: { - type: 'string', + type: SlugType.String, }, - }) + } satisfies Partial) .mockResolvedValueOnce({ _id: 'repeatable:instance:1234', _rev: '42-abc', slug: 'test', values: [], - }); - handle.userPut.mockResolvedValue({ id: '4321' }); - useLocation.mockReturnValue({ + } satisfies Partial); + handle.userPut.mockResolvedValue({ _id: '4321' }); + mockUseLocation.mockReturnValue({ search: '?template=repeatable:template:1234', }); - useParams + mockUseParams .mockReturnValueOnce({ repeatableId: 'new' }) .mockReturnValue({ repeatableId: '5678' }); - render(); + render(); expect(handle.get).toBeCalled(); expect(handle.get.mock.calls[0][0]).toBe('repeatable:template:1234'); @@ -85,7 +96,7 @@ describe('Repeatable', () => { expect(navigate.mock.calls[0][0]).toMatch(/\/repeatable\/repeatable:instance:/); expect(handle.userPut).toBeCalled(); - const storedRepeatable = handle.userPut.mock.calls[0][0]; + const storedRepeatable = handle.userPut.mock.calls[0][0] as RepeatableDoc; expect(storedRepeatable).toBeTruthy(); expect(storedRepeatable._id).toMatch(/^repeatable:instance:/); expect(storedRepeatable._rev).not.toBeTruthy(); @@ -97,13 +108,14 @@ describe('Repeatable', () => { }); describe('completion redirection semantics', () => { - let repeatable; - let template; + let repeatable: Partial; + let template: Partial; + beforeEach(() => { handle.get.mockReset(); handle.userPut.mockReset(); - useLocation.mockReset(); - useParams.mockReset(); + mockUseLocation.mockReset(); + mockUseParams.mockReset(); repeatable = { _id: 'repeatable:instance:1234', @@ -116,22 +128,22 @@ describe('Repeatable', () => { values: [false], }; - handle.get.mockImplementation((docId) => { + handle.get.mockImplementation((docId: string) => { if (docId === 'repeatable:instance:1234') { return Promise.resolve(repeatable); } if (docId === 'repeatable:template:5678') { return Promise.resolve(template); } - return Promise.reject(`Bad ${docId}`); + return Promise.reject(new Error(`Bad ${docId}`)); }); - useLocation.mockReturnValue(); - useParams.mockReturnValue({ repeatableId: 'repeatable:instance:1234' }); - handle.userPut.mockResolvedValue({ rev: '2-abc' }); + mockUseLocation.mockReturnValue(undefined); + mockUseParams.mockReturnValue({ repeatableId: 'repeatable:instance:1234' }); + handle.userPut.mockResolvedValue({ _id: 'repeatable:instance:1234', _rev: '2-abc' }); }); it('redirects when completing a fresh repeatable', async () => { - render(); + render(); await waitFor(() => screen.getByText(/Some text/)); fireEvent.click(await waitFor(() => screen.getByText(/Complete/))); @@ -140,41 +152,41 @@ describe('Repeatable', () => { it('doesnt redirect when uncompleting a repeatable', async () => { repeatable.completed = 123456789; - render(); + render(); await waitFor(() => screen.getByText(/Some text/)); fireEvent.click(await waitFor(() => screen.getByText(/Un-complete/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(1)); expect(navigate.mock.calls.length).toBe(0); - expect(handle.userPut.mock.calls[0][0].completed).not.toBeTruthy(); + expect((handle.userPut.mock.calls[0][0] as RepeatableDoc).completed).not.toBeTruthy(); }); it('doesnt redirect when completing a just uncompleted repeatable', async () => { repeatable.completed = 123456789; - render(); + render(); await waitFor(() => screen.getByText(/Some text/)); fireEvent.click(await waitFor(() => screen.getByText(/Un-complete/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(1)); expect(navigate.mock.calls.length).toBe(0); - expect(handle.userPut.mock.calls[0][0].completed).not.toBeTruthy(); + expect((handle.userPut.mock.calls[0][0] as RepeatableDoc).completed).not.toBeTruthy(); fireEvent.click(await waitFor(() => screen.getByText(/Complete/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(2)); expect(navigate.mock.calls.length).toBe(0); - expect(handle.userPut.mock.calls[1][0].completed).toBeTruthy(); + expect((handle.userPut.mock.calls[1][0] as RepeatableDoc).completed).toBeTruthy(); }); it('does redirect when completing a just uncompleted repeatable if you change something', async () => { repeatable.completed = 123456789; - render(); + render(); await waitFor(() => screen.getByText(/Something to change/)); fireEvent.click(await waitFor(() => screen.getByText(/Un-complete/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(1)); expect(navigate.mock.calls.length).toBe(0); - expect(handle.userPut.mock.calls[0][0].completed).not.toBeTruthy(); + expect((handle.userPut.mock.calls[0][0] as RepeatableDoc).completed).not.toBeTruthy(); fireEvent.click(await waitFor(() => screen.getByText(/Something to change/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(2)); @@ -182,7 +194,7 @@ describe('Repeatable', () => { fireEvent.click(await waitFor(() => screen.getByText(/Complete/))); await waitFor(() => expect(handle.userPut.mock.calls.length).toBe(3)); expect(navigate.mock.calls.length).toBe(1); - expect(handle.userPut.mock.calls[2][0].completed).toBeTruthy(); + expect((handle.userPut.mock.calls[2][0] as RepeatableDoc).completed).toBeTruthy(); expect(navigate.mock.calls[0][0]).toBe('/'); }); }); diff --git a/src/client/pages/Template.tsx b/src/client/pages/Template.tsx index e96ea45..2448e52 100644 --- a/src/client/pages/Template.tsx +++ b/src/client/pages/Template.tsx @@ -36,7 +36,6 @@ function Template() { title: '', slug: { type: SlugType.Timestamp, - placeholder: '', }, markdown: '', created: now, @@ -133,12 +132,18 @@ function Template() { const name = target.name; if (name === 'slugType') { - copy.slug = Object.assign({}, copy.slug); - copy.slug.type = value as SlugType; + const newType = value as SlugType; + // When changing type, create appropriate slug config + if (newType === SlugType.String || newType === SlugType.URL) { + copy.slug = { type: newType, placeholder: '' }; + } else { + copy.slug = { type: newType }; + } } else if (name === 'slugPlaceholder') { - copy.slug = Object.assign({}, copy.slug); - - copy.slug.placeholder = value as string; + // Only String and URL types support placeholder + if (copy.slug.type === SlugType.String || copy.slug.type === SlugType.URL) { + copy.slug = { ...copy.slug, placeholder: value as string }; + } } else { // @ts-expect-error copy[name] = value; @@ -252,7 +257,7 @@ function Template() { - {['string', 'url'].includes(template.slug.type) && ( + {(template.slug.type === SlugType.String || template.slug.type === SlugType.URL) && ( = T extends { type: SlugType.String } + ? string + : T extends { type: SlugType.URL } + ? string + : T extends { type: SlugType.Date } + ? number + : T extends { type: SlugType.Timestamp } + ? number + : never; + export interface RepeatableDoc extends Doc { template: DocId; slug: string | number | undefined; @@ -34,10 +61,7 @@ export interface RepeatableDoc extends Doc { export interface TemplateDoc extends Doc { deleted?: boolean; title: string; - slug: { - type: SlugType; - placeholder?: string; - }; + slug: SlugConfig; markdown: string; created: number; updated: number; From b02cb4bfb79fa44fd611c816fa97afdd9327135c Mon Sep 17 00:00:00 2001 From: Stefan du Fresne Date: Mon, 19 Jan 2026 16:30:46 +0000 Subject: [PATCH 2/3] unused --- src/shared/types.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/shared/types.ts b/src/shared/types.ts index c5dd9ca..9c727dd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,17 +38,6 @@ export type SlugData = | { type: SlugType.Date; value: number } | { type: SlugType.Timestamp; value: number }; -// Helper to extract value type from a SlugConfig -export type SlugValueFor = T extends { type: SlugType.String } - ? string - : T extends { type: SlugType.URL } - ? string - : T extends { type: SlugType.Date } - ? number - : T extends { type: SlugType.Timestamp } - ? number - : never; - export interface RepeatableDoc extends Doc { template: DocId; slug: string | number | undefined; From 186a62ee9125ac2c05b3d6858321aa6249d0bb23 Mon Sep 17 00:00:00 2001 From: Stefan du Fresne Date: Mon, 19 Jan 2026 16:31:58 +0000 Subject: [PATCH 3/3] note for later --- TODO | 4 +--- src/client/pages/About.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/TODO b/TODO index 3179a34..82ddad5 100644 --- a/TODO +++ b/TODO @@ -1,8 +1,6 @@ Quick list -Better bullet points -- hitting enter creates next line with new point of same style -- nested bullet points +Revisit typing. We should have internal types not bound to PouchDB structures that mean the slug against the repeatable are cleanly typed with no ambiguiity. Button to edit template on repeatable view. Will edit existing repeatables. This has downsides because repeatable data is just an array of booleans, not a map. Think about that but probably don't care for now. diff --git a/src/client/pages/About.tsx b/src/client/pages/About.tsx index 29a7197..8236f14 100644 --- a/src/client/pages/About.tsx +++ b/src/client/pages/About.tsx @@ -10,7 +10,7 @@ import SyncPanel from '../features/Sync/SyncPanel'; import UpdatePanel from '../features/Update/UpdatePanel'; import { useSelector } from '../store'; -type InfoRow = [ReactNode, ReactNode?]; +type InfoRow = [string | ReactNode, (string | ReactNode)?]; function mapProps(parent: string, info: Record): InfoRow[] { return Object.keys(info)