Skip to content

Commit 5584454

Browse files
authored
Added post and page write hooks to the framework data layer (#30426)
no ref The React editor work needs create/edit mutations for posts and pages whose requests match what the Ember editor sends today, so the API behaves identically for both clients.
1 parent fd8026e commit 5584454

12 files changed

Lines changed: 1511 additions & 114 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/** Shared Admin API type contracts for posts and pages. */
2+
3+
type Override<Base, Changes> = Omit<Base, keyof Changes> & Changes;
4+
5+
export type Email = {
6+
opened_count: number;
7+
email_count: number;
8+
status?: string;
9+
track_opens?: boolean;
10+
track_clicks?: boolean;
11+
};
12+
13+
// Every field is optional because list and analytics endpoints return different
14+
// projections of these relations.
15+
export type PostAuthor = {
16+
id?: string;
17+
name?: string;
18+
email?: string;
19+
slug?: string;
20+
profile_image?: string | null;
21+
};
22+
23+
export type PostTag = {
24+
id?: string;
25+
name?: string;
26+
slug?: string;
27+
visibility?: string;
28+
};
29+
30+
export type PostStatus = 'published' | 'draft' | 'scheduled' | 'sent';
31+
export type PageStatus = Exclude<PostStatus, 'sent'>;
32+
33+
type AtLeastOne<T> = {
34+
[Key in keyof T]-?: Required<Pick<T, Key>> & Partial<Omit<T, Key>>;
35+
}[keyof T];
36+
37+
export type PostAuthorInput = string | AtLeastOne<{ id: string; slug: string; email: string }>;
38+
39+
export type PostTagInput =
40+
| string
41+
| ({ id: string } & Partial<{ name: string; slug: string | null }>)
42+
| ({ name: string } & Partial<{ id: string; slug: string | null }>)
43+
| ({ slug: string } & Partial<{ id: string; name: string }>);
44+
45+
export type PostTierInput = { id: string };
46+
47+
export type PostTier = {
48+
id: string;
49+
name?: string;
50+
slug?: string | null;
51+
};
52+
53+
export type PostRevision = {
54+
id?: string;
55+
post_id?: string;
56+
lexical?: string | null;
57+
title?: string | null;
58+
feature_image?: string | null;
59+
feature_image_alt?: string | null;
60+
feature_image_caption?: string | null;
61+
custom_excerpt?: string | null;
62+
post_status?: string | null;
63+
reason?: string | null;
64+
created_at?: string;
65+
author?: PostAuthor | null;
66+
};
67+
68+
/** Fields shared by post and page list responses. */
69+
export type ContentListFields = {
70+
featured?: boolean;
71+
updated_at?: string | null;
72+
created_at?: string;
73+
excerpt?: string | null;
74+
custom_excerpt?: string | null;
75+
authors?: PostAuthor[];
76+
primary_author?: PostAuthor | null;
77+
tags?: PostTag[];
78+
primary_tag?: PostTag | null;
79+
tiers?: object[];
80+
};
81+
82+
/** Fields shared by post and page editor responses. */
83+
export type ContentEditorFields = {
84+
lexical?: string | null;
85+
mobiledoc?: string | null;
86+
meta_title?: string | null;
87+
meta_description?: string | null;
88+
canonical_url?: string | null;
89+
custom_template?: string | null;
90+
codeinjection_head?: string | null;
91+
codeinjection_foot?: string | null;
92+
og_image?: string | null;
93+
og_title?: string | null;
94+
og_description?: string | null;
95+
twitter_image?: string | null;
96+
twitter_title?: string | null;
97+
twitter_description?: string | null;
98+
feature_image_alt?: string | null;
99+
feature_image_caption?: string | null;
100+
post_revisions?: PostRevision[];
101+
};
102+
103+
export type PostCount = {
104+
clicks?: number;
105+
conversions?: number;
106+
signups?: number;
107+
paid_conversions?: number;
108+
positive_feedback?: number;
109+
negative_feedback?: number;
110+
};
111+
112+
export type PageCount = {
113+
signups?: number;
114+
paid_conversions?: number;
115+
};
116+
117+
/** Broad response record shared by the posts and pages endpoints. */
118+
export type ContentRecord = {
119+
id: string;
120+
url: string;
121+
slug: string;
122+
title: string;
123+
visibility?: string;
124+
uuid?: string;
125+
feature_image?: string | null;
126+
published_at?: string | null;
127+
} & ContentListFields &
128+
ContentEditorFields;
129+
130+
export type PostEmailFields = {
131+
email?: Email | null;
132+
email_subject?: string | null;
133+
newsletter?: object | null;
134+
email_only?: boolean;
135+
email_segment?: string | null;
136+
};
137+
138+
export type Post = Override<
139+
ContentRecord,
140+
{
141+
uuid: string;
142+
status?: PostStatus;
143+
count?: PostCount;
144+
}
145+
> &
146+
PostEmailFields;
147+
148+
export type Page = Override<
149+
ContentRecord,
150+
{
151+
status?: PageStatus;
152+
count?: PageCount;
153+
show_title_and_feature_image?: boolean;
154+
}
155+
>;
156+
157+
// Write access stays opt-in: a new response field must not silently become
158+
// writable. Pick supplies each allowed field's value type from ContentRecord.
159+
type ContentWritableKey =
160+
| 'title'
161+
| 'slug'
162+
| 'mobiledoc'
163+
| 'lexical'
164+
| 'feature_image'
165+
| 'feature_image_alt'
166+
| 'feature_image_caption'
167+
| 'featured'
168+
| 'meta_title'
169+
| 'meta_description'
170+
| 'updated_at'
171+
| 'published_at'
172+
| 'custom_excerpt'
173+
| 'codeinjection_head'
174+
| 'codeinjection_foot'
175+
| 'og_image'
176+
| 'og_title'
177+
| 'og_description'
178+
| 'twitter_image'
179+
| 'twitter_title'
180+
| 'twitter_description'
181+
| 'custom_template'
182+
| 'canonical_url';
183+
184+
type ContentEditableScalars = Partial<Pick<ContentRecord, ContentWritableKey>>;
185+
186+
/** Shared post/page input, including fields whose input shape differs from output. */
187+
export type ContentEditableData = Override<
188+
ContentEditableScalars,
189+
{
190+
html?: string | null;
191+
locale?: string | null;
192+
// The serializer treats null visibility as "leave visibility unchanged".
193+
visibility?: string | null;
194+
visibility_filter?: string | null;
195+
authors?: PostAuthorInput[];
196+
tags?: PostTagInput[];
197+
tiers?: PostTierInput[];
198+
}
199+
>;
200+
201+
export type PostEditableData = ContentEditableData &
202+
Partial<Pick<Post, 'status' | 'email_subject' | 'email_only'>>;
203+
204+
export type PageEditableData = ContentEditableData &
205+
Partial<Pick<Page, 'status' | 'show_title_and_feature_image'>>;
206+
207+
type EditorRelations = {
208+
updated_at: string | null;
209+
authors?: Array<PostAuthor & { id: string }>;
210+
tags?: Array<PostTag & { id: string }>;
211+
tiers?: PostTier[];
212+
};
213+
214+
/** A single editor response has the relations required for a safe round-trip edit. */
215+
export type EditorRecord<RecordType extends ContentRecord> = Override<RecordType, EditorRelations>;
216+
217+
export type PostEditorRecord = EditorRecord<Post>;
218+
export type PageEditorRecord = EditorRecord<Page>;
219+
220+
export type CreateContentData<Data extends { title?: string }> = Override<Data, { title: string }>;
221+
222+
export type EditContentData<Data extends { updated_at?: string | null }> = Override<
223+
Data,
224+
{ id: string; updated_at: string | null }
225+
>;
226+
227+
export type PostBulkAction =
228+
| { type: 'feature' }
229+
| { type: 'unfeature' }
230+
| { type: 'unpublish' }
231+
| { type: 'unschedule' }
232+
| { type: 'addTag'; meta: { tags: { id?: string; name: string; slug?: string }[] } }
233+
| { type: 'access'; meta: { visibility: string; tiers?: { id: string }[] } };
234+
235+
// Compatibility aliases for existing imports from api/posts.
236+
export type PostListFields = ContentListFields;
237+
export type PostEditorFields = ContentEditorFields & Pick<PostEmailFields, 'email_subject'>;

apps/admin-x-framework/src/api/pages.ts

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,40 @@
11
import { InfiniteData } from '@tanstack/react-query';
2-
import { Meta, createInfiniteQuery, createMutation, createQuery } from '../utils/api/hooks';
3-
import type { Email, PostBulkAction, PostListFields } from './posts';
4-
5-
// A page is a post with `displayName: 'page'` server-side, so the list screens
6-
// read the same fields off both.
7-
export type Page = {
8-
id: string;
9-
title: string;
10-
slug: string;
11-
url: string;
12-
status?: string;
13-
published_at?: string;
14-
visibility?: string;
15-
uuid?: string;
16-
feature_image?: string;
17-
email?: Email;
18-
count?: {
19-
clicks?: number;
20-
};
21-
// Pages are never emailed, but the list reads these off both resources
22-
// through one type, so they have to be addressable here too.
23-
email_only?: boolean;
24-
email_segment?: string;
25-
newsletter?: object;
26-
} & PostListFields;
2+
import {
3+
Meta,
4+
createInfiniteQuery,
5+
createMutation,
6+
createQuery,
7+
createQueryWithId,
8+
} from '../utils/api/hooks';
9+
import {
10+
PageWriteOptions,
11+
PostCreateOptions,
12+
buildPostEditorReadParams,
13+
buildPageWriteParams,
14+
buildPostReadParams,
15+
serializePostPayload,
16+
} from './post-contract';
17+
import type {
18+
CreateContentData,
19+
EditContentData,
20+
Page,
21+
PageEditableData,
22+
PageEditorRecord,
23+
PostBulkAction,
24+
} from './content-types';
25+
26+
export type { Page, PageEditableData, PageEditorRecord, PageStatus } from './content-types';
2727

2828
export interface PagesResponseType {
2929
meta?: Meta;
3030
pages: Page[];
3131
}
3232

33+
export interface PageResponseType {
34+
meta?: Meta;
35+
pages: PageEditorRecord[];
36+
}
37+
3338
const dataType = 'PagesResponseType';
3439

3540
export const useBrowsePages = createQuery<PagesResponseType>({
@@ -63,6 +68,63 @@ export const useBrowsePagesInfinite = createInfiniteQuery<PagesResponseType & {
6368
},
6469
});
6570

71+
const usePageQuery = createQueryWithId<PageResponseType>({
72+
dataType,
73+
path: (id) => `/pages/${id}/`,
74+
});
75+
76+
export const usePage = (id: string, options: Parameters<typeof usePageQuery>[1] = {}) => {
77+
const { searchParams, ...queryOptions } = options;
78+
return usePageQuery(id, {
79+
...queryOptions,
80+
searchParams: { ...buildPostReadParams(), ...searchParams },
81+
});
82+
};
83+
84+
const useEditorPageQuery = createQueryWithId<PageResponseType>({
85+
dataType,
86+
path: (id) => `/pages/${id}/`,
87+
});
88+
89+
export const useEditorPage = (
90+
id: string,
91+
options: Parameters<typeof useEditorPageQuery>[1] = {},
92+
) => {
93+
const { searchParams, ...queryOptions } = options;
94+
return useEditorPageQuery(id, {
95+
...queryOptions,
96+
searchParams: { ...searchParams, ...buildPostEditorReadParams() },
97+
});
98+
};
99+
100+
// The create endpoint only accepts include/formats/source - revision and
101+
// email delivery options are update-only
102+
export interface AddPagePayload {
103+
page: CreateContentData<PageEditableData>;
104+
options?: PostCreateOptions;
105+
}
106+
107+
export interface EditPagePayload {
108+
page: EditContentData<PageEditableData>;
109+
options?: PageWriteOptions;
110+
}
111+
112+
export const useAddPage = createMutation<PageResponseType, AddPagePayload>({
113+
method: 'POST',
114+
path: () => '/pages/',
115+
searchParams: ({ options }) => buildPageWriteParams(options),
116+
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
117+
invalidateQueries: { dataType },
118+
});
119+
120+
export const useEditPage = createMutation<PageResponseType, EditPagePayload>({
121+
method: 'PUT',
122+
path: ({ page }) => `/pages/${page.id}/`,
123+
searchParams: ({ options }) => buildPageWriteParams(options),
124+
body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }),
125+
invalidateQueries: { dataType },
126+
});
127+
66128
/** Duplicate a page. As with posts, the copy is always a draft. */
67129
export const useCopyPage = createMutation<PagesResponseType, string>({
68130
method: 'POST',

0 commit comments

Comments
 (0)