diff --git a/apps/admin/package.json b/apps/admin/package.json index 4c62754957a..9f8c6f21633 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -84,6 +84,7 @@ "event-source-polyfill": "1.0.31", "exifr": "^7.1.3", "fuse.js": "7.4.2", + "immer": "11.1.8", "jotai": "2.20.1", "js-cookie": "3.0.8", "js-yaml": "5.2.0", diff --git a/apps/admin/src/data/resource/collection.test.ts b/apps/admin/src/data/resource/collection.test.ts new file mode 100644 index 00000000000..5dd2b53411e --- /dev/null +++ b/apps/admin/src/data/resource/collection.test.ts @@ -0,0 +1,477 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest' + +import { defineCollection, resetAllCollections } from './collection' +import { serializeListKey } from './key' +import { createTransaction } from './transaction' + +interface TestEntity { + id: string + text?: string + title?: string + count?: number +} + +describe('serializeListKey', () => { + it('does not change output based on object key order', () => { + const a = serializeListKey([{ a: 1, b: 2 }]) + const b = serializeListKey([{ b: 2, a: 1 }]) + expect(a).toBe(b) + }) + + it('is order independent for nested objects', () => { + const a = serializeListKey([{ outer: { a: 1, b: 2 }, z: 1 }]) + const b = serializeListKey([{ z: 1, outer: { b: 2, a: 1 } }]) + expect(a).toBe(b) + }) + + it('produces different output for different values', () => { + const a = serializeListKey([{ a: 1 }]) + const b = serializeListKey([{ a: 2 }]) + expect(a).not.toBe(b) + }) + + it('keeps array order significant', () => { + const a = serializeListKey([1, 2, 3]) + const b = serializeListKey([3, 2, 1]) + expect(a).not.toBe(b) + }) + + it('drops undefined values inside objects like JSON.stringify', () => { + const a = serializeListKey([{ a: 1, b: undefined }]) + const b = serializeListKey([{ a: 1 }]) + expect(a).toBe(b) + }) +}) + +describe('defineCollection: merge-upsert', () => { + it('preserves fields absent from a later partial hydrate', () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([{ id: '1', text: 'full text', title: 'hello' }]) + collection.hydrate([{ id: '1', title: 'updated title' }]) + + expect(collection.get('1')).toEqual({ + id: '1', + text: 'full text', + title: 'updated title', + }) + }) +}) + +describe('defineCollection: pending update survives stale hydrate', () => { + it('keeps optimistic patch visible over a stale hydrate; commit clears it', async () => { + let resolveUpdate: (value: TestEntity) => void = () => {} + const onUpdate = vi.fn( + () => + new Promise((resolve) => { + resolveUpdate = resolve + }), + ) + + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate, + }) + + collection.hydrate([{ id: '1', text: 'original', title: 'original title' }]) + + const updatePromise = collection.update('1', (draft) => { + draft.title = 'optimistic title' + }) + + collection.hydrate([{ id: '1', text: 'original', title: 'stale title' }]) + + expect(collection.get('1')?.title).toBe('optimistic title') + + resolveUpdate({ id: '1', text: 'original', title: 'server title' }) + await updatePromise + + expect(collection.get('1')?.title).toBe('server title') + expect(collection.getBase('1')?.title).toBe('server title') + }) +}) + +describe('defineCollection: rollback replays remaining pending ops', () => { + it('replays the second op over base after the first rolls back', async () => { + let rejectFirst: (error: unknown) => void = () => {} + const onUpdate = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirst = reject + }), + ) + .mockImplementationOnce(({ next }: { next: TestEntity }) => + Promise.resolve(next), + ) + + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate, + }) + + collection.hydrate([{ id: '1', count: 0 }]) + + const firstUpdate = collection.update('1', (draft) => { + draft.count = 1 + }) + const secondUpdate = collection.update('1', (draft) => { + draft.count = 2 + }) + + expect(collection.get('1')?.count).toBe(2) + + rejectFirst(new Error('boom')) + await expect(firstUpdate).rejects.toThrow('boom') + await secondUpdate + + expect(collection.get('1')?.count).toBe(2) + expect(collection.getBase('1')?.count).toBe(2) + }) +}) + +describe('defineCollection: optimistic delete', () => { + it('hides the entity while pending; rollback restores it', async () => { + let rejectDelete: (error: unknown) => void = () => {} + const onDelete = vi.fn( + () => + new Promise((_, reject) => { + rejectDelete = reject + }), + ) + + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onDelete, + }) + + collection.hydrate([{ id: '1', text: 'entity' }]) + + const deletePromise = collection.delete('1') + expect(collection.get('1')).toBeUndefined() + + rejectDelete(new Error('nope')) + await expect(deletePromise).rejects.toThrow('nope') + + expect(collection.get('1')).toEqual({ id: '1', text: 'entity' }) + }) +}) + +describe('defineCollection: onUpdate rejection', () => { + it('releases the op, records the error, rejects, and reverts the visible value', async () => { + const onUpdate = vi.fn(() => Promise.reject(new Error('update failed'))) + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate, + }) + + collection.hydrate([{ id: '1', title: 'original' }]) + + await expect( + collection.update('1', (draft) => { + draft.title = 'optimistic' + }), + ).rejects.toThrow('update failed') + + expect(collection.get('1')?.title).toBe('original') + expect(collection.store.getState().errorsByKey['1']).toBeInstanceOf(Error) + expect(collection.store.getState().pendingOpsByKey['1']).toBeUndefined() + }) +}) + +describe('defineCollection: onUpdate resolving with server echo', () => { + it('merges the echo into base', async () => { + const onUpdate = vi.fn(() => + Promise.resolve({ id: '1', title: 'server echo', text: 'server text' }), + ) + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate, + }) + + collection.hydrate([{ id: '1', title: 'original', text: 'original' }]) + + await collection.update('1', (draft) => { + draft.title = 'optimistic' + }) + + expect(collection.getBase('1')).toEqual({ + id: '1', + title: 'server echo', + text: 'server text', + }) + }) +}) + +describe('defineCollection: update on unknown entity', () => { + it('throws', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + await expect( + collection.update('missing', (draft) => { + draft.title = 'x' + }), + ).rejects.toThrow('[collection:test] update on unknown entity missing') + }) +}) + +describe('defineCollection: version bumps', () => { + it('bumps on hydrate, op begin, commit, and rollback', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate: () => Promise.reject(new Error('fail')), + }) + + collection.hydrate([{ id: '1', title: 'a' }]) + const afterHydrate = collection.store.getState().versionByKey['1'] + expect(afterHydrate).toBe(1) + + const updatePromise = collection.update('1', (draft) => { + draft.title = 'b' + }) + const afterBegin = collection.store.getState().versionByKey['1'] + expect(afterBegin).toBe(2) + + await expect(updatePromise).rejects.toThrow('fail') + const afterRollback = collection.store.getState().versionByKey['1'] + expect(afterRollback).toBe(3) + }) + + it('strictly increases across a successful commit', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + onUpdate: ({ next }) => Promise.resolve(next), + }) + + collection.hydrate([{ id: '1', title: 'a' }]) + const beforeUpdate = collection.store.getState().versionByKey['1'] + + await collection.update('1', (draft) => { + draft.title = 'b' + }) + + const afterCommit = collection.store.getState().versionByKey['1'] + expect(afterCommit).toBeGreaterThan(beforeUpdate) + }) +}) + +describe('defineCollection: reset', () => { + it('restores initial empty state', () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([{ id: '1', title: 'a' }]) + collection.reset() + + expect(collection.store.getState()).toEqual({ + entitiesById: {}, + versionByKey: {}, + pendingOpsByKey: {}, + errorsByKey: {}, + listIndexes: {}, + }) + }) +}) + +describe('resetAllCollections', () => { + it('resets every collection registered via defineCollection', () => { + const collectionA = defineCollection({ + name: 'reset-all-a', + getKey: (e) => e.id, + }) + const collectionB = defineCollection({ + name: 'reset-all-b', + getKey: (e) => e.id, + }) + + collectionA.hydrate([{ id: '1', title: 'a' }]) + collectionB.hydrate([{ id: '2', title: 'b' }]) + + resetAllCollections() + + const initialState = { + entitiesById: {}, + versionByKey: {}, + pendingOpsByKey: {}, + errorsByKey: {}, + listIndexes: {}, + } + expect(collectionA.store.getState()).toEqual(initialState) + expect(collectionB.store.getState()).toEqual(initialState) + }) +}) + +describe('createTransaction: partial success', () => { + it('commits fulfilled entities and rolls back the rest', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + { id: '3', title: 'c' }, + ]) + + const tx = createTransaction() + tx.delete(collection, '1') + tx.delete(collection, '2') + tx.delete(collection, '3') + + await tx.commit(async () => ({ fulfilledKeys: ['1', '2'] })) + + expect(collection.getBase('1')).toBeUndefined() + expect(collection.getBase('2')).toBeUndefined() + expect(collection.getBase('3')).toEqual({ id: '3', title: 'c' }) + expect(collection.get('3')).toEqual({ id: '3', title: 'c' }) + }) +}) + +describe('createTransaction: request rejection', () => { + it('rolls back everything and rethrows', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ]) + + const tx = createTransaction() + tx.delete(collection, '1') + tx.delete(collection, '2') + + await expect( + tx.commit(async () => { + throw new Error('network down') + }), + ).rejects.toThrow('network down') + + expect(collection.get('1')).toEqual({ id: '1', title: 'a' }) + expect(collection.get('2')).toEqual({ id: '2', title: 'b' }) + }) +}) + +describe('createTransaction: registration after commit', () => { + it('throws', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + collection.hydrate([{ id: '1', title: 'a' }]) + + const tx = createTransaction() + tx.delete(collection, '1') + await tx.commit(async () => ({})) + + expect(() => tx.delete(collection, '1')).toThrow() + }) + + it('throws on double commit', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + collection.hydrate([{ id: '1', title: 'a' }]) + + const tx = createTransaction() + tx.delete(collection, '1') + await tx.commit(async () => ({})) + + await expect(tx.commit(async () => ({}))).rejects.toThrow() + }) +}) + +describe('createTransaction: partial success does not record an error for the rolled-back entity', () => { + it('leaves errorsByKey untouched for the entity rolled back without an error', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ]) + + const tx = createTransaction() + tx.delete(collection, '1') + tx.delete(collection, '2') + + await tx.commit(async () => ({ fulfilledKeys: ['1'] })) + + const state = collection.store.getState() + expect('2' in state.errorsByKey).toBe(false) + }) +}) + +describe('createTransaction: update with partial fulfilledKeys', () => { + it('lands the optimistic value in base after commit and clears the pending op', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ]) + + const tx = createTransaction() + tx.update(collection, '1', (draft) => { + draft.title = 'updated a' + }) + tx.delete(collection, '2') + + await tx.commit(async () => ({ fulfilledKeys: ['1'] })) + + expect(collection.getBase('1')?.title).toBe('updated a') + expect(collection.store.getState().pendingOpsByKey['1']).toBeUndefined() + }) +}) + +describe('createTransaction: commit all when no fulfilledKeys', () => { + it('commits every registered op', async () => { + const collection = defineCollection({ + name: 'test', + getKey: (e) => e.id, + }) + + collection.hydrate([ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ]) + + const tx = createTransaction() + tx.update(collection, '1', (draft) => { + draft.title = 'updated a' + }) + tx.delete(collection, '2') + + await tx.commit(async () => {}) + + expect(collection.getBase('1')?.title).toBe('updated a') + expect(collection.getBase('2')).toBeUndefined() + }) +}) diff --git a/apps/admin/src/data/resource/collection.ts b/apps/admin/src/data/resource/collection.ts new file mode 100644 index 00000000000..b5a1060730e --- /dev/null +++ b/apps/admin/src/data/resource/collection.ts @@ -0,0 +1,351 @@ +import { produce } from 'immer' +import type { StoreApi } from 'zustand/vanilla' +import { createStore } from 'zustand/vanilla' + +import type { ListIndex } from './list-index' + +export interface CollectionConfig { + name: string + getKey: (entity: T) => string + merge?: (prev: T, next: T) => T + normalize?: (entity: T) => void + onInsert?: (args: { entity: T }) => Promise + onUpdate?: (args: { + id: string + patch: Partial + next: T + }) => Promise + onDelete?: (args: { id: string }) => Promise +} + +export interface PendingOp { + opId: string + kind: 'insert' | 'update' | 'delete' + entityId: string + patch?: Partial + entity?: T +} + +export interface CollectionState { + entitiesById: Record + versionByKey: Record + pendingOpsByKey: Record[]> + errorsByKey: Record + listIndexes: Record +} + +export interface Collection { + name: string + store: StoreApi> + getKey: (entity: T) => string + get: (id: string) => T | undefined + getBase: (id: string) => T | undefined + hydrate: (entities: T[]) => void + upsert: (entity: T) => void + update: (id: string, recipe: (draft: T) => void) => Promise + insert: (entity: T) => Promise + delete: (id: string) => Promise + reset: () => void + _ops: { + begin: (op: Omit, 'opId'>) => string + commit: (opId: string, serverEcho?: T) => void + rollback: (opId: string, error?: unknown) => void + } +} + +let opCounter = 0 + +interface Resettable { + reset: () => void +} + +const registeredCollections = new Set() + +export function resetAllCollections(): void { + for (const collection of registeredCollections) { + collection.reset() + } +} + +function createInitialState(): CollectionState { + return { + entitiesById: {}, + versionByKey: {}, + pendingOpsByKey: {}, + errorsByKey: {}, + listIndexes: {}, + } +} + +function applyRecipe(base: T, recipe: (draft: T) => void): T { + return produce(base, recipe as (draft: T) => void) as T +} + +export function deriveUpdate( + current: T, + recipe: (draft: T) => void, +): { next: T; patch: Partial } { + const next = applyRecipe(current, recipe) + const patch: Partial = {} + for (const key of Object.keys(next) as (keyof T)[]) { + if (!Object.is(next[key], current[key])) { + patch[key] = next[key] + } + } + return { next, patch } +} + +export function defineCollection( + config: CollectionConfig, +): Collection { + const { name, getKey, merge, normalize, onInsert, onUpdate, onDelete } = + config + + const store = createStore>()(() => createInitialState()) + + function withNextVersion( + versionByKey: Record, + id: string, + ): Record { + return { ...versionByKey, [id]: (versionByKey[id] ?? 0) + 1 } + } + + function withoutError( + errorsByKey: Record, + id: string, + ): Record { + if (!(id in errorsByKey)) return errorsByKey + const next = { ...errorsByKey } + delete next[id] + return next + } + + function getBase(id: string): T | undefined { + return store.getState().entitiesById[id] + } + + function get(id: string): T | undefined { + const state = store.getState() + let visible: T | undefined = state.entitiesById[id] + const ops = state.pendingOpsByKey[id] + if (!ops || ops.length === 0) return visible + + for (const op of ops) { + switch (op.kind) { + case 'update': { + visible = { ...visible, ...op.patch } + break + } + case 'delete': { + return undefined + } + case 'insert': { + if (visible === undefined && op.entity !== undefined) { + visible = op.entity + } + break + } + } + } + return visible + } + + function upsertToBase(entity: T) { + const id = getKey(entity) + store.setState((state) => { + const prev = state.entitiesById[id] + const merged = prev + ? merge + ? merge(prev, entity) + : { ...prev, ...entity } + : entity + return { + entitiesById: { ...state.entitiesById, [id]: merged }, + versionByKey: withNextVersion(state.versionByKey, id), + errorsByKey: withoutError(state.errorsByKey, id), + } + }) + } + + function hydrate(entities: T[]) { + for (const entity of entities) { + normalize?.(entity) + upsertToBase(entity) + } + } + + function upsert(entity: T) { + upsertToBase(entity) + } + + function begin(op: Omit, 'opId'>): string { + const opId = `op-${opCounter++}` + store.setState((state) => { + const list = state.pendingOpsByKey[op.entityId] ?? [] + return { + pendingOpsByKey: { + ...state.pendingOpsByKey, + [op.entityId]: [...list, { ...op, opId }], + }, + versionByKey: withNextVersion(state.versionByKey, op.entityId), + errorsByKey: withoutError(state.errorsByKey, op.entityId), + } + }) + return opId + } + + function removeOp(entityId: string, opId: string) { + store.setState((state) => { + const list = state.pendingOpsByKey[entityId] + if (!list) return state + + const filtered = list.filter((op) => op.opId !== opId) + const pendingOpsByKey = { ...state.pendingOpsByKey } + if (filtered.length > 0) { + pendingOpsByKey[entityId] = filtered + } else { + delete pendingOpsByKey[entityId] + } + + return { + pendingOpsByKey, + versionByKey: withNextVersion(state.versionByKey, entityId), + } + }) + } + + function findOp( + opId: string, + ): { entityId: string; op: PendingOp } | undefined { + const state = store.getState() + for (const [entityId, ops] of Object.entries(state.pendingOpsByKey)) { + const op = ops.find((candidate) => candidate.opId === opId) + if (op) return { entityId, op } + } + return undefined + } + + function commit(opId: string, serverEcho?: T) { + const found = findOp(opId) + if (!found) return + const { entityId, op } = found + + removeOp(entityId, opId) + + if (op.kind === 'delete') { + store.setState((state) => { + const entitiesById = { ...state.entitiesById } + delete entitiesById[entityId] + return { + entitiesById, + errorsByKey: withoutError(state.errorsByKey, entityId), + versionByKey: withNextVersion(state.versionByKey, entityId), + } + }) + return + } + + if (serverEcho !== undefined) { + upsertToBase(serverEcho) + } + } + + function rollback(opId: string, error?: unknown) { + const found = findOp(opId) + if (!found) return + const { entityId } = found + + removeOp(entityId, opId) + if (error === undefined) return + store.setState((state) => ({ + errorsByKey: { ...state.errorsByKey, [entityId]: error }, + })) + } + + async function update( + id: string, + recipe: (draft: T) => void, + ): Promise { + const current = get(id) + if (current === undefined) { + throw new Error(`[collection:${name}] update on unknown entity ${id}`) + } + + const { next, patch } = deriveUpdate(current, recipe) + + const opId = begin({ kind: 'update', entityId: id, patch }) + + if (!onUpdate) { + commit(opId, next) + return next + } + + try { + const echo = await onUpdate({ id, patch, next }) + commit(opId, echo ?? next) + return echo ?? next + } catch (error) { + rollback(opId, error) + throw error + } + } + + async function insert(entity: T): Promise { + const id = getKey(entity) + const opId = begin({ kind: 'insert', entityId: id, entity }) + + if (!onInsert) { + commit(opId, entity) + return entity + } + + try { + const echo = await onInsert({ entity }) + commit(opId, echo ?? entity) + return echo ?? entity + } catch (error) { + rollback(opId, error) + throw error + } + } + + async function del(id: string): Promise { + const opId = begin({ kind: 'delete', entityId: id }) + + if (!onDelete) { + commit(opId) + return + } + + try { + await onDelete({ id }) + commit(opId) + } catch (error) { + rollback(opId, error) + throw error + } + } + + function reset() { + store.setState(createInitialState(), true) + } + + const collection: Collection = { + name, + store, + getKey, + get, + getBase, + hydrate, + upsert, + update, + insert, + delete: del, + reset, + _ops: { begin, commit, rollback }, + } + + registeredCollections.add(collection) + + return collection +} diff --git a/apps/admin/src/data/resource/hooks.test.tsx b/apps/admin/src/data/resource/hooks.test.tsx new file mode 100644 index 00000000000..5b76a86a151 --- /dev/null +++ b/apps/admin/src/data/resource/hooks.test.tsx @@ -0,0 +1,432 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, createElement, useEffect } from 'react' +import type { Root } from 'react-dom/client' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Collection } from './collection' +import { defineCollection } from './collection' +import type { EntityListResult } from './hooks' +import { useCollectionInfiniteQuery, useEntity, useEntityList } from './hooks' +import { serializeListKey } from './key' +import { hydrateList, readList } from './list-index' + +interface TestEntity { + id: string + title?: string +} + +function makeCollection(name = 'test'): Collection { + return defineCollection({ name, getKey: (e) => e.id }) +} + +const mounted: (() => void)[] = [] + +afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.() + } +}) + +function mountEntity( + collection: Collection, + id: string | undefined, +) { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const box: { value: TestEntity | undefined } = { value: undefined } + + function Probe({ id }: { id: string | undefined }) { + const value = useEntity(collection, id) + useEffect(() => { + box.value = value + }) + box.value = value + return null + } + + act(() => { + root.render(createElement(Probe, { id })) + }) + + const unmount = () => { + act(() => root.unmount()) + container.remove() + } + mounted.push(unmount) + + return { + get value() { + return box.value + }, + rerender: (nextId: string | undefined) => { + act(() => { + root.render(createElement(Probe, { id: nextId })) + }) + }, + unmount, + } +} + +function mountEntityList( + collection: Collection, + initialQueryKey: readonly unknown[], + options?: { keepPrevious?: boolean }, +) { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const box: { value: EntityListResult | undefined } = { + value: undefined, + } + + function Probe({ queryKey }: { queryKey: readonly unknown[] }) { + const value = useEntityList(collection, queryKey, options) + useEffect(() => { + box.value = value + }) + box.value = value + return null + } + + act(() => { + root.render(createElement(Probe, { queryKey: initialQueryKey })) + }) + + const unmount = () => { + act(() => root.unmount()) + container.remove() + } + mounted.push(unmount) + + return { + get value() { + return box.value as EntityListResult + }, + rerender: (queryKey: readonly unknown[]) => { + act(() => { + root.render(createElement(Probe, { queryKey })) + }) + }, + unmount, + } +} + +describe('hydrateList / readList', () => { + it('writes the entities and the list index; readList returns ids and pagination', () => { + const collection = makeCollection() + + hydrateList(collection, 'k1', { + items: [ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ], + pagination: { page: 1, size: 20, total: 2, totalPages: 1 }, + }) + + expect(collection.get('1')).toEqual({ id: '1', title: 'a' }) + expect(collection.get('2')).toEqual({ id: '2', title: 'b' }) + + const index = readList(collection.store.getState(), collection, 'k1') + expect(index?.ids).toEqual(['1', '2']) + expect(index?.pagination).toEqual({ + page: 1, + size: 20, + total: 2, + totalPages: 1, + }) + expect(index?.updatedAt).toBeGreaterThan(0) + }) + + it('returns undefined for a key that was never hydrated', () => { + const collection = makeCollection() + expect( + readList(collection.store.getState(), collection, 'missing'), + ).toBeUndefined() + }) +}) + +describe('hydrateList append mode', () => { + it('dedupe-concats incoming ids onto the existing index, preserving order', () => { + const collection = makeCollection() + + hydrateList(collection, 'feed', { + items: [ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ], + }) + hydrateList( + collection, + 'feed', + { + items: [ + { id: '2', title: 'b-updated' }, + { id: '3', title: 'c' }, + ], + }, + { mode: 'append' }, + ) + + const index = readList(collection.store.getState(), collection, 'feed') + expect(index?.ids).toEqual(['1', '2', '3']) + expect(collection.get('2')).toEqual({ id: '2', title: 'b-updated' }) + }) + + it('behaves like a replace when there is no existing index yet', () => { + const collection = makeCollection() + + hydrateList( + collection, + 'feed', + { items: [{ id: '1', title: 'a' }] }, + { mode: 'append' }, + ) + + const index = readList(collection.store.getState(), collection, 'feed') + expect(index?.ids).toEqual(['1']) + }) + + it('leaves replace mode (default and explicit) unchanged', () => { + const collection = makeCollection() + + hydrateList(collection, 'feed', { + items: [ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ], + }) + hydrateList( + collection, + 'feed', + { items: [{ id: '3', title: 'c' }] }, + { mode: 'replace' }, + ) + + const index = readList(collection.store.getState(), collection, 'feed') + expect(index?.ids).toEqual(['3']) + }) +}) + +describe('hydrateList LRU eviction', () => { + it('evicts the least-recently-used index at the 51st key; entities are untouched', () => { + vi.useFakeTimers() + try { + const collection = makeCollection() + const base = Date.now() + + for (let i = 0; i < 50; i++) { + vi.setSystemTime(base + i) + hydrateList(collection, `k${i}`, { items: [{ id: `e${i}` }] }) + } + + vi.setSystemTime(base + 50) + hydrateList(collection, 'k50', { items: [{ id: 'e50' }] }) + + const state = collection.store.getState() + expect(Object.keys(state.listIndexes)).toHaveLength(50) + expect(state.listIndexes.k0).toBeUndefined() + expect(state.listIndexes.k50).toBeDefined() + + for (let i = 0; i < 50; i++) { + expect(collection.get(`e${i}`)).toEqual({ id: `e${i}` }) + } + expect(collection.get('e50')).toEqual({ id: 'e50' }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('useEntityList reference stability', () => { + it('returns the same reference across an unrelated write; a new one once a member changes', () => { + const collection = makeCollection() + hydrateList(collection, serializeListKey(['list']), { + items: [ + { id: '1', title: 'a' }, + { id: '2', title: 'b' }, + ], + }) + collection.upsert({ id: 'other', title: 'x' }) + + const harness = mountEntityList(collection, ['list']) + const first = harness.value + + act(() => { + collection.upsert({ id: 'other', title: 'y' }) + }) + expect(harness.value).toBe(first) + + act(() => { + collection.upsert({ id: '1', title: 'updated' }) + }) + expect(harness.value).not.toBe(first) + expect(harness.value.items.find((item) => item.id === '1')?.title).toBe( + 'updated', + ) + }) +}) + +describe('useEntityList missing index', () => { + it('returns a stable EMPTY reference across reads and unrelated writes', () => { + const collection = makeCollection() + const harness = mountEntityList(collection, ['missing']) + const first = harness.value + + expect(first.isHydrated).toBe(false) + expect(first.items).toEqual([]) + + act(() => { + collection.upsert({ id: 'unrelated' }) + }) + expect(harness.value).toBe(first) + }) +}) + +describe('useEntityList keepPrevious', () => { + it('falls back to the previous key items until the new key hydrates', () => { + const collection = makeCollection() + hydrateList(collection, serializeListKey(['a']), { + items: [{ id: '1', title: 'from-a' }], + }) + + const harness = mountEntityList(collection, ['a'], { + keepPrevious: true, + }) + expect(harness.value.items.map((item) => item.id)).toEqual(['1']) + + harness.rerender(['b']) + expect(harness.value.items.map((item) => item.id)).toEqual(['1']) + expect(harness.value.isHydrated).toBe(true) + + act(() => { + hydrateList(collection, serializeListKey(['b']), { + items: [{ id: '2', title: 'from-b' }], + }) + }) + expect(harness.value.items.map((item) => item.id)).toEqual(['2']) + }) +}) + +describe('useEntityList pending-delete filtering', () => { + it('filters an entity mid pending-delete out of items', () => { + const onDelete = () => new Promise(() => {}) + const collection = defineCollection({ + name: 'del-test', + getKey: (e) => e.id, + onDelete, + }) + hydrateList(collection, serializeListKey(['list']), { + items: [{ id: '1' }, { id: '2' }], + }) + + void collection.delete('1') + + const harness = mountEntityList(collection, ['list']) + expect(harness.value.items.map((item) => item.id)).toEqual(['2']) + }) +}) + +describe('useEntity reference stability', () => { + it('returns the same reference when the version is unchanged; a new value after commit', async () => { + const onUpdate = vi.fn(({ next }: { next: TestEntity }) => + Promise.resolve(next), + ) + const collection = defineCollection({ + name: 'entity-test', + getKey: (e) => e.id, + onUpdate, + }) + collection.hydrate([{ id: '1', title: 'a' }]) + + const harness = mountEntity(collection, '1') + const first = harness.value + expect(first).toEqual({ id: '1', title: 'a' }) + + act(() => { + collection.upsert({ id: 'other', title: 'x' }) + }) + expect(harness.value).toBe(first) + + await act(async () => { + await collection.update('1', (draft) => { + draft.title = 'b' + }) + }) + expect(harness.value).not.toBe(first) + expect(harness.value?.title).toBe('b') + }) + + it('returns undefined for an undefined id with no subscription churn', () => { + const collection = makeCollection() + const harness = mountEntity(collection, undefined) + expect(harness.value).toBeUndefined() + + act(() => { + collection.upsert({ id: 'irrelevant' }) + }) + expect(harness.value).toBeUndefined() + }) +}) + +describe('useCollectionInfiniteQuery', () => { + it('replaces the list index on the first page and appends on later pages', async () => { + const collection = makeCollection() + const queryClient = new QueryClient() + const queryKey = ['feed'] as const + const firstPage: TestEntity[] = [{ id: '1' }, { id: '2' }] + const secondPage: TestEntity[] = [{ id: '2' }, { id: '3' }] + + const box: { fetchNextPage: (() => Promise) | undefined } = { + fetchNextPage: undefined, + } + + function Probe() { + const query = useCollectionInfiniteQuery(collection, { + queryKey, + initialPageParam: null as null | string, + queryFn: (pageParam) => + Promise.resolve(pageParam === null ? firstPage : secondPage), + getNextPageParam: (_lastResult, pageParam) => + pageParam === null ? '2' : undefined, + toItems: (result) => result, + }) + useEffect(() => { + box.fetchNextPage = () => query.fetchNextPage() + }) + return null + } + + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + + await act(async () => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Probe), + ), + ) + await Promise.resolve() + }) + + const listKey = serializeListKey(queryKey) + expect( + readList(collection.store.getState(), collection, listKey)?.ids, + ).toEqual(['1', '2']) + + await act(async () => { + await box.fetchNextPage?.() + }) + + expect( + readList(collection.store.getState(), collection, listKey)?.ids, + ).toEqual(['1', '2', '3']) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/admin/src/data/resource/hooks.ts b/apps/admin/src/data/resource/hooks.ts new file mode 100644 index 00000000000..b822312242f --- /dev/null +++ b/apps/admin/src/data/resource/hooks.ts @@ -0,0 +1,289 @@ +import type { + InfiniteData, + QueryKey, + UseInfiniteQueryResult, + UseQueryResult, +} from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' + +import type { Pager } from '~/models/base' + +import type { Collection } from './collection' +import { serializeListKey } from './key' +import type { ListPage } from './list-index' +import { hydrateList, readList, touchList } from './list-index' + +export function useEntity( + collection: Collection, + id: string | undefined, +): T | undefined { + const cache = useRef<{ id: string; version: number; value: T | undefined }>( + undefined, + ) + + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!id) return () => {} + return collection.store.subscribe((state, prevState) => { + if (state.versionByKey[id] !== prevState.versionByKey[id]) { + onStoreChange() + } + }) + }, + [collection, id], + ) + + const getSnapshot = useCallback((): T | undefined => { + if (!id) return undefined + + const version = collection.store.getState().versionByKey[id] ?? 0 + const cached = cache.current + if (cached && cached.id === id && cached.version === version) { + return cached.value + } + + const value = collection.get(id) + cache.current = { id, version, value } + return value + }, [collection, id]) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} + +export interface EntityListResult { + items: T[] + pagination?: Pager + updatedAt: number + isHydrated: boolean +} + +const EMPTY_LIST_RESULT: EntityListResult = Object.freeze({ + items: Object.freeze([]) as never[], + pagination: undefined, + updatedAt: 0, + isHydrated: false, +}) + +interface ListResultCache { + activeKey: string + updatedAt: number + versionSignature: string + result: EntityListResult +} + +export function useEntityList( + collection: Collection, + queryKey: readonly unknown[], + options?: { keepPrevious?: boolean }, +): EntityListResult { + const listKey = useMemo(() => serializeListKey(queryKey), [queryKey]) + const keepPrevious = options?.keepPrevious ?? false + + const lastHydratedKeyRef = useRef(undefined) + const activeKeyRef = useRef(undefined) + const touchedKeyRef = useRef(undefined) + const cacheRef = useRef | undefined>(undefined) + + const subscribe = useCallback( + (onStoreChange: () => void) => collection.store.subscribe(onStoreChange), + [collection], + ) + + const getSnapshot = useCallback((): EntityListResult => { + const state = collection.store.getState() + + let activeKey = listKey + let index = readList(state, collection, listKey) + + if (!index && keepPrevious && lastHydratedKeyRef.current) { + const previousIndex = readList( + state, + collection, + lastHydratedKeyRef.current, + ) + if (previousIndex) { + activeKey = lastHydratedKeyRef.current + index = previousIndex + } + } + + if (!index) { + activeKeyRef.current = undefined + return EMPTY_LIST_RESULT + } + + if (activeKey === listKey) { + lastHydratedKeyRef.current = listKey + } + activeKeyRef.current = activeKey + + const versionSignature = index.ids + .map((id) => `${id}:${state.versionByKey[id] ?? 0}`) + .join('|') + + const cached = cacheRef.current + if ( + cached && + cached.activeKey === activeKey && + cached.updatedAt === index.updatedAt && + cached.versionSignature === versionSignature + ) { + return cached.result + } + + const items: T[] = [] + for (const id of index.ids) { + const entity = collection.get(id) + if (entity !== undefined) items.push(entity) + } + + const result: EntityListResult = { + items, + pagination: index.pagination, + updatedAt: index.updatedAt, + isHydrated: true, + } + + cacheRef.current = { + activeKey, + updatedAt: index.updatedAt, + versionSignature, + result, + } + + return result + }, [collection, listKey, keepPrevious]) + + const result = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + const key = activeKeyRef.current + if (key && touchedKeyRef.current !== key) { + touchedKeyRef.current = key + touchList(collection, key) + } + }) + + return result +} + +export interface CollectionQueryReceipt { + hydratedAt: number +} + +export function useCollectionListQuery( + collection: Collection, + options: { + queryKey: QueryKey + queryFn: () => Promise + toPage: (result: TResult) => ListPage + enabled?: boolean + }, +): UseQueryResult { + const listKey = useMemo( + () => serializeListKey(options.queryKey), + [options.queryKey], + ) + + return useQuery({ + queryKey: options.queryKey, + enabled: options.enabled, + queryFn: async (): Promise => { + const result = await options.queryFn() + const page = options.toPage(result) + hydrateList(collection, listKey, page) + return { hydratedAt: Date.now() } + }, + }) +} + +export interface CollectionInfiniteReceipt { + hydratedAt: number + nextPageParam: TPageParam | undefined +} + +export function useCollectionInfiniteQuery< + T extends object, + TResult, + TPageParam, +>( + collection: Collection, + options: { + queryKey: QueryKey + queryFn: (pageParam: TPageParam) => Promise + getNextPageParam: ( + lastResult: TResult, + pageParam: TPageParam, + ) => TPageParam | undefined + initialPageParam: TPageParam + toItems: (result: TResult) => T[] + enabled?: boolean + }, +): UseInfiniteQueryResult< + InfiniteData, TPageParam> +> { + const listKey = useMemo( + () => serializeListKey(options.queryKey), + [options.queryKey], + ) + + return useInfiniteQuery< + CollectionInfiniteReceipt, + Error, + InfiniteData, TPageParam>, + QueryKey, + TPageParam + >({ + queryKey: options.queryKey, + enabled: options.enabled, + initialPageParam: options.initialPageParam, + queryFn: async (context: { + pageParam?: unknown + }): Promise> => { + const pageParam = context.pageParam as TPageParam + const result = await options.queryFn(pageParam) + const items = options.toItems(result) + hydrateList( + collection, + listKey, + { items }, + { mode: pageParam ? 'append' : 'replace' }, + ) + return { + hydratedAt: Date.now(), + nextPageParam: options.getNextPageParam(result, pageParam), + } + }, + getNextPageParam: (lastPage) => lastPage.nextPageParam, + }) +} + +export function useCollectionDetailQuery( + collection: Collection, + options: { + queryKey: QueryKey + queryFn: () => Promise + toEntity?: (result: TResult) => T + enabled?: boolean + }, +): UseQueryResult { + return useQuery({ + queryKey: options.queryKey, + enabled: options.enabled, + queryFn: async (): Promise => { + const result = await options.queryFn() + const entity = options.toEntity + ? options.toEntity(result) + : (result as unknown as T) + collection.hydrate([entity]) + return { hydratedAt: Date.now() } + }, + }) +} diff --git a/apps/admin/src/data/resource/key.ts b/apps/admin/src/data/resource/key.ts new file mode 100644 index 00000000000..f69187bfcfb --- /dev/null +++ b/apps/admin/src/data/resource/key.ts @@ -0,0 +1,22 @@ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) + } + + if (Array.isArray(value)) { + return `[${value.map((item) => (item === undefined ? 'null' : stableStringify(item))).join(',')}]` + } + + const keys = Object.keys(value as Record).sort() + const entries: string[] = [] + for (const key of keys) { + const entryValue = (value as Record)[key] + if (entryValue === undefined) continue + entries.push(`${JSON.stringify(key)}:${stableStringify(entryValue)}`) + } + return `{${entries.join(',')}}` +} + +export function serializeListKey(queryKey: readonly unknown[]): string { + return stableStringify(queryKey) +} diff --git a/apps/admin/src/data/resource/list-index.ts b/apps/admin/src/data/resource/list-index.ts new file mode 100644 index 00000000000..077e0e335c2 --- /dev/null +++ b/apps/admin/src/data/resource/list-index.ts @@ -0,0 +1,112 @@ +import type { Pager } from '~/models/base' + +import type { Collection, CollectionState } from './collection' + +export interface ListIndex { + ids: string[] + pagination?: Pager + updatedAt: number + lruAt: number +} + +export interface ListPage { + items: T[] + pagination?: Pager +} + +const MAX_LIST_KEYS = 50 + +export interface HydrateListOptions { + mode?: 'append' | 'replace' +} + +function dedupeConcat(existingIds: string[], incomingIds: string[]): string[] { + const seen = new Set(existingIds) + const appended = incomingIds.filter((id) => { + if (seen.has(id)) return false + seen.add(id) + return true + }) + return [...existingIds, ...appended] +} + +export function hydrateList( + collection: Collection, + listKey: string, + page: ListPage, + options?: HydrateListOptions, +): void { + collection.hydrate(page.items) + + const incomingIds = page.items.map((item) => collection.getKey(item)) + const mode = options?.mode ?? 'replace' + const now = Date.now() + + collection.store.setState((state) => { + const existing = state.listIndexes[listKey] + const ids = + mode === 'append' && existing + ? dedupeConcat(existing.ids, incomingIds) + : incomingIds + + const listIndexes = { + ...state.listIndexes, + [listKey]: { + ids, + pagination: page.pagination, + updatedAt: now, + lruAt: now, + }, + } + return { listIndexes: evictOverflow(listIndexes) } + }) +} + +function evictOverflow( + listIndexes: Record, +): Record { + const keys = Object.keys(listIndexes) + if (keys.length <= MAX_LIST_KEYS) return listIndexes + + const sortedByLru = keys.sort( + (a, b) => listIndexes[a].lruAt - listIndexes[b].lruAt, + ) + const evictCount = keys.length - MAX_LIST_KEYS + const evicted = new Set(sortedByLru.slice(0, evictCount)) + + const next: Record = {} + for (const key of keys) { + if (!evicted.has(key)) next[key] = listIndexes[key] + } + return next +} + +export function readList( + state: CollectionState, + _collection: Collection, + listKey: string, +): { ids: string[]; pagination?: Pager; updatedAt: number } | undefined { + const index = state.listIndexes[listKey] + if (!index) return undefined + return { + ids: index.ids, + pagination: index.pagination, + updatedAt: index.updatedAt, + } +} + +export function touchList( + collection: Collection, + listKey: string, +): void { + collection.store.setState((state) => { + const index = state.listIndexes[listKey] + if (!index) return state + return { + listIndexes: { + ...state.listIndexes, + [listKey]: { ...index, lruAt: Date.now() }, + }, + } + }) +} diff --git a/apps/admin/src/data/resource/transaction.ts b/apps/admin/src/data/resource/transaction.ts new file mode 100644 index 00000000000..1e3b736d5ce --- /dev/null +++ b/apps/admin/src/data/resource/transaction.ts @@ -0,0 +1,125 @@ +import type { Collection } from './collection' +import { deriveUpdate } from './collection' + +export interface TransactionResult { + fulfilledKeys?: string[] +} + +export interface ResourceTransaction { + update: ( + collection: Collection, + id: string, + recipe: (draft: T) => void, + ) => this + delete: (collection: Collection, id: string) => this + insert: (collection: Collection, entity: T) => this + commit: (request: () => Promise) => Promise +} + +function extractFulfilledKeys(result: unknown): string[] | undefined { + if (typeof result !== 'object' || result === null) return undefined + if (!('fulfilledKeys' in result)) return undefined + const keys = result.fulfilledKeys + if (!Array.isArray(keys)) return undefined + return keys.filter((key): key is string => typeof key === 'string') +} + +interface Registration { + entityId: string + commitOp: () => void + rollbackOp: (error?: unknown) => void +} + +export function createTransaction(): ResourceTransaction { + const registrations: Registration[] = [] + let committed = false + + function assertNotCommitted() { + if (committed) { + throw new Error('[transaction] cannot register ops after commit') + } + } + + const tx: ResourceTransaction = { + update( + collection: Collection, + id: string, + recipe: (draft: T) => void, + ) { + assertNotCommitted() + const current = collection.get(id) + if (current === undefined) { + throw new Error( + `[collection:${collection.name}] update on unknown entity ${id}`, + ) + } + + const { next, patch } = deriveUpdate(current, recipe) + + const opId = collection._ops.begin({ + kind: 'update', + entityId: id, + patch, + }) + registrations.push({ + entityId: id, + commitOp: () => collection._ops.commit(opId, next), + rollbackOp: (error) => collection._ops.rollback(opId, error), + }) + return tx + }, + delete(collection: Collection, id: string) { + assertNotCommitted() + const opId = collection._ops.begin({ kind: 'delete', entityId: id }) + registrations.push({ + entityId: id, + commitOp: () => collection._ops.commit(opId), + rollbackOp: (error) => collection._ops.rollback(opId, error), + }) + return tx + }, + insert(collection: Collection, entity: T) { + assertNotCommitted() + const id = collection.getKey(entity) + const opId = collection._ops.begin({ + kind: 'insert', + entityId: id, + entity, + }) + registrations.push({ + entityId: id, + commitOp: () => collection._ops.commit(opId, entity), + rollbackOp: (error) => collection._ops.rollback(opId, error), + }) + return tx + }, + async commit(request: () => Promise) { + assertNotCommitted() + committed = true + + let result: R + try { + result = await request() + } catch (error) { + for (const registration of registrations) { + registration.rollbackOp(error) + } + throw error + } + + const fulfilledKeys = extractFulfilledKeys(result) + + for (const registration of registrations) { + if (!fulfilledKeys || fulfilledKeys.includes(registration.entityId)) { + registration.commitOp() + } else { + registration.rollbackOp() + } + } + + return result + }, + } + + return tx +} diff --git a/apps/admin/src/data/resources/category.mutations.ts b/apps/admin/src/data/resources/category.mutations.ts new file mode 100644 index 00000000000..dacbab2abde --- /dev/null +++ b/apps/admin/src/data/resources/category.mutations.ts @@ -0,0 +1,26 @@ +import type { CreateCategoryData } from '~/api/categories' +import { createCategory } from '~/api/categories' + +import type { CategoryEntity } from './category' +import { categories } from './category' + +export async function saveCategory( + mode: { kind: 'create' } | { kind: 'edit'; id: string }, + form: { name: string; slug: string }, +): Promise { + if (mode.kind === 'edit') { + return categories.update(mode.id, (draft) => { + draft.name = form.name + draft.slug = form.slug + }) + } + + const data: CreateCategoryData = { name: form.name, slug: form.slug } + const result = await createCategory(data) + categories.upsert(result) + return result +} + +export function removeCategory(id: string): Promise { + return categories.delete(id) +} diff --git a/apps/admin/src/data/resources/category.ts b/apps/admin/src/data/resources/category.ts new file mode 100644 index 00000000000..6726f63b90a --- /dev/null +++ b/apps/admin/src/data/resources/category.ts @@ -0,0 +1,28 @@ +import type { UpdateCategoryData } from '~/api/categories' +import { deleteCategory, updateCategory } from '~/api/categories' +import { defineCollection } from '~/data/resource/collection' +import type { CategoryType } from '~/models/category' + +export interface CategoryEntity { + id: string + name: string + slug: string + type: CategoryType + count?: number + createdAt?: string +} + +export const categories = defineCollection({ + name: 'category', + getKey: (category) => category.id, + onUpdate: async ({ id, patch }) => { + const data: UpdateCategoryData = {} + if (patch.name !== undefined) data.name = patch.name + if (patch.slug !== undefined) data.slug = patch.slug + if (patch.type !== undefined) data.type = patch.type + return await updateCategory(id, data) + }, + onDelete: async ({ id }) => { + await deleteCategory(id) + }, +}) diff --git a/apps/admin/src/data/resources/link.mutations.ts b/apps/admin/src/data/resources/link.mutations.ts new file mode 100644 index 00000000000..b645cd89fc2 --- /dev/null +++ b/apps/admin/src/data/resources/link.mutations.ts @@ -0,0 +1,63 @@ +import type { LinkInput } from '~/api/links' +import { + auditLinkWithReason, + auditPassLink, + createLink, + updateLink, +} from '~/api/links' +import { createTransaction } from '~/data/resource/transaction' +import type { LinkModel } from '~/models/link' +import { LinkState } from '~/models/link' + +import { links } from './link' + +export async function saveLink( + mode: { kind: 'create' } | { id: string; kind: 'edit' }, + form: LinkInput, +): Promise { + if (mode.kind === 'edit') { + const { id } = mode + const tx = createTransaction() + tx.update(links, id, (draft) => { + draft.name = form.name + draft.url = form.url + draft.description = form.description + if (form.avatar !== undefined) draft.avatar = form.avatar + if (form.type !== undefined) draft.type = form.type + if (form.state !== undefined) draft.state = form.state + }) + const result = await tx.commit(() => updateLink(id, form)) + links.hydrate([result]) + return result + } + + const result = await createLink(form) + links.upsert(result) + return result +} + +export function removeLink(id: string): Promise { + return links.delete(id) +} + +export async function auditPass(id: string): Promise { + const tx = createTransaction() + tx.update(links, id, (draft) => { + draft.state = LinkState.Pass + }) + const result = await tx.commit(() => auditPassLink(id)) + links.hydrate([result]) + return result +} + +export function auditWithReason( + id: string, + reason: string, + state: number, +): Promise { + const tx = createTransaction() + tx.update(links, id, (draft) => { + draft.state = state + }) + return tx.commit(() => auditLinkWithReason(id, { reason, state })) +} diff --git a/apps/admin/src/data/resources/link.ts b/apps/admin/src/data/resources/link.ts new file mode 100644 index 00000000000..615a3eab1dd --- /dev/null +++ b/apps/admin/src/data/resources/link.ts @@ -0,0 +1,11 @@ +import { deleteLink } from '~/api/links' +import { defineCollection } from '~/data/resource/collection' +import type { LinkModel } from '~/models/link' + +export const links = defineCollection({ + name: 'link', + getKey: (link) => link.id, + onDelete: async ({ id }) => { + await deleteLink(id) + }, +}) diff --git a/apps/admin/src/data/resources/note.mutations.ts b/apps/admin/src/data/resources/note.mutations.ts new file mode 100644 index 00000000000..f1a245ef2c5 --- /dev/null +++ b/apps/admin/src/data/resources/note.mutations.ts @@ -0,0 +1,112 @@ +import type { CreateNoteData, PatchNoteData } from '~/api/notes' +import { + createNote, + deleteNote, + getNoteById, + patchNotePublish, + updateNote, +} from '~/api/notes' +import { createTransaction } from '~/data/resource/transaction' +import type { NoteModel } from '~/models/note' + +import { notes } from './note' + +async function ensureNoteHydrated(id: string): Promise { + if (notes.get(id) !== undefined) return + const entity = await getNoteById(id, { single: true }) + notes.hydrate([entity]) +} + +export async function publishNote( + id: string, + isPublished: boolean, +): Promise { + await ensureNoteHydrated(id) + const tx = createTransaction() + tx.update(notes, id, (draft) => { + draft.isPublished = isPublished + }) + const result = await tx.commit(() => patchNotePublish(id, isPublished)) + notes.hydrate([result]) + return result +} + +export async function patchNoteFields( + id: string, + patch: PatchNoteData, +): Promise { + await ensureNoteHydrated(id) + return notes.update(id, (draft) => { + Object.assign(draft, patch) + }) +} + +export function removeNote(id: string): Promise { + return notes.delete(id) +} + +export interface BatchRemoveResult { + failedCount: number + fulfilledKeys: string[] + successCount: number +} + +export function removeNotes(ids: string[]): Promise { + const tx = createTransaction() + ids.forEach((id) => tx.delete(notes, id)) + + return tx.commit(async () => { + const results = await Promise.allSettled(ids.map((id) => deleteNote(id))) + const fulfilledKeys = ids.filter( + (_, index) => results[index].status === 'fulfilled', + ) + + return { + failedCount: ids.length - fulfilledKeys.length, + fulfilledKeys, + successCount: fulfilledKeys.length, + } + }) +} + +function toOptimisticNotePatch(data: CreateNoteData): Partial { + const patch: Partial = { + bookmark: data.bookmark, + contentFormat: data.contentFormat, + coordinates: data.coordinates, + isPublished: data.isPublished, + location: data.location, + meta: data.meta, + publicAt: data.publicAt, + text: data.text, + title: data.title, + topicId: data.topicId, + } + if (data.content !== undefined) patch.content = data.content + if (data.images !== undefined) patch.images = data.images + if (data.mood !== undefined) patch.mood = data.mood + if (data.slug !== undefined) patch.slug = data.slug + if (data.weather !== undefined) patch.weather = data.weather + return patch +} + +export async function saveNote( + id: string, + data: CreateNoteData, +): Promise { + if (!id) { + const result = await createNote(data) + notes.upsert(result) + return result + } + + await ensureNoteHydrated(id) + const patch = toOptimisticNotePatch(data) + const tx = createTransaction() + tx.update(notes, id, (draft) => { + Object.assign(draft, patch) + }) + const result = await tx.commit(() => updateNote(id, data)) + notes.hydrate([result]) + return result +} diff --git a/apps/admin/src/data/resources/note.ts b/apps/admin/src/data/resources/note.ts new file mode 100644 index 00000000000..6a8f8fcaf71 --- /dev/null +++ b/apps/admin/src/data/resources/note.ts @@ -0,0 +1,15 @@ +import { deleteNote, patchNote } from '~/api/notes' +import { defineCollection } from '~/data/resource/collection' +import type { NoteModel } from '~/models/note' + +import { topics } from './topic' + +export const notes = defineCollection({ + name: 'note', + getKey: (note) => note.id, + normalize: (note) => { + if (note.topic) topics.upsert(note.topic) + }, + onUpdate: ({ id, patch }) => patchNote(id, patch), + onDelete: ({ id }) => deleteNote(id), +}) diff --git a/apps/admin/src/data/resources/page.mutations.ts b/apps/admin/src/data/resources/page.mutations.ts new file mode 100644 index 00000000000..bfc00631890 --- /dev/null +++ b/apps/admin/src/data/resources/page.mutations.ts @@ -0,0 +1,57 @@ +import type { CreatePageData } from '~/api/pages' +import { createPage, reorderPages, updatePage } from '~/api/pages' +import { createTransaction } from '~/data/resource/transaction' +import type { PageModel } from '~/models/page' + +import { pages } from './page' + +export function removePage(id: string): Promise { + return pages.delete(id) +} + +function toOptimisticPagePatch(data: CreatePageData): Partial { + const patch: Partial = { + contentFormat: data.contentFormat, + meta: data.meta, + slug: data.slug, + subtitle: data.subtitle, + text: data.text, + title: data.title, + } + if (data.content !== undefined) patch.content = data.content + if (data.images !== undefined) patch.images = data.images + if (data.order !== undefined) patch.order = data.order + return patch +} + +export async function savePage( + id: string, + data: CreatePageData, +): Promise { + if (!id) { + const result = await createPage(data) + pages.upsert(result) + return result + } + + const patch = toOptimisticPagePatch(data) + const tx = createTransaction() + tx.update(pages, id, (draft) => { + Object.assign(draft, patch) + }) + const result = await tx.commit(() => updatePage(id, data)) + pages.hydrate([result]) + return result +} + +export function reorderPagesOptimistic( + seq: Array<{ id: string; order: number }>, +): Promise { + const tx = createTransaction() + seq.forEach(({ id, order }) => { + tx.update(pages, id, (draft) => { + draft.order = order + }) + }) + return tx.commit(() => reorderPages(seq)) +} diff --git a/apps/admin/src/data/resources/page.ts b/apps/admin/src/data/resources/page.ts new file mode 100644 index 00000000000..676a6f9d352 --- /dev/null +++ b/apps/admin/src/data/resources/page.ts @@ -0,0 +1,9 @@ +import { deletePage } from '~/api/pages' +import { defineCollection } from '~/data/resource/collection' +import type { PageModel } from '~/models/page' + +export const pages = defineCollection({ + name: 'page', + getKey: (page) => page.id, + onDelete: ({ id }) => deletePage(id), +}) diff --git a/apps/admin/src/data/resources/post.mutations.ts b/apps/admin/src/data/resources/post.mutations.ts new file mode 100644 index 00000000000..3b55f7befbb --- /dev/null +++ b/apps/admin/src/data/resources/post.mutations.ts @@ -0,0 +1,111 @@ +import type { CreatePostData } from '~/api/posts' +import { createPost, deletePost, getPostById, updatePost } from '~/api/posts' +import { createTransaction } from '~/data/resource/transaction' +import type { PostModel } from '~/models/post' + +import { posts } from './post' + +async function ensurePostHydrated(id: string): Promise { + if (posts.get(id) !== undefined) return + const entity = await getPostById(id) + posts.hydrate([entity]) +} + +export async function publishPost( + id: string, + isPublished: boolean, +): Promise { + await ensurePostHydrated(id) + return posts.update(id, (draft) => { + draft.isPublished = isPublished + }) +} + +export async function pinPost( + id: string, + isPinned: boolean, +): Promise { + await ensurePostHydrated(id) + return posts.update(id, (draft) => { + draft.pinAt = isPinned ? new Date().toISOString() : null + }) +} + +export async function movePostCategory( + id: string, + categoryId: string, +): Promise { + await ensurePostHydrated(id) + return posts.update(id, (draft) => { + draft.categoryId = categoryId + }) +} + +export function removePost(id: string): Promise { + return posts.delete(id) +} + +export interface BatchRemoveResult { + failedCount: number + fulfilledKeys: string[] + successCount: number +} + +export function removePosts(ids: string[]): Promise { + const tx = createTransaction() + ids.forEach((id) => tx.delete(posts, id)) + + return tx.commit(async () => { + const results = await Promise.allSettled(ids.map((id) => deletePost(id))) + const fulfilledKeys = ids.filter( + (_, index) => results[index].status === 'fulfilled', + ) + + return { + failedCount: ids.length - fulfilledKeys.length, + fulfilledKeys, + successCount: fulfilledKeys.length, + } + }) +} + +function toOptimisticPostPatch(data: CreatePostData): Partial { + const patch: Partial = { + categoryId: data.categoryId, + contentFormat: data.contentFormat, + copyright: data.copyright, + isPublished: data.isPublished, + meta: data.meta, + pinAt: data.pin, + pinOrder: data.pinOrder, + slug: data.slug, + summary: data.summary, + tags: data.tags, + text: data.text, + title: data.title, + } + if (data.content !== undefined) patch.content = data.content + if (data.images !== undefined) patch.images = data.images + return patch +} + +export async function savePost( + id: string, + data: CreatePostData, +): Promise { + if (!id) { + const result = await createPost(data) + posts.upsert(result) + return result + } + + await ensurePostHydrated(id) + const patch = toOptimisticPostPatch(data) + const tx = createTransaction() + tx.update(posts, id, (draft) => { + Object.assign(draft, patch) + }) + const result = await tx.commit(() => updatePost(id, data)) + posts.hydrate([result]) + return result +} diff --git a/apps/admin/src/data/resources/post.ts b/apps/admin/src/data/resources/post.ts new file mode 100644 index 00000000000..2cd7ae34481 --- /dev/null +++ b/apps/admin/src/data/resources/post.ts @@ -0,0 +1,29 @@ +import { deletePost, patchPost } from '~/api/posts' +import { defineCollection } from '~/data/resource/collection' +import { CategoryType } from '~/models/category' +import type { Category, PostModel } from '~/models/post' + +import type { CategoryEntity } from './category' +import { categories } from './category' + +function toCategoryEntity(category: Category): CategoryEntity { + return { + id: category.id, + name: category.name, + slug: category.slug, + type: + category.type === CategoryType.Tag + ? CategoryType.Tag + : CategoryType.Category, + } +} + +export const posts = defineCollection({ + name: 'post', + getKey: (post) => post.id, + normalize: (post) => { + if (post.category) categories.upsert(toCategoryEntity(post.category)) + }, + onUpdate: ({ id, patch }) => patchPost(id, patch), + onDelete: ({ id }) => deletePost(id), +}) diff --git a/apps/admin/src/data/resources/project.mutations.ts b/apps/admin/src/data/resources/project.mutations.ts new file mode 100644 index 00000000000..6fcdcc12c93 --- /dev/null +++ b/apps/admin/src/data/resources/project.mutations.ts @@ -0,0 +1,37 @@ +import type { ProjectInput } from '~/api/projects' +import { createProject, updateProject } from '~/api/projects' +import { createTransaction } from '~/data/resource/transaction' +import type { ProjectModel } from '~/models/project' + +import { projects } from './project' + +export async function saveProject( + mode: { kind: 'create' } | { id: string; kind: 'edit' }, + form: ProjectInput, +): Promise { + if (mode.kind === 'edit') { + const { id } = mode + const tx = createTransaction() + tx.update(projects, id, (draft) => { + draft.name = form.name + draft.description = form.description + draft.previewUrl = form.previewUrl ?? null + draft.docUrl = form.docUrl ?? null + draft.projectUrl = form.projectUrl ?? null + draft.images = form.images ?? null + draft.avatar = form.avatar ?? null + draft.text = form.text + }) + const result = await tx.commit(() => updateProject(id, form)) + projects.hydrate([result]) + return result + } + + const result = await createProject(form) + projects.upsert(result) + return result +} + +export function removeProject(id: string): Promise { + return projects.delete(id) +} diff --git a/apps/admin/src/data/resources/project.ts b/apps/admin/src/data/resources/project.ts new file mode 100644 index 00000000000..df2d253d107 --- /dev/null +++ b/apps/admin/src/data/resources/project.ts @@ -0,0 +1,11 @@ +import { deleteProject } from '~/api/projects' +import { defineCollection } from '~/data/resource/collection' +import type { ProjectModel } from '~/models/project' + +export const projects = defineCollection({ + name: 'project', + getKey: (project) => project.id, + onDelete: async ({ id }) => { + await deleteProject(id) + }, +}) diff --git a/apps/admin/src/data/resources/recently.mutations.ts b/apps/admin/src/data/resources/recently.mutations.ts new file mode 100644 index 00000000000..d25b5f2b702 --- /dev/null +++ b/apps/admin/src/data/resources/recently.mutations.ts @@ -0,0 +1,45 @@ +import type { RecentlyInput } from '~/api/recently' +import { createRecently, updateRecently } from '~/api/recently' +import { createTransaction } from '~/data/resource/transaction' +import type { EnrichmentResult } from '~/models/enrichment' +import type { RecentlyModel } from '~/models/recently' + +import { recentlies } from './recently' + +export async function saveRecently( + mode: { kind: 'create' } | { id: string; kind: 'edit' }, + input: RecentlyInput, +): Promise { + if (mode.kind === 'edit') { + const { id } = mode + const tx = createTransaction() + tx.update(recentlies, id, (draft) => { + draft.content = input.content + }) + const result = await tx.commit(() => updateRecently(id, input)) + recentlies.hydrate([result]) + return result + } + + const result = await createRecently(input) + recentlies.upsert(result) + return result +} + +export function removeRecently(id: string): Promise { + return recentlies.delete(id) +} + +export function applyRecentlyEnrichment( + id: string, + url: string, + enrichment: EnrichmentResult, +): void { + const current = recentlies.get(id) + if (!current) return + + recentlies.upsert({ + ...current, + enrichments: { ...current.enrichments, [url]: enrichment }, + }) +} diff --git a/apps/admin/src/data/resources/recently.ts b/apps/admin/src/data/resources/recently.ts new file mode 100644 index 00000000000..678e7cbe1b4 --- /dev/null +++ b/apps/admin/src/data/resources/recently.ts @@ -0,0 +1,9 @@ +import { deleteRecently } from '~/api/recently' +import { defineCollection } from '~/data/resource/collection' +import type { RecentlyModel } from '~/models/recently' + +export const recentlies = defineCollection({ + name: 'recently', + getKey: (recently) => recently.id, + onDelete: ({ id }) => deleteRecently(id), +}) diff --git a/apps/admin/src/data/resources/say.mutations.ts b/apps/admin/src/data/resources/say.mutations.ts new file mode 100644 index 00000000000..194afc772ca --- /dev/null +++ b/apps/admin/src/data/resources/say.mutations.ts @@ -0,0 +1,32 @@ +import type { SayInput } from '~/api/says' +import { createSay, updateSay } from '~/api/says' +import { createTransaction } from '~/data/resource/transaction' +import type { SayModel } from '~/models/say' + +import { says } from './say' + +export async function saveSay( + mode: { kind: 'create' } | { id: string; kind: 'edit' }, + form: SayInput, +): Promise { + if (mode.kind === 'edit') { + const { id } = mode + const tx = createTransaction() + tx.update(says, id, (draft) => { + draft.text = form.text + draft.author = form.author ?? null + draft.source = form.source ?? null + }) + const result = await tx.commit(() => updateSay(id, form)) + says.hydrate([result]) + return result + } + + const result = await createSay(form) + says.upsert(result) + return result +} + +export function removeSay(id: string): Promise { + return says.delete(id) +} diff --git a/apps/admin/src/data/resources/say.ts b/apps/admin/src/data/resources/say.ts new file mode 100644 index 00000000000..bdd48a78fbe --- /dev/null +++ b/apps/admin/src/data/resources/say.ts @@ -0,0 +1,9 @@ +import { deleteSay } from '~/api/says' +import { defineCollection } from '~/data/resource/collection' +import type { SayModel } from '~/models/say' + +export const says = defineCollection({ + name: 'say', + getKey: (say) => say.id, + onDelete: ({ id }) => deleteSay(id), +}) diff --git a/apps/admin/src/data/resources/topic.mutations.ts b/apps/admin/src/data/resources/topic.mutations.ts new file mode 100644 index 00000000000..91f40ee0572 --- /dev/null +++ b/apps/admin/src/data/resources/topic.mutations.ts @@ -0,0 +1,58 @@ +import type { CreateTopicData } from '~/api/topics' +import { createTopic, deleteTopic, updateTopic } from '~/api/topics' +import { createTransaction } from '~/data/resource/transaction' +import type { TopicModel } from '~/models/topic' + +import { topics } from './topic' + +export async function saveTopic( + mode: { kind: 'create' } | { id: string; kind: 'edit' }, + form: CreateTopicData, +): Promise { + if (mode.kind === 'edit') { + const { id } = mode + const tx = createTransaction() + tx.update(topics, id, (draft) => { + draft.name = form.name + draft.slug = form.slug + draft.introduce = form.introduce + draft.description = form.description ?? '' + draft.icon = form.icon ?? null + }) + const result = await tx.commit(() => updateTopic(id, form)) + topics.hydrate([result]) + return result + } + + const result = await createTopic(form) + topics.upsert(result) + return result +} + +export function removeTopic(id: string): Promise { + return topics.delete(id) +} + +export interface BatchRemoveResult { + failedCount: number + fulfilledKeys: string[] + successCount: number +} + +export function removeTopics(ids: string[]): Promise { + const tx = createTransaction() + ids.forEach((id) => tx.delete(topics, id)) + + return tx.commit(async () => { + const results = await Promise.allSettled(ids.map((id) => deleteTopic(id))) + const fulfilledKeys = ids.filter( + (_, index) => results[index].status === 'fulfilled', + ) + + return { + failedCount: ids.length - fulfilledKeys.length, + fulfilledKeys, + successCount: fulfilledKeys.length, + } + }) +} diff --git a/apps/admin/src/data/resources/topic.ts b/apps/admin/src/data/resources/topic.ts new file mode 100644 index 00000000000..d8a23fa8e5b --- /dev/null +++ b/apps/admin/src/data/resources/topic.ts @@ -0,0 +1,9 @@ +import { deleteTopic } from '~/api/topics' +import { defineCollection } from '~/data/resource/collection' +import type { TopicModel } from '~/models/topic' + +export const topics = defineCollection({ + name: 'topic', + getKey: (topic) => topic.id, + onDelete: ({ id }) => deleteTopic(id), +}) diff --git a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx index 464195c2bdb..a38be0c2eba 100644 --- a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx +++ b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx @@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react' import { useNavigate, useParams } from 'react-router' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import type { CategoryEntity } from '~/data/resources/category' import { useI18n } from '~/i18n' -import type { CategoryModel } from '~/models/category' import { MasterDetailShell } from '~/ui/layout/master-detail-shell' import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance' import { Button } from '~/ui/primitives/button' @@ -81,14 +81,14 @@ export function CategoriesRouteViewContent() { () => ({ deleting: deleteMutation.isPending, onBack: closeDetail, - onDelete: (category: CategoryModel) => { + onDelete: (category: CategoryEntity) => { if ( window.confirm(t('categories.confirmDelete', { name: category.name })) ) { deleteMutation.mutate(category.id) } }, - onEdit: (category: CategoryModel) => + onEdit: (category: CategoryEntity) => void openForm({ category, kind: 'edit' }), }), [closeDetail, deleteMutation, openForm, t], diff --git a/apps/admin/src/features/categories/components/CategoryDetail.tsx b/apps/admin/src/features/categories/components/CategoryDetail.tsx index ed77ed915ee..6ab455135ed 100644 --- a/apps/admin/src/features/categories/components/CategoryDetail.tsx +++ b/apps/admin/src/features/categories/components/CategoryDetail.tsx @@ -1,8 +1,9 @@ -import { useQuery } from '@tanstack/react-query' import { Edit3, FolderOpen, Loader2, Trash2 } from 'lucide-react' -import type { CategoryModel } from '~/models/category' import { getPosts } from '~/api/posts' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import type { CategoryEntity } from '~/data/resources/category' +import { posts } from '~/data/resources/post' import { useI18n } from '~/i18n' import { adminQueryKeys } from '~/query/keys' import { Button } from '~/ui/primitives/button' @@ -14,14 +15,15 @@ import { EntitySummary } from './EntitySummary' import { PostListSection } from './PostListSection' export function CategoryDetail(props: { - category: CategoryModel + category: CategoryEntity deleting: boolean onBack: () => void - onDelete: (category: CategoryModel) => void - onEdit: (category: CategoryModel) => void + onDelete: (category: CategoryEntity) => void + onEdit: (category: CategoryEntity) => void }) { const { t } = useI18n() - const postsQuery = useQuery({ + const postsListKey = adminQueryKeys.posts.categoryDetail(props.category.id) + const postsQuery = useCollectionListQuery(posts, { enabled: !!props.category.id, queryFn: () => getPosts({ @@ -30,9 +32,14 @@ export function CategoryDetail(props: { size: categoryDetailPostPageSize, sort_by: 'createdAt', sort_order: 'desc', - }).then((result) => result.data), - queryKey: adminQueryKeys.posts.categoryDetail(props.category.id), + }), + queryKey: postsListKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, + }), }) + const postsList = useEntityList(posts, postsListKey) return (
@@ -64,7 +71,7 @@ export function CategoryDetail(props: { diff --git a/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx b/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx index 02779817e8f..06de3ed81e6 100644 --- a/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx +++ b/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx @@ -1,10 +1,15 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router' import { getCategories, getCategory, getTags } from '~/api/categories' -import { findInListCache } from '~/api/list-cache' +import { + useCollectionDetailQuery, + useCollectionListQuery, + useEntity, +} from '~/data/resource/hooks' +import { categories } from '~/data/resources/category' import { useDocumentTitle } from '~/hooks/use-document-title' -import type { CategoryModel, TagModel } from '~/models/category' +import type { TagModel } from '~/models/category' import { adminQueryKeys } from '~/query/keys' import { useCategoriesRouteContext } from './categories-route-context' @@ -32,42 +37,34 @@ function parseId(raw: string | undefined): ParsedTarget | null { export function CategoryDetailRoute() { const { id } = useParams<{ id: string }>() - const queryClient = useQueryClient() const ctx = useCategoriesRouteContext() const parsed = parseId(id) - const initialCategory = - parsed?.kind === 'category' - ? findInListCache( - queryClient, - CATEGORY_LIST_KEY, - parsed.value, - ) - : undefined + const category = useEntity( + categories, + parsed?.kind === 'category' ? parsed.value : undefined, + ) - const categoryQuery = useQuery({ + useCollectionDetailQuery(categories, { enabled: parsed?.kind === 'category', - initialData: initialCategory, queryFn: () => getCategory(parsed!.value), queryKey: parsed?.value ? adminQueryKeys.categories.detail(parsed.value) : adminQueryKeys.categories.root, - staleTime: initialCategory ? 30_000 : 0, }) - // Tag detail isn't fetched by id; reach into the list cache to find it. const tagsListCacheQuery = useQuery({ enabled: parsed?.kind === 'tag', queryFn: getTags, queryKey: TAGS_LIST_KEY, }) - // Keep the categories list warm in case the user opened a deep link directly. - useQuery({ - enabled: parsed?.kind === 'category' && !initialCategory, + useCollectionListQuery(categories, { + enabled: parsed?.kind === 'category' && !category, queryFn: () => getCategories({ type: 'Category' }), queryKey: CATEGORY_LIST_KEY, + toPage: (result) => ({ items: result }), }) const dynamicTitle = @@ -75,7 +72,7 @@ export function CategoryDetailRoute() { ? (tagsListCacheQuery.data?.find( (entry: TagModel) => entry.name === parsed.value, )?.name ?? parsed.value) - : (categoryQuery.data?.name ?? undefined) + : (category?.name ?? undefined) useDocumentTitle(dynamicTitle) if (!parsed) return @@ -89,7 +86,6 @@ export function CategoryDetailRoute() { return } - const category = categoryQuery.data if (!category) return return ( diff --git a/apps/admin/src/features/categories/components/CategoryFormModal.tsx b/apps/admin/src/features/categories/components/CategoryFormModal.tsx index a8af37d34f5..f63ed266400 100644 --- a/apps/admin/src/features/categories/components/CategoryFormModal.tsx +++ b/apps/admin/src/features/categories/components/CategoryFormModal.tsx @@ -4,10 +4,9 @@ import type { FormEvent } from 'react' import { useState } from 'react' import { toast } from 'sonner' -import type { CreateCategoryData } from '~/api/categories' -import { createCategory, updateCategory } from '~/api/categories' +import type { CategoryEntity } from '~/data/resources/category' +import { saveCategory } from '~/data/resources/category.mutations' import { useI18n } from '~/i18n' -import type { CategoryModel } from '~/models/category' import { ModalFooter, ModalHeader } from '~/ui/feedback/modal' import { present, useModal } from '~/ui/feedback/modal-imperative' import { Button } from '~/ui/primitives/button' @@ -22,7 +21,7 @@ interface CategoryFormModalProps { function CategoryFormModal(props: CategoryFormModalProps) { const { t } = useI18n() - const modal = useModal() + const modal = useModal() const [name, setName] = useState( props.mode.kind === 'edit' ? props.mode.category.name : '', ) @@ -35,13 +34,17 @@ function CategoryFormModal(props: CategoryFormModalProps) { : t('categories.form.createTitle') const mutation = useMutation({ - mutationFn: (data: CreateCategoryData) => - props.mode.kind === 'edit' - ? updateCategory(props.mode.category.id, { ...data, type: 0 }) - : createCategory(data), + mutationFn: (data: { name: string; slug: string }) => + saveCategory( + props.mode.kind === 'edit' + ? { id: props.mode.category.id, kind: 'edit' } + : { kind: 'create' }, + data, + ), onError: (error: unknown) => toast.error(getErrorMessage(error, t('categories.form.saveFailed'))), onSuccess: (category) => { + if (!category) return toast.success( props.mode.kind === 'edit' ? t('categories.form.updated') @@ -105,8 +108,8 @@ function CategoryFormModal(props: CategoryFormModalProps) { */ export async function presentCategoryForm( mode: CategoryFormMode, -): Promise { - const handle = present( +): Promise { + const handle = present( CategoryFormModal, { mode }, { diff --git a/apps/admin/src/features/categories/components/CategoryRow.tsx b/apps/admin/src/features/categories/components/CategoryRow.tsx index c8df265d6b0..8c0d5806360 100644 --- a/apps/admin/src/features/categories/components/CategoryRow.tsx +++ b/apps/admin/src/features/categories/components/CategoryRow.tsx @@ -1,10 +1,10 @@ import { FolderOpen, Hash } from 'lucide-react' -import type { CategoryModel } from '~/models/category' +import type { CategoryEntity } from '~/data/resources/category' import { cn } from '~/utils/cn' export function CategoryRow(props: { - category: CategoryModel + category: CategoryEntity onSelect: () => void selected: boolean }) { @@ -33,7 +33,7 @@ export function CategoryRow(props: {

- {props.category.count} + {props.category.count ?? 0} ) diff --git a/apps/admin/src/features/categories/components/TagDetail.tsx b/apps/admin/src/features/categories/components/TagDetail.tsx index 04ad77c7b56..f04efa3ae85 100644 --- a/apps/admin/src/features/categories/components/TagDetail.tsx +++ b/apps/admin/src/features/categories/components/TagDetail.tsx @@ -1,9 +1,10 @@ -import { useQuery } from '@tanstack/react-query' import { Tag } from 'lucide-react' -import type { TagModel } from '~/models/category' import { getPostsByTag } from '~/api/categories' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { posts } from '~/data/resources/post' import { useI18n } from '~/i18n' +import type { TagModel } from '~/models/category' import { adminQueryKeys } from '~/query/keys' import { Scroll } from '~/ui/primitives/scroll' @@ -13,11 +14,14 @@ import { PostListSection } from './PostListSection' export function TagDetail(props: { onBack: () => void; tag: TagModel }) { const { t } = useI18n() - const postsQuery = useQuery({ + const postsListKey = adminQueryKeys.posts.tagDetail(props.tag.name) + const postsQuery = useCollectionListQuery(posts, { enabled: !!props.tag.name, queryFn: () => getPostsByTag(props.tag.name), - queryKey: adminQueryKeys.posts.tagDetail(props.tag.name), + queryKey: postsListKey, + toPage: (result) => ({ items: result }), }) + const postsList = useEntityList(posts, postsListKey) return (
@@ -36,7 +40,7 @@ export function TagDetail(props: { onBack: () => void; tag: TagModel }) { void - onDelete: (category: CategoryModel) => void - onEdit: (category: CategoryModel) => void + onDelete: (category: CategoryEntity) => void + onEdit: (category: CategoryEntity) => void } export const CategoriesRouteContext = diff --git a/apps/admin/src/features/categories/hooks/use-categories-list.ts b/apps/admin/src/features/categories/hooks/use-categories-list.ts index d6f59033eee..f2a0ed7e49a 100644 --- a/apps/admin/src/features/categories/hooks/use-categories-list.ts +++ b/apps/admin/src/features/categories/hooks/use-categories-list.ts @@ -1,20 +1,28 @@ import { useQuery } from '@tanstack/react-query' import { getCategories, getTags } from '~/api/categories' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { categories } from '~/data/resources/category' import { adminQueryKeys } from '~/query/keys' export function useCategoriesList() { - const categoriesQuery = useQuery({ - queryFn: () => getCategories({ type: 'Category' }), + const categoriesQuery = useCollectionListQuery(categories, { queryKey: adminQueryKeys.categories.list(), + queryFn: () => getCategories({ type: 'Category' }), + toPage: (result) => ({ items: result }), }) + const categoriesList = useEntityList( + categories, + adminQueryKeys.categories.list(), + ) + const tagsQuery = useQuery({ queryFn: getTags, queryKey: adminQueryKeys.categories.tags(), }) return { - categories: categoriesQuery.data ?? [], + categories: categoriesList.items, categoriesQuery, tags: tagsQuery.data ?? [], tagsQuery, diff --git a/apps/admin/src/features/categories/hooks/use-category-mutations.ts b/apps/admin/src/features/categories/hooks/use-category-mutations.ts index deec2bff616..3450555094e 100644 --- a/apps/admin/src/features/categories/hooks/use-category-mutations.ts +++ b/apps/admin/src/features/categories/hooks/use-category-mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' -import { deleteCategory } from '~/api/categories' +import { removeCategory } from '~/data/resources/category.mutations' import { useI18n } from '~/i18n' import { adminQueryKeys } from '~/query/keys' @@ -25,7 +25,7 @@ export function useCategoryMutations( }, [queryClient]) const deleteMutation = useMutation({ - mutationFn: deleteCategory, + mutationFn: (id: string) => removeCategory(id), onError: (error: unknown) => toast.error(getErrorMessage(error, t('categories.toast.deleteFailed'))), onSuccess: async () => { diff --git a/apps/admin/src/features/categories/types/categories.ts b/apps/admin/src/features/categories/types/categories.ts index 27812179f00..536e86ecbbe 100644 --- a/apps/admin/src/features/categories/types/categories.ts +++ b/apps/admin/src/features/categories/types/categories.ts @@ -1,4 +1,4 @@ -import type { CategoryModel } from '~/models/category' +import type { CategoryEntity } from '~/data/resources/category' export type SelectedItem = | { @@ -15,6 +15,6 @@ export type CategoryFormMode = kind: 'create' } | { - category: CategoryModel + category: CategoryEntity kind: 'edit' } diff --git a/apps/admin/src/features/friends/components/AuditReasonModal.tsx b/apps/admin/src/features/friends/components/AuditReasonModal.tsx index af4ea366ebf..d0d00dad073 100644 --- a/apps/admin/src/features/friends/components/AuditReasonModal.tsx +++ b/apps/admin/src/features/friends/components/AuditReasonModal.tsx @@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { toast } from 'sonner' -import { auditLinkWithReason } from '~/api/links' +import { auditWithReason } from '~/data/resources/link.mutations' import { useI18n } from '~/i18n' import type { LinkModel } from '~/models/link' import { LinkState, LinkStateNameKeys } from '~/models/link' @@ -23,7 +23,7 @@ function AuditReasonModal(props: AuditReasonModalProps) { const [reason, setReason] = useState('') const mutation = useMutation({ - mutationFn: () => auditLinkWithReason(props.link.id, { reason, state }), + mutationFn: () => auditWithReason(props.link.id, reason, state), onSuccess: () => { toast.success(t('friends.toast.auditSent')) modal.close(true) diff --git a/apps/admin/src/features/friends/components/FriendEditorModal.tsx b/apps/admin/src/features/friends/components/FriendEditorModal.tsx index fbe0ca6b28c..78a8c622023 100644 --- a/apps/admin/src/features/friends/components/FriendEditorModal.tsx +++ b/apps/admin/src/features/friends/components/FriendEditorModal.tsx @@ -3,7 +3,7 @@ import type { FormEvent } from 'react' import { useState } from 'react' import { toast } from 'sonner' -import { createLink, updateLink } from '~/api/links' +import { saveLink } from '~/data/resources/link.mutations' import { useI18n } from '~/i18n' import type { LinkModel } from '~/models/link' import { LinkState, LinkStateNameKeys, LinkType } from '~/models/link' @@ -41,8 +41,11 @@ function FriendEditorModal(props: FriendEditorModalProps) { url: url.trim(), } - if (props.link?.id) return updateLink(props.link.id, data) - return createLink(data) + const mode = props.link?.id + ? { id: props.link.id, kind: 'edit' as const } + : { kind: 'create' as const } + + return saveLink(mode, data) }, onSuccess: () => { toast.success(t('friends.toast.saved')) diff --git a/apps/admin/src/features/friends/hooks/use-friend-mutations.ts b/apps/admin/src/features/friends/hooks/use-friend-mutations.ts index 946d3c22948..f09b69b0816 100644 --- a/apps/admin/src/features/friends/hooks/use-friend-mutations.ts +++ b/apps/admin/src/features/friends/hooks/use-friend-mutations.ts @@ -2,12 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' -import { - auditPassLink, - checkLinksHealth, - deleteLink, - migrateLinkAvatars, -} from '~/api/links' +import { checkLinksHealth, migrateLinkAvatars } from '~/api/links' +import { auditPass, removeLink } from '~/data/resources/link.mutations' import { useI18n } from '~/i18n' import { friendsQueryKey } from '../constants' @@ -27,7 +23,7 @@ export function useFriendMutations(options: UseFriendMutationsOptions = {}) { }, [queryClient]) const deleteMutation = useMutation({ - mutationFn: deleteLink, + mutationFn: removeLink, onSuccess: async () => { toast.success(t('friends.toast.deleted')) await invalidateFriends() @@ -35,7 +31,7 @@ export function useFriendMutations(options: UseFriendMutationsOptions = {}) { }) const auditPassMutation = useMutation({ - mutationFn: auditPassLink, + mutationFn: auditPass, onSuccess: async () => { toast.success(t('friends.toast.auditPass')) await invalidateFriends() diff --git a/apps/admin/src/features/friends/hooks/use-friends-list.ts b/apps/admin/src/features/friends/hooks/use-friends-list.ts index 5d4055cae62..683b8fb32c9 100644 --- a/apps/admin/src/features/friends/hooks/use-friends-list.ts +++ b/apps/admin/src/features/friends/hooks/use-friends-list.ts @@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { getLinks, getLinkStateCount } from '~/api/links' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { links } from '~/data/resources/link' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import type { LinkState } from '~/models/link' import { adminQueryKeys } from '~/query/keys' @@ -33,21 +35,28 @@ export function useFriendsList() { const [state, setState] = useUrlListState(urlStateOptions) - const linksQuery = useQuery({ - placeholderData: (previous) => previous, + const linksListKey = adminQueryKeys.links.list({ + page: state.page, + size: friendsPageSize, + state: state.state, + }) + + const linksQuery = useCollectionListQuery(links, { queryFn: () => getLinks({ page: state.page, size: friendsPageSize, state: state.state, }), - queryKey: adminQueryKeys.links.list({ - page: state.page, - size: friendsPageSize, - state: state.state, + queryKey: linksListKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const linksList = useEntityList(links, linksListKey, { keepPrevious: true }) + const countsQuery = useQuery({ queryFn: getLinkStateCount, queryKey: adminQueryKeys.links.stateCount(), @@ -56,10 +65,10 @@ export function useFriendsList() { return { counts: countsQuery.data, countsQuery, - links: linksQuery.data?.data ?? [], + links: linksList.items, linksQuery, page: state.page, - pagination: linksQuery.data?.pagination, + pagination: linksList.pagination, setPage: (page: number | ((current: number) => number)) => { setState((current) => ({ ...current, diff --git a/apps/admin/src/features/notes/hooks/use-note-mutations.ts b/apps/admin/src/features/notes/hooks/use-note-mutations.ts index de050da6bfa..18444da70fb 100644 --- a/apps/admin/src/features/notes/hooks/use-note-mutations.ts +++ b/apps/admin/src/features/notes/hooks/use-note-mutations.ts @@ -1,7 +1,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { deleteNote, patchNote, patchNotePublish } from '~/api/notes' +import { + patchNoteFields, + publishNote, + removeNote, + removeNotes, +} from '~/data/resources/note.mutations' import { useI18n } from '~/i18n' import { notesQueryKey } from '../constants' @@ -22,7 +27,7 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) { const publishMutation = useMutation({ mutationFn: (payload: { id: string; isPublished: boolean }) => - patchNotePublish(payload.id, payload.isPublished), + publishNote(payload.id, payload.isPublished), onError: (error: unknown) => toast.error(getErrorMessage(error, t('notes.toast.updateFailed'))), onSuccess: invalidateNotes, @@ -30,14 +35,14 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) { const patchMutation = useMutation({ mutationFn: (payload: { data: NoteMetadataUpdate; id: string }) => - patchNote(payload.id, payload.data), + patchNoteFields(payload.id, payload.data), onError: (error: unknown) => toast.error(getErrorMessage(error, t('notes.toast.updateFailed'))), onSuccess: invalidateNotes, }) const deleteMutation = useMutation({ - mutationFn: deleteNote, + mutationFn: (id: string) => removeNote(id), onError: (error: unknown) => toast.error(getErrorMessage(error, t('notes.toast.deleteFailed'))), onSuccess: async () => { @@ -47,18 +52,7 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) { }) const batchDeleteMutation = useMutation({ - mutationFn: async (ids: string[]) => { - const results = await Promise.allSettled(ids.map((id) => deleteNote(id))) - const successfulIds = ids.filter( - (_, index) => results[index].status === 'fulfilled', - ) - - return { - failedCount: ids.length - successfulIds.length, - successfulIds, - successCount: successfulIds.length, - } - }, + mutationFn: (ids: string[]) => removeNotes(ids), onError: (error: unknown) => toast.error(getErrorMessage(error, t('notes.toast.batchDeleteFailed'))), onSuccess: async ({ failedCount, successCount }) => { diff --git a/apps/admin/src/features/notes/hooks/use-notes-list.ts b/apps/admin/src/features/notes/hooks/use-notes-list.ts index fc44e05f10d..1ce1c8d1e01 100644 --- a/apps/admin/src/features/notes/hooks/use-notes-list.ts +++ b/apps/admin/src/features/notes/hooks/use-notes-list.ts @@ -2,7 +2,11 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { getNotes, searchNotes } from '~/api/notes' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { notes } from '~/data/resources/note' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' +import type { Pager } from '~/models/base' +import type { NoteModel } from '~/models/note' import { adminQueryKeys } from '~/query/keys' import { notesPageSize, notesQueryKey } from '../constants' @@ -53,38 +57,58 @@ export function useNotesList() { setKeywordInput(state.keyword) }, [state.keyword]) - const notesQuery = useQuery({ - placeholderData: (previous) => previous, + const notesListKey = adminQueryKeys.notes.list({ + filter: state.filter, + keyword: state.keyword, + page: state.page, + size: notesPageSize, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }) + const collectionQuery = useCollectionListQuery(notes, { + enabled: !state.keyword, queryFn: () => - state.keyword - ? searchNotes({ - keyword: state.keyword, + state.filter === 'all' + ? getNotes({ page: state.page, size: notesPageSize, + sort_by: state.sortKey, + sort_order: state.sortOrder, }) - : state.filter === 'all' - ? getNotes({ - page: state.page, - size: notesPageSize, - sort_by: state.sortKey, - sort_order: state.sortOrder, - }) - : getFilteredNotes({ - filter: state.filter, - page: state.page, - size: notesPageSize, - sortKey: state.sortKey, - sortOrder: state.sortOrder, - }), - queryKey: adminQueryKeys.notes.list({ - filter: state.filter, - keyword: state.keyword, - page: state.page, - size: notesPageSize, - sortKey: state.sortKey, - sortOrder: state.sortOrder, + : getFilteredNotes({ + filter: state.filter, + page: state.page, + size: notesPageSize, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }), + queryKey: notesListKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const notesList = useEntityList(notes, notesListKey, { keepPrevious: true }) + + const searchQuery = useQuery({ + enabled: !!state.keyword, + placeholderData: (previous) => previous, + queryFn: () => + searchNotes({ + keyword: state.keyword, + page: state.page, + size: notesPageSize, + }), + queryKey: [...notesListKey, 'search'], + }) + + const notesQuery = state.keyword ? searchQuery : collectionQuery + const searchItems: NoteModel[] = state.keyword + ? (searchQuery.data?.data ?? []) + : [] + const searchPagination: Pager | undefined = state.keyword + ? searchQuery.data?.pagination + : undefined return { clearSearch: () => { @@ -94,10 +118,10 @@ export function useNotesList() { filter: state.filter, keyword: state.keyword, keywordInput, - notes: notesQuery.data?.data ?? [], + notes: state.keyword ? searchItems : notesList.items, notesQuery, page: state.page, - pagination: notesQuery.data?.pagination, + pagination: state.keyword ? searchPagination : notesList.pagination, rootQueryKey: notesQueryKey, setFilter: (filter: NoteFilter) => setState((current) => ({ ...current, filter, page: 1 })), diff --git a/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx b/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx index 74729d51133..aaa01507c6d 100644 --- a/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx +++ b/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx @@ -1,10 +1,16 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { FileText, Plus, RefreshCw } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' -import { deletePage, getPages, reorderPages } from '~/api/pages' +import { getPages } from '~/api/pages' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { pages } from '~/data/resources/page' +import { + removePage, + reorderPagesOptimistic, +} from '~/data/resources/page.mutations' import { useI18n } from '~/i18n' import type { PageModel } from '~/models/page' import { adminQueryKeys } from '~/query/keys' @@ -22,18 +28,29 @@ import { PagesEmpty } from './PagesEmpty' import { PagesError } from './PagesError' import { PagesSkeleton } from './PagesSkeleton' +const pagesListQueryKey = adminQueryKeys.pages.list({ page: 1, size: 100 }) + export function PagesRouteViewContent() { const { t } = useI18n() const queryClient = useQueryClient() - const [orderedPages, setOrderedPages] = useState([]) const [draggingId, setDraggingId] = useState('') - const pagesQuery = useQuery({ + + const pagesQuery = useCollectionListQuery(pages, { queryFn: () => getPages({ page: 1, size: 100 }), - queryKey: adminQueryKeys.pages.list({ page: 1, size: 100 }), + queryKey: pagesListQueryKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, + }), }) + const pagesList = useEntityList(pages, pagesListQueryKey) + const orderedPages = useMemo( + () => [...pagesList.items].sort((a, b) => (b.order ?? 0) - (a.order ?? 0)), + [pagesList.items], + ) const deleteMutation = useMutation({ - mutationFn: deletePage, + mutationFn: removePage, onError: (error: unknown) => toast.error(getErrorMessage(error, t('pages.toast.deleteFailed'))), onSuccess: async () => { @@ -42,18 +59,10 @@ export function PagesRouteViewContent() { }, }) - const pages = pagesQuery.data?.data ?? [] - - useEffect(() => { - setOrderedPages(pages) - }, [pages]) - const reorderMutation = useMutation({ - mutationFn: reorderPages, - onError: (error: unknown) => { - setOrderedPages(pages) - toast.error(getErrorMessage(error, t('pages.toast.reorderFailed'))) - }, + mutationFn: reorderPagesOptimistic, + onError: (error: unknown) => + toast.error(getErrorMessage(error, t('pages.toast.reorderFailed'))), onSuccess: async () => { toast.success(t('pages.toast.reorderSaved')) await queryClient.invalidateQueries({ queryKey: pagesQueryKey }) @@ -61,7 +70,6 @@ export function PagesRouteViewContent() { }) const commitReorder = (nextPages: PageModel[]) => { - setOrderedPages(nextPages) const seq = [...nextPages] .reverse() .map((page, index) => ({ id: page.id, order: index + 1 })) @@ -97,7 +105,7 @@ export function PagesRouteViewContent() {
{t('pages.header.count', { - count: pagesQuery.data?.pagination.total ?? 0, + count: pagesList.pagination?.total ?? 0, })} @@ -123,7 +131,7 @@ export function PagesRouteViewContent() {
- {pagesQuery.isLoading ? ( + {pagesQuery.isLoading && orderedPages.length === 0 ? ( ) : pagesQuery.isError ? ( void pagesQuery.refetch()} /> diff --git a/apps/admin/src/features/posts/hooks/use-post-mutations.ts b/apps/admin/src/features/posts/hooks/use-post-mutations.ts index 49ea60871b1..709f765b1c1 100644 --- a/apps/admin/src/features/posts/hooks/use-post-mutations.ts +++ b/apps/admin/src/features/posts/hooks/use-post-mutations.ts @@ -1,7 +1,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { deletePost, patchPost } from '~/api/posts' +import { + movePostCategory, + pinPost, + publishPost, + removePost, + removePosts, +} from '~/data/resources/post.mutations' import { useI18n } from '~/i18n' import { postsQueryKey } from '../constants' @@ -21,13 +27,13 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) { const publishMutation = useMutation({ mutationFn: (payload: { id: string; isPublished: boolean }) => - patchPost(payload.id, { isPublished: payload.isPublished }), + publishPost(payload.id, payload.isPublished), onSuccess: invalidatePosts, }) const categoryMutation = useMutation({ mutationFn: (payload: { categoryId: string; id: string }) => - patchPost(payload.id, { categoryId: payload.categoryId }), + movePostCategory(payload.id, payload.categoryId), onError: (error: unknown) => toast.error( getErrorMessage(error, t('posts.toast.categoryUpdateFailed')), @@ -36,7 +42,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) { }) const deleteMutation = useMutation({ - mutationFn: deletePost, + mutationFn: (id: string) => removePost(id), onSuccess: async () => { toast.success(t('posts.toast.deleted')) await invalidatePosts() @@ -44,18 +50,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) { }) const batchDeleteMutation = useMutation({ - mutationFn: async (ids: string[]) => { - const results = await Promise.allSettled(ids.map((id) => deletePost(id))) - const successfulIds = ids.filter( - (_, index) => results[index].status === 'fulfilled', - ) - - return { - failedCount: ids.length - successfulIds.length, - successfulIds, - successCount: successfulIds.length, - } - }, + mutationFn: (ids: string[]) => removePosts(ids), onError: (error: unknown) => toast.error(getErrorMessage(error, t('posts.toast.batchDeleteFailed'))), onSuccess: async ({ failedCount, successCount }) => { @@ -78,9 +73,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) { const pinMutation = useMutation({ mutationFn: (payload: { id: string; isPinned: boolean }) => - patchPost(payload.id, { - pinAt: payload.isPinned ? new Date().toISOString() : null, - }), + pinPost(payload.id, payload.isPinned), onError: (error: unknown) => toast.error(getErrorMessage(error, t('posts.toast.pinFailed'))), onSuccess: invalidatePosts, diff --git a/apps/admin/src/features/posts/hooks/use-posts-list.ts b/apps/admin/src/features/posts/hooks/use-posts-list.ts index e7805e6a71b..f8b5f73521d 100644 --- a/apps/admin/src/features/posts/hooks/use-posts-list.ts +++ b/apps/admin/src/features/posts/hooks/use-posts-list.ts @@ -3,7 +3,12 @@ import { useEffect, useMemo, useState } from 'react' import { getCategories } from '~/api/categories' import { getPosts, searchPosts } from '~/api/posts' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { categories } from '~/data/resources/category' +import { posts } from '~/data/resources/post' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' +import type { Pager } from '~/models/base' +import type { PostModel } from '~/models/post' import { adminQueryKeys } from '~/query/keys' import { allCategoriesValue, postsPageSize, postsQueryKey } from '../constants' @@ -54,42 +59,67 @@ export function usePostsList() { setKeywordInput(state.keyword) }, [state.keyword]) - const categoriesQuery = useQuery({ + const categoriesQuery = useCollectionListQuery(categories, { queryFn: () => getCategories({ type: 'Category' }), queryKey: adminQueryKeys.categories.postFilter(), + toPage: (result) => ({ items: result }), }) + const categoriesList = useEntityList( + categories, + adminQueryKeys.categories.postFilter(), + ) - const postsQuery = useQuery({ - placeholderData: (previous) => previous, + const postsListKey = adminQueryKeys.posts.list({ + categoryId: state.categoryId, + keyword: state.keyword, + page: state.page, + size: postsPageSize, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }) + const collectionQuery = useCollectionListQuery(posts, { + enabled: !state.keyword, queryFn: () => - state.keyword - ? searchPosts({ - keyword: state.keyword, - page: state.page, - size: postsPageSize, - }) - : getPosts({ - categoryIds: - state.categoryId === allCategoriesValue - ? undefined - : [state.categoryId], - page: state.page, - size: postsPageSize, - sort_by: state.sortKey, - sort_order: state.sortOrder, - }), - queryKey: adminQueryKeys.posts.list({ - categoryId: state.categoryId, - keyword: state.keyword, - page: state.page, - size: postsPageSize, - sortKey: state.sortKey, - sortOrder: state.sortOrder, + getPosts({ + categoryIds: + state.categoryId === allCategoriesValue + ? undefined + : [state.categoryId], + page: state.page, + size: postsPageSize, + sort_by: state.sortKey, + sort_order: state.sortOrder, + }), + queryKey: postsListKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const postsList = useEntityList(posts, postsListKey, { keepPrevious: true }) + + const searchQuery = useQuery({ + enabled: !!state.keyword, + placeholderData: (previous) => previous, + queryFn: () => + searchPosts({ + keyword: state.keyword, + page: state.page, + size: postsPageSize, + }), + queryKey: [...postsListKey, 'search'], + }) + + const postsQuery = state.keyword ? searchQuery : collectionQuery + const searchItems: PostModel[] = state.keyword + ? (searchQuery.data?.data ?? []) + : [] + const searchPagination: Pager | undefined = state.keyword + ? searchQuery.data?.pagination + : undefined return { - categories: categoriesQuery.data ?? [], + categories: categoriesList.items, categoriesQuery, categoryId: state.categoryId, clearSearch: () => { @@ -99,8 +129,8 @@ export function usePostsList() { keyword: state.keyword, keywordInput, page: state.page, - pagination: postsQuery.data?.pagination, - posts: postsQuery.data?.data ?? [], + pagination: state.keyword ? searchPagination : postsList.pagination, + posts: state.keyword ? searchItems : postsList.items, postsQuery, rootQueryKey: postsQueryKey, setCategoryId: (categoryId: string) => diff --git a/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx b/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx index f9c996a03bf..165ecffc5c3 100644 --- a/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx +++ b/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx @@ -1,13 +1,11 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' import { useParams, useSearchParams } from 'react-router' -import { findInListCache } from '~/api/list-cache' import { getProject } from '~/api/projects' +import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks' +import { projects } from '~/data/resources/project' import { useDocumentTitle } from '~/hooks/use-document-title' -import type { ProjectModel } from '~/models/project' import { adminQueryKeys } from '~/query/keys' -import { projectsQueryKey } from '../constants' import { ProjectDetailPanel } from './ProjectDetailPanel' import { ProjectFormPanel } from './ProjectFormPanel' import { @@ -16,30 +14,24 @@ import { } from './ProjectPrimitives' import { useProjectsRouteContext } from './projects-route-context' -const LIST_PREFIX = [...projectsQueryKey, 'list'] as const - export function ProjectDetailRoute() { const { id } = useParams<{ id: string }>() const [searchParams] = useSearchParams() - const queryClient = useQueryClient() const ctx = useProjectsRouteContext() const isCreating = id === 'new' const isEditing = !isCreating && searchParams.get('edit') === '1' - const initialProject = - !isCreating && id - ? findInListCache(queryClient, LIST_PREFIX, id) - : undefined + const project = useEntity(projects, isCreating ? undefined : id) - const projectQuery = useQuery({ + const projectQuery = useCollectionDetailQuery(projects, { enabled: Boolean(id) && !isCreating, - initialData: initialProject, queryFn: () => getProject(id!), - queryKey: id ? adminQueryKeys.projects.detail(id) : projectsQueryKey, - staleTime: initialProject ? 30_000 : 0, + queryKey: id + ? adminQueryKeys.projects.detail(id) + : adminQueryKeys.projects.root, }) - useDocumentTitle(projectQuery.data?.name) + useDocumentTitle(project?.name) if (isCreating) { return ( @@ -57,7 +49,7 @@ export function ProjectDetailRoute() { if (!id) return - if (projectQuery.isLoading && !projectQuery.data) { + if (projectQuery.isLoading && !project) { return (
@@ -65,7 +57,7 @@ export function ProjectDetailRoute() { ) } - if (!projectQuery.data) return + if (!project) return if (isEditing) { return ( @@ -75,7 +67,7 @@ export function ProjectDetailRoute() { onCancel={ctx.onStopEditing} onMobileBack={ctx.onMobileBack} onSuccess={ctx.onSaved} - project={projectQuery.data} + project={project} />
) @@ -88,7 +80,7 @@ export function ProjectDetailRoute() { onDeleted={ctx.onDeleted} onEdit={ctx.onEdit} onMobileBack={ctx.onMobileBack} - project={projectQuery.data} + project={project} /> ) diff --git a/apps/admin/src/features/projects/components/ProjectFormPanel.tsx b/apps/admin/src/features/projects/components/ProjectFormPanel.tsx index d3509f2cb8c..a52cbed683d 100644 --- a/apps/admin/src/features/projects/components/ProjectFormPanel.tsx +++ b/apps/admin/src/features/projects/components/ProjectFormPanel.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import type { GithubRepo } from '~/api/github-repo' -import { createProject, updateProject } from '~/api/projects' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import { saveProject } from '~/data/resources/project.mutations' import { useI18n } from '~/i18n' import type { ProjectModel } from '~/models/project' import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance' @@ -42,10 +42,11 @@ export function ProjectFormPanel(props: { const mutation = useMutation({ mutationFn: async () => { const payload = formToPayload(form) + const mode = props.project?.id + ? { id: props.project.id, kind: 'edit' as const } + : { kind: 'create' as const } - if (props.project?.id) return updateProject(props.project.id, payload) - - return createProject(payload) + return saveProject(mode, payload) }, onSuccess: async (project) => { toast.success( diff --git a/apps/admin/src/features/projects/hooks/use-project-mutations.ts b/apps/admin/src/features/projects/hooks/use-project-mutations.ts index f01ff6d883c..ce208ac25e0 100644 --- a/apps/admin/src/features/projects/hooks/use-project-mutations.ts +++ b/apps/admin/src/features/projects/hooks/use-project-mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' -import { deleteProject } from '~/api/projects' +import { removeProject } from '~/data/resources/project.mutations' import { useI18n } from '~/i18n' import { projectsQueryKey } from '../constants' @@ -20,7 +20,7 @@ export function useProjectMutations(options: UseProjectMutationsOptions = {}) { }, [queryClient]) const deleteMutation = useMutation({ - mutationFn: deleteProject, + mutationFn: removeProject, onSuccess: async () => { toast.success(t('projects.detail.deleteSuccess')) await options.onDeleted?.() diff --git a/apps/admin/src/features/projects/hooks/use-projects-list.ts b/apps/admin/src/features/projects/hooks/use-projects-list.ts index 0d394187bb8..cf7399f17e1 100644 --- a/apps/admin/src/features/projects/hooks/use-projects-list.ts +++ b/apps/admin/src/features/projects/hooks/use-projects-list.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { getProjects } from '~/api/projects' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { projects } from '~/data/resources/project' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import { adminQueryKeys } from '~/query/keys' @@ -29,19 +30,28 @@ export function useProjectsList() { const [state, setState] = useUrlListState(urlStateOptions) - const projectsQuery = useQuery({ - placeholderData: (previous) => previous, + const projectsListKey = adminQueryKeys.projects.list({ + page: state.page, + size: projectsPageSize, + }) + + const projectsQuery = useCollectionListQuery(projects, { queryFn: () => getProjects({ page: state.page, size: projectsPageSize }), - queryKey: adminQueryKeys.projects.list({ - page: state.page, - size: projectsPageSize, + queryKey: projectsListKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const projectsList = useEntityList(projects, projectsListKey, { + keepPrevious: true, + }) + return { page: state.page, - pagination: projectsQuery.data?.pagination, - projects: projectsQuery.data?.data ?? [], + pagination: projectsList.pagination, + projects: projectsList.items, projectsQuery, setPage: (page: number) => setState({ page }), } diff --git a/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx b/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx index 96268cc69c7..5b54858f0ef 100644 --- a/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx +++ b/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { resolveEnrichment } from '~/api/enrichment' -import { createRecently, updateRecently } from '~/api/recently' +import { saveRecently } from '~/data/resources/recently.mutations' import { useI18n } from '~/i18n' import type { RecentlyModel } from '~/models/recently' import { ModalFooter, ModalHeader } from '~/ui/feedback/modal' @@ -82,8 +82,12 @@ function RecentlyEditorModal(props: RecentlyEditorModalProps) { const mutation = useMutation({ mutationFn: async () => { const data = { content: content.trim() } - if (props.item?.id) return updateRecently(props.item.id, data) - return createRecently(data) + return saveRecently( + props.item?.id + ? { id: props.item.id, kind: 'edit' } + : { kind: 'create' }, + data, + ) }, onSuccess: () => { toast.success(t('recently.editor.saveSuccess')) diff --git a/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx b/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx index fa5f0f78837..54c7671392e 100644 --- a/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx +++ b/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx @@ -1,16 +1,20 @@ -import type { InfiniteData } from '@tanstack/react-query' -import { - useInfiniteQuery, - useMutation, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, StickyNote } from 'lucide-react' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useRef } from 'react' import { useSearchParams } from 'react-router' import { toast } from 'sonner' -import { deleteRecently, getRecentlyList } from '~/api/recently' +import { getRecentlyList } from '~/api/recently' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import { + useCollectionInfiniteQuery, + useEntityList, +} from '~/data/resource/hooks' +import { recentlies } from '~/data/resources/recently' +import { + applyRecentlyEnrichment, + removeRecently, +} from '~/data/resources/recently.mutations' import { useI18n } from '~/i18n' import type { EnrichmentResult } from '~/models/enrichment' import type { RecentlyModel } from '~/models/recently' @@ -40,28 +44,28 @@ export function RecentlyRouteViewContent() { const scrollContainerRef = useRef(null) const loadMoreRef = useRef(null) - const recentlyQuery = useInfiniteQuery< + const recentlyQuery = useCollectionInfiniteQuery< + RecentlyModel, RecentlyModel[], - Error, - InfiniteData, - typeof recentlyListQueryKey, null | string - >({ - getNextPageParam: (lastPage) => - lastPage.length >= RECENTLY_PAGE_SIZE - ? (lastPage.at(-1)?.id ?? null) - : null, + >(recentlies, { + getNextPageParam: (lastResult) => + lastResult.length >= RECENTLY_PAGE_SIZE + ? (lastResult.at(-1)?.id ?? undefined) + : undefined, initialPageParam: null as null | string, - queryFn: ({ pageParam }) => + queryFn: (pageParam) => getRecentlyList({ before: pageParam ?? undefined, size: RECENTLY_PAGE_SIZE, }), queryKey: recentlyListQueryKey, + toItems: (result) => result, }) + const recentlyList = useEntityList(recentlies, recentlyListQueryKey) const deleteMutation = useMutation({ - mutationFn: deleteRecently, + mutationFn: removeRecently, onSuccess: async () => { toast.success(t('recently.deleteSuccess')) await queryClient.invalidateQueries({ queryKey: recentlyQueryKey }) @@ -90,34 +94,10 @@ export function RecentlyRouteViewContent() { url: string, enrichment: EnrichmentResult, ) => { - queryClient.setQueryData>( - recentlyListQueryKey, - (current) => - current - ? { - ...current, - pages: current.pages.map((page) => - page.map((item) => - item.id === itemId - ? { - ...item, - enrichments: { - ...item.enrichments, - [url]: enrichment, - }, - } - : item, - ), - ), - } - : current, - ) + applyRecentlyEnrichment(itemId, url, enrichment) } - const items = useMemo( - () => recentlyQuery.data?.pages.flat() ?? [], - [recentlyQuery.data], - ) + const items = recentlyList.items const hasNextPage = Boolean(recentlyQuery.hasNextPage) useEffect(() => { diff --git a/apps/admin/src/features/says/components/SayEditorModal.tsx b/apps/admin/src/features/says/components/SayEditorModal.tsx index df225d27488..fbdb6b11855 100644 --- a/apps/admin/src/features/says/components/SayEditorModal.tsx +++ b/apps/admin/src/features/says/components/SayEditorModal.tsx @@ -3,7 +3,7 @@ import type { FormEvent } from 'react' import { useState } from 'react' import { toast } from 'sonner' -import { createSay, updateSay } from '~/api/says' +import { saveSay } from '~/data/resources/say.mutations' import { useI18n } from '~/i18n' import type { SayModel } from '~/models/say' import { ModalFooter, ModalHeader } from '~/ui/feedback/modal' @@ -32,9 +32,10 @@ function SayEditorModal(props: SayEditorModalProps) { text: text.trim(), } - if (props.say?.id) return updateSay(props.say.id, data) - - return createSay(data) + return saveSay( + props.say?.id ? { id: props.say.id, kind: 'edit' } : { kind: 'create' }, + data, + ) }, onSuccess: () => { toast.success( diff --git a/apps/admin/src/features/says/hooks/use-say-mutations.ts b/apps/admin/src/features/says/hooks/use-say-mutations.ts index 8e6f3560db9..52b115ecde8 100644 --- a/apps/admin/src/features/says/hooks/use-say-mutations.ts +++ b/apps/admin/src/features/says/hooks/use-say-mutations.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { deleteSay } from '~/api/says' +import { removeSay } from '~/data/resources/say.mutations' import { useI18n } from '~/i18n' import { saysQueryKey } from '../constants' @@ -15,7 +15,7 @@ export function useSayMutations() { } const deleteMutation = useMutation({ - mutationFn: deleteSay, + mutationFn: removeSay, onSuccess: async () => { toast.success(t('says.deleteSuccess')) await invalidateSays() diff --git a/apps/admin/src/features/says/hooks/use-says-list.ts b/apps/admin/src/features/says/hooks/use-says-list.ts index efc2da28b6a..499b3d0fa08 100644 --- a/apps/admin/src/features/says/hooks/use-says-list.ts +++ b/apps/admin/src/features/says/hooks/use-says-list.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { getSays } from '~/api/says' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { says } from '~/data/resources/say' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import { adminQueryKeys } from '~/query/keys' @@ -29,19 +30,25 @@ export function useSaysList() { const [state, setState] = useUrlListState(urlStateOptions) - const saysQuery = useQuery({ - placeholderData: (previous) => previous, + const listKey = adminQueryKeys.says.list({ + page: state.page, + size: saysPageSize, + }) + + const saysQuery = useCollectionListQuery(says, { queryFn: () => getSays({ page: state.page, size: saysPageSize }), - queryKey: adminQueryKeys.says.list({ - page: state.page, - size: saysPageSize, + queryKey: listKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const saysList = useEntityList(says, listKey, { keepPrevious: true }) return { page: state.page, - pagination: saysQuery.data?.pagination, - says: saysQuery.data?.data ?? [], + pagination: saysList.pagination, + says: saysList.items, saysQuery, setPage: (page: number) => setState({ page }), } diff --git a/apps/admin/src/features/settings/components/account/SessionSection.tsx b/apps/admin/src/features/settings/components/account/SessionSection.tsx index 0c20ac88395..8c968b14f77 100644 --- a/apps/admin/src/features/settings/components/account/SessionSection.tsx +++ b/apps/admin/src/features/settings/components/account/SessionSection.tsx @@ -2,8 +2,8 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { Globe, Shield } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' -import type { AccountSession } from '../../types/settings' +import { resetAllCollections } from '~/data/resource/collection' import { IpInfoPopover } from '~/features/_shared/components/ip-info-popover' import { useI18n } from '~/i18n' import { adminQueryKeys } from '~/query/keys' @@ -11,6 +11,7 @@ import { Button } from '~/ui/primitives/button' import { authClient } from '~/utils/authjs/auth' import { cn } from '~/utils/cn' +import type { AccountSession } from '../../types/settings' import { listSessions } from '../../utils/account-sessions' import { formatDateTime, getErrorMessage } from '../../utils/settings' import { SettingsSection } from '../SettingsPrimitives' @@ -31,6 +32,7 @@ export function SessionSection() { throw new Error( result.error.message || t('settings.session.error.revokeFailed'), ) + resetAllCollections() } else { const result = await authClient.revokeSession({ token: session.token }) if (result.error) diff --git a/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx b/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx index 900ec146782..4d417a9fb54 100644 --- a/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx +++ b/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router' import { toast } from 'sonner' +import { resetAllCollections } from '~/data/resource/collection' import { useI18n } from '~/i18n' import { ModalHeader } from '~/ui/feedback/modal' import { present, useModal } from '~/ui/feedback/modal-imperative' @@ -42,6 +43,7 @@ function ChangePasswordModal() { toast.success(t('settings.password.success')) modal.close(true) await authClient.signOut() + resetAllCollections() navigate('/login') }, }) diff --git a/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx b/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx index a0ed1b95227..92eee6405de 100644 --- a/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx +++ b/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx @@ -1,15 +1,17 @@ +import type { InfiniteData } from '@tanstack/react-query' import { useInfiniteQuery, useMutation } from '@tanstack/react-query' import { Check, Inbox, Loader2, Plus } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' -import type { InfiniteData } from '@tanstack/react-query' -import type { PaginateResult } from '~/models/base' -import type { NoteModel } from '~/models/note' -import { getNotes, patchNote } from '~/api/notes' +import { getNotes } from '~/api/notes' +import { notes as notesCollection } from '~/data/resources/note' +import { patchNoteFields } from '~/data/resources/note.mutations' import { useI18n } from '~/i18n' -import { ModalHeader } from '~/ui/feedback/modal' +import type { PaginateResult } from '~/models/base' +import type { NoteModel } from '~/models/note' import { adminQueryKeys } from '~/query/keys' +import { ModalHeader } from '~/ui/feedback/modal' import { present, useModal } from '~/ui/feedback/modal-imperative' import { Button } from '~/ui/primitives/button' import { Scroll } from '~/ui/primitives/scroll' @@ -41,11 +43,14 @@ function AddNotesToTopicModal(props: AddNotesToTopicModalProps) { ? lastPage.pagination.page + 1 : undefined, initialPageParam: 1, - queryFn: ({ pageParam }) => - getNotes({ + queryFn: async ({ pageParam }) => { + const result = await getNotes({ page: pageParam, size: topicPickerPageSize, - }), + }) + notesCollection.hydrate(result.data) + return result + }, queryKey: adminQueryKeys.notes.topicPicker(props.topicId), }) @@ -74,7 +79,9 @@ function AddNotesToTopicModal(props: AddNotesToTopicModalProps) { mutationFn: async () => { const noteIds = Array.from(selectedIds) await Promise.all( - noteIds.map((noteId) => patchNote(noteId, { topicId: props.topicId })), + noteIds.map((noteId) => + patchNoteFields(noteId, { topicId: props.topicId }), + ), ) }, onError: (error: unknown) => diff --git a/apps/admin/src/features/topics/components/TopicDetail.tsx b/apps/admin/src/features/topics/components/TopicDetail.tsx index 7544e77b0b2..6243981fd65 100644 --- a/apps/admin/src/features/topics/components/TopicDetail.tsx +++ b/apps/admin/src/features/topics/components/TopicDetail.tsx @@ -1,12 +1,21 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, Edit3, Loader2, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { patchNote } from '~/api/notes' import { getNotesByTopic, getTopic } from '~/api/topics' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import { + useCollectionDetailQuery, + useCollectionListQuery, + useEntity, + useEntityList, +} from '~/data/resource/hooks' +import { notes as notesCollection } from '~/data/resources/note' +import { patchNoteFields } from '~/data/resources/note.mutations' +import { topics } from '~/data/resources/topic' import { useI18n } from '~/i18n' +import type { NoteModel } from '~/models/note' import type { TopicModel } from '~/models/topic' import { adminQueryKeys } from '~/query/keys' import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance' @@ -33,34 +42,41 @@ export function TopicDetail(props: { const queryClient = useQueryClient() const [notesPage, setNotesPage] = useState(1) - const topicQuery = useQuery({ + const topic = useEntity(topics, props.topicId) + const topicQuery = useCollectionDetailQuery(topics, { queryFn: () => getTopic(props.topicId), queryKey: adminQueryKeys.topics.detail(props.topicId), }) - const notesQuery = useQuery({ - placeholderData: (previous) => previous, + const topicNotesKey = adminQueryKeys.topics.notes({ + page: notesPage, + size: topicNotesPageSize, + topicId: props.topicId, + }) + const notesQuery = useCollectionListQuery(notesCollection, { queryFn: () => getNotesByTopic(props.topicId, { page: notesPage, size: topicNotesPageSize, }), - queryKey: adminQueryKeys.topics.notes({ - page: notesPage, - size: topicNotesPageSize, - topicId: props.topicId, + queryKey: topicNotesKey, + toPage: (result) => ({ + items: result.data as NoteModel[], + pagination: result.pagination, }), }) + const notesList = useEntityList(notesCollection, topicNotesKey, { + keepPrevious: true, + }) useEffect(() => { setNotesPage(1) }, [props.topicId]) - const topic = topicQuery.data - const notes = notesQuery.data?.data ?? [] - const notesPagination = notesQuery.data?.pagination + const notes = notesList.items + const notesPagination = notesList.pagination const removeNoteMutation = useMutation({ - mutationFn: (noteId: string) => patchNote(noteId, { topicId: null }), + mutationFn: (noteId: string) => patchNoteFields(noteId, { topicId: null }), onError: (error: unknown) => toast.error(getErrorMessage(error, t('topics.detail.removeRefFailed'))), onSuccess: async () => { diff --git a/apps/admin/src/features/topics/components/TopicDetailRoute.tsx b/apps/admin/src/features/topics/components/TopicDetailRoute.tsx index f52b7b3a172..355a443e061 100644 --- a/apps/admin/src/features/topics/components/TopicDetailRoute.tsx +++ b/apps/admin/src/features/topics/components/TopicDetailRoute.tsx @@ -1,40 +1,30 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' import { useParams } from 'react-router' -import { findInListCache } from '~/api/list-cache' import { getTopic } from '~/api/topics' +import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks' +import { topics } from '~/data/resources/topic' import { useDocumentTitle } from '~/hooks/use-document-title' -import type { TopicModel } from '~/models/topic' import { adminQueryKeys } from '~/query/keys' import { TopicDetail } from './TopicDetail' import { TopicDetailEmpty } from './TopicDetailEmpty' import { useTopicsRouteContext } from './topics-route-context' -const LIST_PREFIX = adminQueryKeys.topics.listRoot - export function TopicDetailRoute() { const { id } = useParams<{ id: string }>() - const queryClient = useQueryClient() const ctx = useTopicsRouteContext() - const initialTopic = id - ? findInListCache(queryClient, LIST_PREFIX, id) - : undefined + const topic = useEntity(topics, id) - // Prime the detail cache so the inner TopicDetail useQuery resolves - // synchronously on mount. - const topicQuery = useQuery({ + useCollectionDetailQuery(topics, { enabled: Boolean(id), - initialData: initialTopic, queryFn: () => getTopic(id!), queryKey: id ? adminQueryKeys.topics.detail(id) : adminQueryKeys.topics.root, - staleTime: initialTopic ? 30_000 : 0, }) - useDocumentTitle(topicQuery.data?.name) + useDocumentTitle(topic?.name) if (!id) return diff --git a/apps/admin/src/features/topics/components/TopicFormModal.tsx b/apps/admin/src/features/topics/components/TopicFormModal.tsx index fd2e04daa87..b85f169bd21 100644 --- a/apps/admin/src/features/topics/components/TopicFormModal.tsx +++ b/apps/admin/src/features/topics/components/TopicFormModal.tsx @@ -6,7 +6,8 @@ import { toast } from 'sonner' import { uploadFile } from '~/api/files' import type { CreateTopicData } from '~/api/topics' -import { createTopic, getTopic, updateTopic } from '~/api/topics' +import { getTopic } from '~/api/topics' +import { saveTopic } from '~/data/resources/topic.mutations' import { useI18n } from '~/i18n' import type { TopicModel } from '~/models/topic' import { adminQueryKeys } from '~/query/keys' @@ -32,7 +33,7 @@ function TopicFormModal(props: TopicFormModalProps) { enabled: isEdit, queryFn: () => getTopic(editId ?? ''), queryKey: editId - ? adminQueryKeys.topics.detail(editId) + ? [...adminQueryKeys.topics.detail(editId), 'form'] : adminQueryKeys.topics.root, }) const [name, setName] = useState('') @@ -63,10 +64,14 @@ function TopicFormModal(props: TopicFormModalProps) { const mutation = useMutation({ mutationFn: (data: CreateTopicData) => - editId ? updateTopic(editId, data) : createTopic(data), + saveTopic( + editId ? { id: editId, kind: 'edit' } : { kind: 'create' }, + data, + ), onError: (error: unknown) => toast.error(getErrorMessage(error, t('topics.form.saveFailed'))), onSuccess: (topic) => { + if (!topic) return toast.success( isEdit ? t('topics.form.savedUpdated') : t('topics.form.savedCreated'), ) diff --git a/apps/admin/src/features/topics/hooks/use-topic-mutations.ts b/apps/admin/src/features/topics/hooks/use-topic-mutations.ts index 56b5b9bdd2b..99cf1ebfa6d 100644 --- a/apps/admin/src/features/topics/hooks/use-topic-mutations.ts +++ b/apps/admin/src/features/topics/hooks/use-topic-mutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' -import { deleteTopic } from '~/api/topics' +import { removeTopic, removeTopics } from '~/data/resources/topic.mutations' import { useI18n } from '~/i18n' import { topicsQueryKey } from '../constants' @@ -21,7 +21,7 @@ export function useTopicMutations(options: UseTopicMutationsOptions = {}) { }, [queryClient]) const deleteMutation = useMutation({ - mutationFn: deleteTopic, + mutationFn: removeTopic, onError: (error: unknown) => toast.error(getErrorMessage(error, t('topics.list.deleteFailed'))), onSuccess: async () => { @@ -32,13 +32,7 @@ export function useTopicMutations(options: UseTopicMutationsOptions = {}) { }) const batchDeleteMutation = useMutation({ - mutationFn: async (ids: string[]) => { - const results = await Promise.allSettled(ids.map((id) => deleteTopic(id))) - return { - failedCount: results.filter((r) => r.status === 'rejected').length, - successCount: results.filter((r) => r.status === 'fulfilled').length, - } - }, + mutationFn: (ids: string[]) => removeTopics(ids), onError: (error: unknown) => toast.error(getErrorMessage(error, t('topics.list.deleteFailed'))), onSuccess: async ({ failedCount, successCount }) => { diff --git a/apps/admin/src/features/topics/hooks/use-topics-list.ts b/apps/admin/src/features/topics/hooks/use-topics-list.ts index 8000efdc961..d5b13104e53 100644 --- a/apps/admin/src/features/topics/hooks/use-topics-list.ts +++ b/apps/admin/src/features/topics/hooks/use-topics-list.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { getTopics } from '~/api/topics' +import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks' +import { topics } from '~/data/resources/topic' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import { adminQueryKeys } from '~/query/keys' @@ -29,20 +30,26 @@ export function useTopicsList() { const [state, setState] = useUrlListState(urlStateOptions) - const topicsQuery = useQuery({ - placeholderData: (previous) => previous, + const listKey = adminQueryKeys.topics.list({ + page: state.page, + size: topicPageSize, + }) + + const topicsQuery = useCollectionListQuery(topics, { queryFn: () => getTopics({ page: state.page, size: topicPageSize }), - queryKey: adminQueryKeys.topics.list({ - page: state.page, - size: topicPageSize, + queryKey: listKey, + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, }), }) + const topicsList = useEntityList(topics, listKey, { keepPrevious: true }) return { page: state.page, - pagination: topicsQuery.data?.pagination, + pagination: topicsList.pagination, setPage: (page: number) => setState({ page }), - topics: topicsQuery.data?.data ?? [], + topics: topicsList.items, topicsQuery, } } diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx index aa308f98e1d..fb3793e4b31 100644 --- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx +++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx @@ -49,9 +49,12 @@ import { updateDraft, } from '~/api/drafts' import { uploadFile, uploadFileWithProgress } from '~/api/files' -import { createNote, getNoteById, updateNote } from '~/api/notes' -import { createPage, getPageById, updatePage } from '~/api/pages' -import { createPost, getPostById, getPosts, updatePost } from '~/api/posts' +import type { CreateNoteData } from '~/api/notes' +import { getNoteById } from '~/api/notes' +import type { CreatePageData } from '~/api/pages' +import { getPageById } from '~/api/pages' +import type { CreatePostData } from '~/api/posts' +import { getPostById, getPosts } from '~/api/posts' import { callBuiltInFunction } from '~/api/system' import { getTopics } from '~/api/topics' import { API_URL, WEB_URL } from '~/constants/env' @@ -59,6 +62,21 @@ import { APP_SHELL_HEADER_HEIGHT_CLASS, APP_SHELL_HEADER_HEIGHT_VALUE, } from '~/constants/layout' +import { + useCollectionDetailQuery, + useCollectionListQuery, + useEntity, + useEntityList, +} from '~/data/resource/hooks' +import type { CategoryEntity } from '~/data/resources/category' +import { categories as categoriesCollection } from '~/data/resources/category' +import { notes as notesCollection } from '~/data/resources/note' +import { saveNote } from '~/data/resources/note.mutations' +import { pages as pagesCollection } from '~/data/resources/page' +import { savePage } from '~/data/resources/page.mutations' +import { posts as postsCollection } from '~/data/resources/post' +import { savePost } from '~/data/resources/post.mutations' +import { topics as topicsCollection } from '~/data/resources/topic' import { DraftStatusTag } from '~/features/drafts/components/draft-status-tag' import { AgentPanel, useWriteAgent } from '~/features/write/components/agent' import { DraftHintBanner } from '~/features/write/components/DraftHintBanner' @@ -73,7 +91,6 @@ import type { TranslationKey } from '~/i18n/types' import { prepareImageFileForUpload } from '~/lib/image-upload-privacy' import type { Amap, AMapSearch } from '~/models/amap' import type { Image as ImageModel } from '~/models/base' -import type { CategoryModel } from '~/models/category' import type { DraftModel } from '~/models/draft' import { DraftRefType } from '~/models/draft' import type { NoteModel } from '~/models/note' @@ -177,8 +194,6 @@ const emptyState: WriteFormState = { weather: '', } -const emptyCategories: CategoryModel[] = [] -const emptyTopics: TopicModel[] = [] const PREFERRED_CONTENT_FORMAT_STORAGE_KEY = 'preferred-content-format' const RichEditorWithAgent = lazy(() => import('~/vendor/rich-editor/components/RichEditorWithAgent').then( @@ -376,28 +391,42 @@ function WritePage(props: { kind: WriteKind }) { draftId: string } | null>(null) const appliedRouteDraftIdRef = useRef(null) + const formSeededKeyRef = useRef(null) const draftDirtyRef = useRef(false) const lastSavedDraftFingerprintRef = useRef('') const latestDraftFingerprintRef = useRef('') const [lastSavedFingerprint, setLastSavedFingerprint] = useState('') const draftRefType = draftRefTypeByKind[props.kind] - const categoriesQuery = useQuery({ + useCollectionListQuery(categoriesCollection, { enabled: props.kind === 'post', queryFn: () => getCategories({ type: 'Category' }), queryKey: adminQueryKeys.categories.list(), + toPage: (result) => ({ items: result }), }) - const topicsQuery = useQuery({ + const categoriesList = useEntityList( + categoriesCollection, + adminQueryKeys.categories.list(), + ) + useCollectionListQuery(topicsCollection, { enabled: props.kind === 'note', queryFn: () => getTopics({ page: 1, size: 100 }), queryKey: adminQueryKeys.topics.list({ page: 1, size: 100 }), + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, + }), }) + const topicsList = useEntityList( + topicsCollection, + adminQueryKeys.topics.list({ page: 1, size: 100 }), + ) const tagsQuery = useQuery({ enabled: props.kind === 'post', queryFn: getTags, queryKey: adminQueryKeys.categories.tags(), }) - const relatedPostsQuery = useQuery({ + useCollectionListQuery(postsCollection, { enabled: props.kind === 'post', queryFn: () => getPosts({ @@ -407,12 +436,57 @@ function WritePage(props: { kind: WriteKind }) { sort_order: 'desc', }), queryKey: adminQueryKeys.posts.relatedOptions('write'), + toPage: (result) => ({ + items: result.data, + pagination: result.pagination, + }), }) - const detailQuery = useQuery({ - enabled: isEditing, - queryFn: () => getWriteDetail(props.kind, id), - queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }), + const relatedPostsList = useEntityList( + postsCollection, + adminQueryKeys.posts.relatedOptions('write'), + ) + const postDetailQuery = useCollectionDetailQuery(postsCollection, { + enabled: isEditing && props.kind === 'post', + queryFn: () => getPostById(id), + queryKey: adminQueryKeys.write.detail({ id, kind: 'post' }), + }) + const postEntity = useEntity( + postsCollection, + props.kind === 'post' && isEditing ? id : undefined, + ) + const noteDetailQuery = useCollectionDetailQuery(notesCollection, { + enabled: isEditing && props.kind === 'note', + queryFn: () => getNoteById(id, { single: true }), + queryKey: adminQueryKeys.write.detail({ id, kind: 'note' }), }) + const noteEntity = useEntity( + notesCollection, + props.kind === 'note' && isEditing ? id : undefined, + ) + const pageDetailQuery = useCollectionDetailQuery(pagesCollection, { + enabled: isEditing && props.kind === 'page', + queryFn: () => getPageById(id), + queryKey: adminQueryKeys.write.detail({ id, kind: 'page' }), + }) + const pageEntity = useEntity( + pagesCollection, + props.kind === 'page' && isEditing ? id : undefined, + ) + const storeEntity = + props.kind === 'note' + ? noteEntity + : props.kind === 'page' + ? pageEntity + : postEntity + const storeDetailQuery = + props.kind === 'note' + ? noteDetailQuery + : props.kind === 'page' + ? pageDetailQuery + : postDetailQuery + const detailModel: WriteModel | undefined = storeEntity + const isDetailLoaded = storeDetailQuery.isSuccess + const detailLoading = storeDetailQuery.isLoading const refDraftQuery = useQuery({ enabled: isEditing, queryFn: () => getDraftByRef(draftRefType, id), @@ -429,10 +503,10 @@ function WritePage(props: { kind: WriteKind }) { queryKey: adminQueryKeys.drafts.newDraft(draftRefType), }) - const categories = categoriesQuery.data ?? emptyCategories + const categories = categoriesList.items const tags = tagsQuery.data ?? [] - const topics = topicsQuery.data?.data ?? emptyTopics - const relatedPosts = relatedPostsQuery.data?.data ?? [] + const topics = topicsList.items + const relatedPosts = relatedPostsList.items const firstCategoryId = categories[0]?.id ?? '' const activeCategory = categories.find((category) => category.id === state.categoryId) ?? @@ -445,21 +519,19 @@ function WritePage(props: { kind: WriteKind }) { )[0] }, [newDraftsQuery.data, refDraftQuery.data]) const publishedContent = useMemo( - () => (detailQuery.data ? getPublishedContent(detailQuery.data) : null), - [detailQuery.data], + () => (detailModel ? getPublishedContent(detailModel) : null), + [detailModel], ) const defaultNoteTitle = useMemo(() => { - if (props.kind === 'note' && detailQuery.data) { - return getDefaultNoteTitle( - new Date((detailQuery.data as NoteModel).createdAt), - ) + if (props.kind === 'note' && detailModel) { + return getDefaultNoteTitle(new Date((detailModel as NoteModel).createdAt)) } return getDefaultNoteTitle() - }, [detailQuery.data, props.kind]) + }, [detailModel, props.kind]) const notePublicPath = props.kind === 'note' - ? buildNotePublicPath(state, detailQuery.data as NoteModel | undefined) + ? buildNotePublicPath(state, detailModel as NoteModel | undefined) : '' const postPublicPath = props.kind === 'post' ? buildPostPublicPath(state, activeCategory) : '' @@ -514,7 +586,7 @@ function WritePage(props: { kind: WriteKind }) { }, [blocker, t]) useEffect(() => { - if (!detailQuery.data) { + if (!detailModel || !isDetailLoaded) { if (props.kind !== 'post' || !firstCategoryId) return setState((previous) => previous.categoryId @@ -527,10 +599,21 @@ function WritePage(props: { kind: WriteKind }) { return } - if (!routeDraftId) { - setState(fromModel(props.kind, detailQuery.data)) - } - }, [detailQuery.data, firstCategoryId, props.kind, routeDraftId]) + if (routeDraftId) return + + const seedKey = `${props.kind}:${id}` + if (formSeededKeyRef.current === seedKey) return + formSeededKeyRef.current = seedKey + + setState(fromModel(props.kind, detailModel)) + }, [ + detailModel, + firstCategoryId, + id, + isDetailLoaded, + props.kind, + routeDraftId, + ]) useEffect(() => { if (refDraftQuery.data && !draftId) { @@ -540,11 +623,11 @@ function WritePage(props: { kind: WriteKind }) { const recoveryHintDraft = useMemo(() => { const draft = refDraftQuery.data - const published = detailQuery.data + const published = detailModel if (!isEditing || routeDraftId || !draft || !published) return null if (!isDraftNewerThanPublished(draft, published)) return null return draft - }, [detailQuery.data, isEditing, refDraftQuery.data, routeDraftId]) + }, [detailModel, isEditing, refDraftQuery.data, routeDraftId]) useEffect(() => { const draft = routeDraftQuery.data @@ -811,8 +894,8 @@ function WritePage(props: { kind: WriteKind }) { isEditing, isPendingDraftSave: draftMutation.isPending, latestDraft, - publishedUpdatedAt: detailQuery.data - ? getPublishedContent(detailQuery.data).updatedAt + publishedUpdatedAt: detailModel + ? getPublishedContent(detailModel).updatedAt : undefined, }) @@ -915,7 +998,7 @@ function WritePage(props: { kind: WriteKind }) {