Skip to content

Commit dc02641

Browse files
authored
Added snippets API hooks to the shared admin framework (#30425)
no ref The React editor will need the snippet save/insert feature that Ember Data currently serves in the Ember editor. This adds a snippets API module to `apps/admin-x-framework` with `useBrowseSnippets`, `useAddSnippet`, `useEditSnippet`, and `useDeleteSnippet`.
1 parent ff748e1 commit dc02641

6 files changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Meta, createMutation, createQuery } from '../utils/api/hooks';
2+
3+
// mobiledoc and lexical travel as JSON strings on the wire; callers parse/stringify
4+
export type Snippet = {
5+
id: string;
6+
name: string;
7+
mobiledoc: string;
8+
lexical: string | null;
9+
created_at: string;
10+
updated_at: string | null;
11+
};
12+
13+
// The add and edit schemas require name and mobiledoc on every item
14+
export type SnippetEditableData = Pick<Snippet, 'name' | 'mobiledoc'> &
15+
Partial<Pick<Snippet, 'lexical'>>;
16+
17+
export interface SnippetsResponseType {
18+
meta?: Meta;
19+
snippets: Snippet[];
20+
}
21+
22+
const dataType = 'SnippetsResponseType';
23+
24+
// Without `formats` the API strips `lexical` from responses (mobiledoc is the default format)
25+
const formats = 'mobiledoc,lexical';
26+
27+
const useBrowseSnippetsQuery = createQuery<SnippetsResponseType>({
28+
dataType,
29+
path: '/snippets/',
30+
defaultSearchParams: { limit: 'all', formats },
31+
});
32+
33+
export const useBrowseSnippets = ({
34+
searchParams,
35+
...args
36+
}: Parameters<typeof useBrowseSnippetsQuery>[0] = {}) =>
37+
useBrowseSnippetsQuery({
38+
...args,
39+
// caller searchParams replace the defaults wholesale, so re-merge formats
40+
searchParams: { limit: 'all', ...searchParams, formats },
41+
});
42+
43+
export const useAddSnippet = createMutation<SnippetsResponseType, SnippetEditableData>({
44+
method: 'POST',
45+
path: () => '/snippets/',
46+
searchParams: () => ({ formats }),
47+
body: (snippet) => ({ snippets: [snippet] }),
48+
invalidateQueries: { dataType },
49+
});
50+
51+
export const useEditSnippet = createMutation<
52+
SnippetsResponseType,
53+
SnippetEditableData & { id: string }
54+
>({
55+
method: 'PUT',
56+
path: ({ id }) => `/snippets/${id}/`,
57+
searchParams: () => ({ formats }),
58+
body: ({ id: _id, ...snippet }) => ({ snippets: [snippet] }),
59+
invalidateQueries: { dataType },
60+
});
61+
62+
export const useDeleteSnippet = createMutation<void, string>({
63+
method: 'DELETE',
64+
path: (id) => `/snippets/${id}/`,
65+
invalidateQueries: { dataType },
66+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { act, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { ValidationError } from '../../../src/utils/errors';
4+
import { renderHookWithProviders } from '../../../src/test/test-utils';
5+
import {
6+
useAddSnippet,
7+
useBrowseSnippets,
8+
useDeleteSnippet,
9+
useEditSnippet,
10+
} from '../../../src/api/snippets';
11+
import { withMockFetch } from '../../utils/mock-fetch';
12+
13+
const { mockSonnerError } = vi.hoisted(() => ({
14+
mockSonnerError: vi.fn(),
15+
}));
16+
17+
vi.mock('sonner', () => ({
18+
toast: {
19+
error: mockSonnerError,
20+
dismiss: vi.fn(),
21+
},
22+
}));
23+
24+
const existingSnippet = {
25+
id: 'snippet-1',
26+
name: 'Existing snippet',
27+
mobiledoc: '{}',
28+
lexical: '{"nodes":[]}',
29+
created_at: '2024-01-01T00:00:00.000Z',
30+
updated_at: '2024-01-01T00:00:00.000Z',
31+
};
32+
33+
const okResponse = (json: unknown) => ({
34+
json,
35+
headers: { 'content-type': 'application/json' },
36+
ok: true,
37+
status: 200,
38+
});
39+
40+
const duplicateSnippetResponse = {
41+
errors: [
42+
{
43+
code: 'VALIDATION',
44+
context: 'Snippet already exists.',
45+
details: null,
46+
ghostErrorCode: null,
47+
help: null,
48+
id: 'snippet-error-id',
49+
message: 'Validation error, cannot save snippet.',
50+
property: null,
51+
type: 'ValidationError',
52+
},
53+
],
54+
};
55+
56+
const mockErrorFetch = {
57+
json: duplicateSnippetResponse,
58+
headers: { 'content-type': 'application/json' },
59+
ok: false,
60+
status: 422,
61+
};
62+
63+
const findCall = (mock: { calls: unknown[][] }, path: string) =>
64+
mock.calls.find((call) => String(call[0]).includes(path));
65+
66+
describe('snippets api', () => {
67+
beforeEach(() => {
68+
vi.clearAllMocks();
69+
});
70+
71+
it('browses all snippets in both formats', async () => {
72+
await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => {
73+
const { result } = renderHookWithProviders(() => useBrowseSnippets());
74+
75+
await waitFor(() => {
76+
expect(result.current.data).toEqual({ snippets: [existingSnippet] });
77+
});
78+
79+
const call = findCall(mock, '/snippets/');
80+
const url = new URL(String(call![0]));
81+
expect(url.pathname).toBe('/ghost/api/admin/snippets/');
82+
expect(url.searchParams.get('limit')).toBe('all');
83+
expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical');
84+
});
85+
});
86+
87+
it('keeps the formats param when a caller passes its own search params', async () => {
88+
await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => {
89+
const { result } = renderHookWithProviders(() =>
90+
useBrowseSnippets({ searchParams: { filter: 'name:foo' } }),
91+
);
92+
93+
await waitFor(() => {
94+
expect(result.current.data).toEqual({ snippets: [existingSnippet] });
95+
});
96+
97+
const url = new URL(String(findCall(mock, '/snippets/')![0]));
98+
expect(url.searchParams.get('filter')).toBe('name:foo');
99+
expect(url.searchParams.get('limit')).toBe('all');
100+
expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical');
101+
});
102+
});
103+
104+
it('rejects duplicate creates with a validation error without reporting', async () => {
105+
await withMockFetch(mockErrorFetch, async () => {
106+
const { result } = renderHookWithProviders(() => useAddSnippet());
107+
108+
await act(async () => {
109+
await expect(
110+
result.current.mutateAsync({ name: 'Existing snippet', mobiledoc: '{}' }),
111+
).rejects.toBeInstanceOf(ValidationError);
112+
});
113+
114+
expect(mockSonnerError).not.toHaveBeenCalled();
115+
});
116+
});
117+
118+
it('adds a snippet and requests both formats back', async () => {
119+
await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => {
120+
const { result } = renderHookWithProviders(() => useAddSnippet());
121+
122+
await act(async () => {
123+
await result.current.mutateAsync({
124+
name: 'Existing snippet',
125+
lexical: '{"nodes":[]}',
126+
mobiledoc: '{}',
127+
});
128+
});
129+
130+
const call = findCall(mock, '/snippets/')!;
131+
const url = new URL(String(call[0]));
132+
expect(url.pathname).toBe('/ghost/api/admin/snippets/');
133+
expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical');
134+
135+
const options = call[1] as RequestInit;
136+
expect(options.method).toBe('POST');
137+
expect(JSON.parse(options.body as string)).toEqual({
138+
snippets: [{ name: 'Existing snippet', lexical: '{"nodes":[]}', mobiledoc: '{}' }],
139+
});
140+
});
141+
});
142+
143+
// The edit schema requires name and mobiledoc on every item, so edits send the full record
144+
it('edits a snippet with the full record and requests both formats back', async () => {
145+
await withMockFetch(okResponse({ snippets: [existingSnippet] }), async (mock) => {
146+
const { result } = renderHookWithProviders(() => useEditSnippet());
147+
148+
await act(async () => {
149+
await result.current.mutateAsync({
150+
id: 'snippet-1',
151+
name: 'Existing snippet',
152+
mobiledoc: '{}',
153+
lexical: '{"nodes":[]}',
154+
});
155+
});
156+
157+
const call = findCall(mock, '/snippets/snippet-1/')!;
158+
const url = new URL(String(call[0]));
159+
expect(url.pathname).toBe('/ghost/api/admin/snippets/snippet-1/');
160+
expect(url.searchParams.get('formats')).toBe('mobiledoc,lexical');
161+
162+
const options = call[1] as RequestInit;
163+
expect(options.method).toBe('PUT');
164+
expect(JSON.parse(options.body as string)).toEqual({
165+
snippets: [{ name: 'Existing snippet', mobiledoc: '{}', lexical: '{"nodes":[]}' }],
166+
});
167+
});
168+
});
169+
170+
it('deletes a snippet by id', async () => {
171+
await withMockFetch({ ok: true, status: 204 }, async (mock) => {
172+
const { result } = renderHookWithProviders(() => useDeleteSnippet());
173+
174+
await act(async () => {
175+
await result.current.mutateAsync('snippet-1');
176+
});
177+
178+
const call = findCall(mock, '/snippets/snippet-1/')!;
179+
expect(String(call[0])).toBe('http://localhost:3000/ghost/api/admin/snippets/snippet-1/');
180+
expect((call[1] as RequestInit).method).toBe('DELETE');
181+
});
182+
});
183+
});

apps/admin/src/ember-bridge/ember-bridge.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,34 @@ describe('useEmberDataSync', () => {
250250
});
251251
});
252252

253+
queryTest('invalidates snippets when Ember saves one', async ({ queryClient, wrapper }) => {
254+
const mock = createMockStateBridge();
255+
window.EmberBridge = { state: mock.stateBridge };
256+
const snippetsKey = ['SnippetsResponseType', '/snippets'];
257+
258+
queryClient.setQueryDefaults(snippetsKey, { gcTime: Infinity });
259+
queryClient.setQueryData(snippetsKey, { snippets: [] });
260+
261+
renderHook(() => useEmberDataSync(), { wrapper });
262+
263+
await waitFor(() => {
264+
expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function));
265+
});
266+
267+
act(() => {
268+
mock.emit('emberDataChange', {
269+
operation: 'update',
270+
modelName: 'snippet',
271+
id: 'snippet-1',
272+
data: null,
273+
});
274+
});
275+
276+
await waitFor(() => {
277+
expect(queryClient.getQueryState(snippetsKey)?.isInvalidated).toBe(true);
278+
});
279+
});
280+
253281
queryTest(
254282
'invalidates the sidebar member count query for Ember member changes',
255283
async ({ queryClient, wrapper }) => {

apps/admin/src/ember-bridge/ember-bridge.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record<string, string> = {
105105
member: 'MembersResponseType',
106106
tag: 'TagsResponseType',
107107
label: 'LabelsResponseType',
108+
snippet: 'SnippetsResponseType',
108109
};
109110

110111
/**

apps/ember-admin/app/services/state-bridge.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const emberDataTypeMapping = {
1919
NewslettersResponseType: {type: 'newsletter'},
2020
RecommendationResponseType: {type: 'recommendation'},
2121
SettingsResponseType: {type: 'setting', singleton: true},
22+
SnippetsResponseType: {type: 'snippet'},
2223
TagsResponseType: {type: 'tag'},
2324
ThemesResponseType: {type: 'theme'},
2425
TiersResponseType: {type: 'tier'},

apps/ember-admin/tests/unit/services/state-bridge-test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,14 @@ describe('Unit: Service: state-bridge', function () {
306306
expect(store.unloadAll.calledWith('integration')).to.be.true;
307307
});
308308

309+
it('unloads snippets when React snippet queries are invalidated', function () {
310+
run(() => {
311+
service.onInvalidate('SnippetsResponseType');
312+
});
313+
314+
expect(store.unloadAll.calledOnceWith('snippet')).to.be.true;
315+
});
316+
309317
it('unloads all tags when tag queries are invalidated', function () {
310318
run(() => {
311319
service.onInvalidate('TagsResponseType');

0 commit comments

Comments
 (0)