diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 1a98c0ce48e..613f7c39ff0 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -17,6 +17,7 @@ "koenig/kg-lexical-html-renderer/**", "koenig/kg-simplemde/debug/**", "koenig/koenig-lexical/**", - "packages/i18n/locales/**" + "packages/i18n/locales/**", + ".changeset/ledger.yaml" ] } diff --git a/apps/admin-x-framework/src/api/snippets.ts b/apps/admin-x-framework/src/api/snippets.ts new file mode 100644 index 00000000000..5c0b2a976a3 --- /dev/null +++ b/apps/admin-x-framework/src/api/snippets.ts @@ -0,0 +1,66 @@ +import { Meta, createMutation, createQuery } from '../utils/api/hooks'; + +// mobiledoc and lexical travel as JSON strings on the wire; callers parse/stringify +export type Snippet = { + id: string; + name: string; + mobiledoc: string; + lexical: string | null; + created_at: string; + updated_at: string | null; +}; + +// The add and edit schemas require name and mobiledoc on every item +export type SnippetEditableData = Pick & + Partial>; + +export interface SnippetsResponseType { + meta?: Meta; + snippets: Snippet[]; +} + +const dataType = 'SnippetsResponseType'; + +// Without `formats` the API strips `lexical` from responses (mobiledoc is the default format) +const formats = 'mobiledoc,lexical'; + +const useBrowseSnippetsQuery = createQuery({ + dataType, + path: '/snippets/', + defaultSearchParams: { limit: 'all', formats }, +}); + +export const useBrowseSnippets = ({ + searchParams, + ...args +}: Parameters[0] = {}) => + useBrowseSnippetsQuery({ + ...args, + // caller searchParams replace the defaults wholesale, so re-merge formats + searchParams: { limit: 'all', ...searchParams, formats }, + }); + +export const useAddSnippet = createMutation({ + method: 'POST', + path: () => '/snippets/', + searchParams: () => ({ formats }), + body: (snippet) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useEditSnippet = createMutation< + SnippetsResponseType, + SnippetEditableData & { id: string } +>({ + method: 'PUT', + path: ({ id }) => `/snippets/${id}/`, + searchParams: () => ({ formats }), + body: ({ id: _id, ...snippet }) => ({ snippets: [snippet] }), + invalidateQueries: { dataType }, +}); + +export const useDeleteSnippet = createMutation({ + method: 'DELETE', + path: (id) => `/snippets/${id}/`, + invalidateQueries: { dataType }, +}); diff --git a/apps/admin-x-framework/test/unit/api/snippets.test.tsx b/apps/admin-x-framework/test/unit/api/snippets.test.tsx new file mode 100644 index 00000000000..9024b925c54 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/snippets.test.tsx @@ -0,0 +1,183 @@ +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ValidationError } from '../../../src/utils/errors'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { + useAddSnippet, + useBrowseSnippets, + useDeleteSnippet, + useEditSnippet, +} from '../../../src/api/snippets'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const { mockSonnerError } = vi.hoisted(() => ({ + mockSonnerError: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + error: mockSonnerError, + dismiss: vi.fn(), + }, +})); + +const existingSnippet = { + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', +}; + +const okResponse = (json: unknown) => ({ + json, + headers: { 'content-type': 'application/json' }, + ok: true, + status: 200, +}); + +const duplicateSnippetResponse = { + errors: [ + { + code: 'VALIDATION', + context: 'Snippet already exists.', + details: null, + ghostErrorCode: null, + help: null, + id: 'snippet-error-id', + message: 'Validation error, cannot save snippet.', + property: null, + type: 'ValidationError', + }, + ], +}; + +const mockErrorFetch = { + json: duplicateSnippetResponse, + headers: { 'content-type': 'application/json' }, + ok: false, + status: 422, +}; + +const findCall = (mock: { calls: unknown[][] }, path: string) => + mock.calls.find((call) => String(call[0]).includes(path)); + +describe('snippets api', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('browses all snippets in both formats', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useBrowseSnippets()); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const call = findCall(mock, '/snippets/'); + const url = new URL(String(call![0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('keeps the formats param when a caller passes its own search params', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => + useBrowseSnippets({ searchParams: { filter: 'name:foo' } }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual({ snippets: [existingSnippet] }); + }); + + const url = new URL(String(findCall(mock, '/snippets/')![0])); + expect(url.searchParams.get('filter')).toBe('name:foo'); + expect(url.searchParams.get('limit')).toBe('all'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + }); + }); + + it('rejects duplicate creates with a validation error without reporting', async () => { + await withMockFetch(mockErrorFetch, async () => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await expect( + result.current.mutateAsync({ name: 'Existing snippet', mobiledoc: '{}' }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + expect(mockSonnerError).not.toHaveBeenCalled(); + }); + }); + + it('adds a snippet and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useAddSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Existing snippet', + lexical: '{"nodes":[]}', + mobiledoc: '{}', + }); + }); + + const call = findCall(mock, '/snippets/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', lexical: '{"nodes":[]}', mobiledoc: '{}' }], + }); + }); + }); + + // The edit schema requires name and mobiledoc on every item, so edits send the full record + it('edits a snippet with the full record and requests both formats back', async () => { + await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => { + const { result } = renderHookWithProviders(() => useEditSnippet()); + + await act(async () => { + await result.current.mutateAsync({ + id: 'snippet-1', + name: 'Existing snippet', + mobiledoc: '{}', + lexical: '{"nodes":[]}', + }); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + const url = new URL(String(call[0])); + expect(url.pathname).toBe('/ghost/api/admin/snippets/snippet-1/'); + expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical'); + + const options = call[1] as RequestInit; + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body as string)).toEqual({ + snippets: [{ name: 'Existing snippet', mobiledoc: '{}', lexical: '{"nodes":[]}' }], + }); + }); + }); + + it('deletes a snippet by id', async () => { + await withMockFetch({ ok: true, status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useDeleteSnippet()); + + await act(async () => { + await result.current.mutateAsync('snippet-1'); + }); + + const call = findCall(mock, '/snippets/snippet-1/')!; + expect(String(call[0])).toBe('http://localhost:3000/ghost/api/admin/snippets/snippet-1/'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + }); +}); diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index c302973f4fe..6331d027279 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -250,6 +250,34 @@ describe('useEmberDataSync', () => { }); }); + queryTest('invalidates snippets when Ember saves one', async ({ queryClient, wrapper }) => { + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + const snippetsKey = ['SnippetsResponseType', '/snippets']; + + queryClient.setQueryDefaults(snippetsKey, { gcTime: Infinity }); + queryClient.setQueryData(snippetsKey, { snippets: [] }); + + renderHook(() => useEmberDataSync(), { wrapper }); + + await waitFor(() => { + expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); + }); + + act(() => { + mock.emit('emberDataChange', { + operation: 'update', + modelName: 'snippet', + id: 'snippet-1', + data: null, + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryState(snippetsKey)?.isInvalidated).toBe(true); + }); + }); + queryTest( 'invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => { diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index d5d646971b5..c5f7cdf59de 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -105,6 +105,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { member: 'MembersResponseType', tag: 'TagsResponseType', label: 'LabelsResponseType', + snippet: 'SnippetsResponseType', }; /** diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 8a57d0abb5b..3cad2e20c87 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -19,6 +19,7 @@ const emberDataTypeMapping = { NewslettersResponseType: {type: 'newsletter'}, RecommendationResponseType: {type: 'recommendation'}, SettingsResponseType: {type: 'setting', singleton: true}, + SnippetsResponseType: {type: 'snippet'}, TagsResponseType: {type: 'tag'}, ThemesResponseType: {type: 'theme'}, TiersResponseType: {type: 'tier'}, diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index a74065d8ba0..62bf771f690 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -306,6 +306,14 @@ describe('Unit: Service: state-bridge', function () { expect(store.unloadAll.calledWith('integration')).to.be.true; }); + it('unloads snippets when React snippet queries are invalidated', function () { + run(() => { + service.onInvalidate('SnippetsResponseType'); + }); + + expect(store.unloadAll.calledOnceWith('snippet')).to.be.true; + }); + it('unloads all tags when tag queries are invalidated', function () { run(() => { service.onInvalidate('TagsResponseType');