@btst/stack 2.11.8 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +14 -11
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +14 -11
- package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.cjs +87 -15
- package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.mjs +87 -15
- package/dist/packages/stack/src/plugins/cms/schemas.cjs +11 -6
- package/dist/packages/stack/src/plugins/cms/schemas.mjs +10 -6
- package/dist/packages/stack/src/plugins/comments/api/query-key-defs.cjs +2 -1
- package/dist/packages/stack/src/plugins/comments/api/query-key-defs.mjs +2 -1
- package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.cjs +6 -2
- package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.mjs +6 -2
- package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.cjs +5 -3
- package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.mjs +5 -3
- package/dist/packages/ui/src/components/auto-form/fields/array.cjs +181 -4
- package/dist/packages/ui/src/components/auto-form/fields/array.mjs +184 -7
- package/dist/packages/ui/src/components/auto-form/index.cjs +16 -1
- package/dist/packages/ui/src/components/auto-form/index.mjs +17 -2
- package/dist/plugins/cms/api/index.cjs +2 -0
- package/dist/plugins/cms/api/index.d.cts +3 -2
- package/dist/plugins/cms/api/index.d.mts +3 -2
- package/dist/plugins/cms/api/index.d.ts +3 -2
- package/dist/plugins/cms/api/index.mjs +1 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.d.cts +398 -7
- package/dist/plugins/cms/query-keys.d.mts +398 -7
- package/dist/plugins/cms/query-keys.d.ts +398 -7
- package/dist/plugins/comments/api/index.d.cts +4 -4
- package/dist/plugins/comments/api/index.d.mts +4 -4
- package/dist/plugins/comments/api/index.d.ts +4 -4
- package/dist/plugins/comments/client/components/index.d.cts +11 -0
- package/dist/plugins/comments/client/components/index.d.mts +11 -0
- package/dist/plugins/comments/client/components/index.d.ts +11 -0
- package/dist/plugins/comments/client/hooks/index.d.cts +17 -1
- package/dist/plugins/comments/client/hooks/index.d.mts +17 -1
- package/dist/plugins/comments/client/hooks/index.d.ts +17 -1
- package/dist/plugins/comments/client/index.d.cts +9 -0
- package/dist/plugins/comments/client/index.d.mts +9 -0
- package/dist/plugins/comments/client/index.d.ts +9 -0
- package/dist/plugins/comments/query-keys.cjs +1 -0
- package/dist/plugins/comments/query-keys.d.cts +3 -2
- package/dist/plugins/comments/query-keys.d.mts +3 -2
- package/dist/plugins/comments/query-keys.d.ts +3 -2
- package/dist/plugins/comments/query-keys.mjs +1 -0
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/plugins/ui-builder/index.d.cts +1 -1
- package/dist/plugins/ui-builder/index.d.mts +1 -1
- package/dist/plugins/ui-builder/index.d.ts +1 -1
- package/dist/shared/{stack.CJE9sAjV.d.ts → stack.Bq3kiUaM.d.ts} +1 -1
- package/dist/shared/{stack.C-b3Sn8j.d.cts → stack.C7KNNiJm.d.cts} +3 -1
- package/dist/shared/{stack.C-b3Sn8j.d.mts → stack.C7KNNiJm.d.mts} +3 -1
- package/dist/shared/{stack.C-b3Sn8j.d.ts → stack.C7KNNiJm.d.ts} +3 -1
- package/dist/shared/{stack.Dcz6636A.d.mts → stack.CCncwK6g.d.cts} +1 -1
- package/dist/shared/{stack.CmHRdhl8.d.cts → stack.CO1g47IS.d.mts} +1 -1
- package/dist/shared/{stack.B8QD11QU.d.cts → stack.DFtpFS00.d.cts} +10 -0
- package/dist/shared/{stack.B8QD11QU.d.mts → stack.DFtpFS00.d.mts} +10 -0
- package/dist/shared/{stack.B8QD11QU.d.ts → stack.DFtpFS00.d.ts} +10 -0
- package/package.json +1 -1
- package/src/plugins/cms/__tests__/schema-roundtrip.test.ts +203 -0
- package/src/plugins/cms/api/index.ts +1 -0
- package/src/plugins/cms/api/plugin.ts +33 -16
- package/src/plugins/cms/client/components/forms/relation-field.tsx +121 -36
- package/src/plugins/cms/schemas.ts +26 -6
- package/src/plugins/cms/types.ts +10 -0
- package/src/plugins/comments/api/query-key-defs.ts +5 -1
- package/src/plugins/comments/client/components/comment-thread.tsx +15 -0
- package/src/plugins/comments/client/hooks/use-comments.tsx +26 -3
- package/src/plugins/comments/client/overrides.ts +10 -0
- package/src/plugins/comments/query-keys.ts +2 -0
- package/dist/shared/stack.DbnCLdNF.d.mts +0 -399
- package/dist/shared/stack.fXlShnhv.d.ts +0 -399
- package/dist/shared/stack.r6HohbyX.d.cts +0 -399
- package/dist/shared/{stack.B6S3cgwN.d.cts → stack.CuI_-z-_.d.cts} +16 -16
- package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.D2RVkQD8.d.mts} +16 -16
- package/dist/shared/{stack.j5SFLC1d.d.ts → stack.DXrOtuek.d.ts} +16 -16
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useMemo } from "react";
|
|
4
|
+
import { useQueries } from "@tanstack/react-query";
|
|
5
|
+
import { createApiClient } from "@btst/stack/plugins/client";
|
|
6
|
+
import { usePluginOverrides } from "@btst/stack/context";
|
|
4
7
|
import { useContent, useCreateContent } from "../../hooks";
|
|
8
|
+
import type { CMSApiRouter } from "../../../api";
|
|
9
|
+
import type { SerializedContentItemWithType } from "../../../types";
|
|
10
|
+
import type { CMSPluginOverrides } from "../../overrides";
|
|
11
|
+
import { createCMSQueryKeys } from "../../../query-keys";
|
|
5
12
|
import MultipleSelector from "@workspace/ui/components/multi-select";
|
|
6
13
|
import type { Option } from "@workspace/ui/components/multi-select";
|
|
7
14
|
import { Button } from "@workspace/ui/components/button";
|
|
@@ -19,6 +26,16 @@ import { Textarea } from "@workspace/ui/components/textarea";
|
|
|
19
26
|
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
|
|
20
27
|
import type { RelationConfig } from "../../../types";
|
|
21
28
|
|
|
29
|
+
/** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */
|
|
30
|
+
const RELATION_DETAIL_QUERY_OPTS = {
|
|
31
|
+
retry: false,
|
|
32
|
+
refetchOnWindowFocus: false,
|
|
33
|
+
refetchOnMount: false,
|
|
34
|
+
refetchOnReconnect: false,
|
|
35
|
+
staleTime: 1000 * 60 * 5,
|
|
36
|
+
gcTime: 1000 * 60 * 10,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
22
39
|
interface RelationFieldProps extends AutoFormInputComponentProps {
|
|
23
40
|
relation: RelationConfig;
|
|
24
41
|
}
|
|
@@ -43,16 +60,25 @@ export function RelationField({
|
|
|
43
60
|
const [newItemDescription, setNewItemDescription] = useState("");
|
|
44
61
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
const { apiBaseURL, apiBasePath, headers } =
|
|
64
|
+
usePluginOverrides<CMSPluginOverrides>("cms");
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
const listClient = useMemo(
|
|
67
|
+
() =>
|
|
68
|
+
createApiClient<CMSApiRouter>({
|
|
69
|
+
baseURL: apiBaseURL,
|
|
70
|
+
basePath: apiBasePath,
|
|
71
|
+
}),
|
|
72
|
+
[apiBaseURL, apiBasePath],
|
|
73
|
+
);
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
const cmsQueries = useMemo(
|
|
76
|
+
() => createCMSQueryKeys(listClient, headers),
|
|
77
|
+
[listClient, headers],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// For belongsTo (single relation), we only allow one selection
|
|
81
|
+
const isSingleSelect = relation.type === "belongsTo";
|
|
56
82
|
|
|
57
83
|
// Normalize the field value to an array for internal use
|
|
58
84
|
// belongsTo stores as single object { id }, hasMany/manyToMany store as array
|
|
@@ -72,36 +98,95 @@ export function RelationField({
|
|
|
72
98
|
return (field.value as Array<{ id: string }>) || [];
|
|
73
99
|
}, [field.value, isSingleSelect]);
|
|
74
100
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
// Fetch available items from the target content type (first page only)
|
|
102
|
+
const { items: availableItems, isLoading } = useContent(relation.targetType, {
|
|
103
|
+
limit: 500,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const missingDetailIds = useMemo(() => {
|
|
107
|
+
const loadedIds = new Set(availableItems.map((i) => i.id));
|
|
108
|
+
return normalizedValue
|
|
109
|
+
.map((v) => v.id)
|
|
110
|
+
.filter((id) => id.length > 0 && !loadedIds.has(id));
|
|
111
|
+
}, [availableItems, normalizedValue]);
|
|
112
|
+
|
|
113
|
+
const hydrationResult = useQueries({
|
|
114
|
+
queries: missingDetailIds.map((id) => ({
|
|
115
|
+
...cmsQueries.cmsContent.detail(relation.targetType, id),
|
|
116
|
+
...RELATION_DETAIL_QUERY_OPTS,
|
|
117
|
+
enabled: Boolean(relation.targetType && id),
|
|
118
|
+
})),
|
|
119
|
+
combine: (results) => ({
|
|
120
|
+
data: results.map(
|
|
121
|
+
(r) => r.data as SerializedContentItemWithType | null | undefined,
|
|
122
|
+
),
|
|
123
|
+
isHydrating: results.some((r) => r.isFetching),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const isHydratingLabels = hydrationResult.isHydrating;
|
|
128
|
+
|
|
129
|
+
const itemById = useMemo(() => {
|
|
130
|
+
const m = new Map<string, SerializedContentItemWithType>();
|
|
131
|
+
for (const it of availableItems) {
|
|
132
|
+
m.set(it.id, it as SerializedContentItemWithType);
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < missingDetailIds.length; i++) {
|
|
135
|
+
const row = hydrationResult.data[i];
|
|
136
|
+
if (row?.id) {
|
|
137
|
+
m.set(row.id, row);
|
|
88
138
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
return m;
|
|
141
|
+
}, [availableItems, missingDetailIds, hydrationResult.data]);
|
|
93
142
|
|
|
94
|
-
// Convert
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
// Convert normalized value to Option[] for MultipleSelector
|
|
144
|
+
const selectedOptions: Option[] = normalizedValue.map((v) => {
|
|
145
|
+
const item = itemById.get(v.id);
|
|
146
|
+
if (item) {
|
|
147
|
+
const displayValue =
|
|
148
|
+
(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
|
|
149
|
+
item.slug;
|
|
150
|
+
return {
|
|
151
|
+
value: item.id,
|
|
152
|
+
label: String(displayValue),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
|
|
103
156
|
});
|
|
104
157
|
|
|
158
|
+
// Listed options + any selected partners loaded by id (not on first list page)
|
|
159
|
+
const options: Option[] = useMemo(() => {
|
|
160
|
+
const merged: SerializedContentItemWithType[] = [
|
|
161
|
+
...(availableItems as SerializedContentItemWithType[]),
|
|
162
|
+
];
|
|
163
|
+
const seen = new Set(merged.map((x) => x.id));
|
|
164
|
+
for (let i = 0; i < missingDetailIds.length; i++) {
|
|
165
|
+
const row = hydrationResult.data[i];
|
|
166
|
+
if (row?.id && !seen.has(row.id)) {
|
|
167
|
+
merged.push(row);
|
|
168
|
+
seen.add(row.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return merged.map((item) => {
|
|
172
|
+
const displayValue =
|
|
173
|
+
(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
|
|
174
|
+
item.slug;
|
|
175
|
+
return {
|
|
176
|
+
value: item.id,
|
|
177
|
+
label: String(displayValue),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}, [
|
|
181
|
+
availableItems,
|
|
182
|
+
hydrationResult.data,
|
|
183
|
+
missingDetailIds,
|
|
184
|
+
relation.displayField,
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
// Mutation for creating new items
|
|
188
|
+
const createMutation = useCreateContent(relation.targetType);
|
|
189
|
+
|
|
105
190
|
// Handle selection change - convert back to appropriate format
|
|
106
191
|
const handleChange = useCallback(
|
|
107
192
|
(newOptions: Option[]) => {
|
|
@@ -187,11 +272,11 @@ export function RelationField({
|
|
|
187
272
|
onChange={handleChange}
|
|
188
273
|
options={options}
|
|
189
274
|
placeholder={
|
|
190
|
-
isLoading
|
|
275
|
+
isLoading || isHydratingLabels
|
|
191
276
|
? "Loading..."
|
|
192
277
|
: `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
|
|
193
278
|
}
|
|
194
|
-
disabled={isLoading}
|
|
279
|
+
disabled={isLoading || isHydratingLabels}
|
|
195
280
|
hidePlaceholderWhenSelected
|
|
196
281
|
emptyIndicator={
|
|
197
282
|
<p className="text-center text-sm text-muted-foreground py-4">
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
/** Default upper bound for a single page when no maxPageSize is configured. */
|
|
4
|
+
export const DEFAULT_MAX_PAGE_SIZE = 1000;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
|
-
*
|
|
7
|
+
* Factory that creates the list-content query schema with a configurable
|
|
8
|
+
* upper bound on the `limit` parameter.
|
|
9
|
+
*
|
|
10
|
+
* Use this inside the backend plugin factory (where `config.maxPageSize` is
|
|
11
|
+
* available) so the cap is set at registration time rather than hardcoded.
|
|
5
12
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
export function createListContentQuerySchema(
|
|
14
|
+
maxPageSize = DEFAULT_MAX_PAGE_SIZE,
|
|
15
|
+
) {
|
|
16
|
+
return z.object({
|
|
17
|
+
slug: z.string().optional(),
|
|
18
|
+
limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
|
|
19
|
+
offset: z.coerce.number().min(0).optional().default(0),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Schema for listing content items with pagination.
|
|
25
|
+
* Uses the default maxPageSize (1000).
|
|
26
|
+
*
|
|
27
|
+
* @deprecated Prefer {@link createListContentQuerySchema} inside plugin
|
|
28
|
+
* factories so consumers can configure the upper bound via `maxPageSize`.
|
|
29
|
+
*/
|
|
30
|
+
export const listContentQuerySchema = createListContentQuerySchema();
|
|
11
31
|
|
|
12
32
|
/**
|
|
13
33
|
* Schema for creating a content item
|
package/src/plugins/cms/types.ts
CHANGED
|
@@ -303,4 +303,14 @@ export interface CMSBackendConfig {
|
|
|
303
303
|
contentTypes: ContentTypeConfig[];
|
|
304
304
|
/** Optional hooks for customizing behavior */
|
|
305
305
|
hooks?: CMSBackendHooks;
|
|
306
|
+
/**
|
|
307
|
+
* Maximum number of items that can be requested in a single page.
|
|
308
|
+
* Applied to all list endpoints (`/content/:typeSlug`, by-relation, and inverse-relation).
|
|
309
|
+
*
|
|
310
|
+
* Raise this when your content types have many items and you need to fetch
|
|
311
|
+
* large pages (e.g. for SSG, CSV export, or programmatic access).
|
|
312
|
+
*
|
|
313
|
+
* @default 1000
|
|
314
|
+
*/
|
|
315
|
+
maxPageSize?: number;
|
|
306
316
|
}
|
|
@@ -71,6 +71,7 @@ export interface CommentsThreadDiscriminator {
|
|
|
71
71
|
parentId: string | null | undefined;
|
|
72
72
|
status: string | undefined;
|
|
73
73
|
currentUserId: string | undefined;
|
|
74
|
+
sort: string | undefined;
|
|
74
75
|
limit: number;
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -80,6 +81,7 @@ export function commentsThreadDiscriminator(params?: {
|
|
|
80
81
|
parentId?: string | null;
|
|
81
82
|
status?: string;
|
|
82
83
|
currentUserId?: string;
|
|
84
|
+
sort?: string;
|
|
83
85
|
limit?: number;
|
|
84
86
|
}): CommentsThreadDiscriminator {
|
|
85
87
|
return {
|
|
@@ -88,6 +90,7 @@ export function commentsThreadDiscriminator(params?: {
|
|
|
88
90
|
parentId: params?.parentId,
|
|
89
91
|
status: params?.status,
|
|
90
92
|
currentUserId: params?.currentUserId,
|
|
93
|
+
sort: params?.sort,
|
|
91
94
|
limit: params?.limit ?? 20,
|
|
92
95
|
};
|
|
93
96
|
}
|
|
@@ -128,7 +131,7 @@ export const COMMENTS_QUERY_KEYS = {
|
|
|
128
131
|
|
|
129
132
|
/**
|
|
130
133
|
* Key for the infinite thread query (top-level comments, load-more).
|
|
131
|
-
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, limit }]
|
|
134
|
+
* Full key: ["commentsThread", "list", { resourceId, resourceType, parentId, status, currentUserId, sort, limit }]
|
|
132
135
|
* Offset is excluded — it is driven by `pageParam`, not baked into the key.
|
|
133
136
|
*/
|
|
134
137
|
commentsThread: (params?: {
|
|
@@ -137,6 +140,7 @@ export const COMMENTS_QUERY_KEYS = {
|
|
|
137
140
|
parentId?: string | null;
|
|
138
141
|
status?: string;
|
|
139
142
|
currentUserId?: string;
|
|
143
|
+
sort?: string;
|
|
140
144
|
limit?: number;
|
|
141
145
|
}) =>
|
|
142
146
|
["commentsThread", "list", commentsThreadDiscriminator(params)] as const,
|
|
@@ -98,6 +98,17 @@ export interface CommentThreadProps {
|
|
|
98
98
|
* Defaults to true.
|
|
99
99
|
*/
|
|
100
100
|
allowEditing?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Sort direction for top-level comments by `createdAt`.
|
|
103
|
+
* - `"desc"` (default): newest first.
|
|
104
|
+
* - `"asc"`: oldest first.
|
|
105
|
+
*
|
|
106
|
+
* Replies inside each thread always render chronologically (oldest → newest)
|
|
107
|
+
* and are unaffected by this prop.
|
|
108
|
+
*
|
|
109
|
+
* Overrides the global `defaultCommentSort` from `CommentsPluginOverrides`.
|
|
110
|
+
*/
|
|
111
|
+
sort?: "asc" | "desc";
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
const DEFAULT_RENDERER: ComponentType<CommentRendererProps> = ({ body }) => (
|
|
@@ -325,6 +336,7 @@ function CommentThreadInner({
|
|
|
325
336
|
pageSize: pageSizeProp,
|
|
326
337
|
allowPosting: allowPostingProp,
|
|
327
338
|
allowEditing: allowEditingProp,
|
|
339
|
+
sort: sortProp,
|
|
328
340
|
}: CommentThreadProps) {
|
|
329
341
|
const overrides = usePluginOverrides<
|
|
330
342
|
CommentsPluginOverrides,
|
|
@@ -334,6 +346,7 @@ function CommentThreadInner({
|
|
|
334
346
|
pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;
|
|
335
347
|
const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;
|
|
336
348
|
const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;
|
|
349
|
+
const sort = sortProp ?? overrides.defaultCommentSort ?? "desc";
|
|
337
350
|
const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };
|
|
338
351
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
339
352
|
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(
|
|
@@ -357,6 +370,7 @@ function CommentThreadInner({
|
|
|
357
370
|
status: "approved",
|
|
358
371
|
parentId: null,
|
|
359
372
|
currentUserId,
|
|
373
|
+
sort,
|
|
360
374
|
pageSize,
|
|
361
375
|
});
|
|
362
376
|
|
|
@@ -366,6 +380,7 @@ function CommentThreadInner({
|
|
|
366
380
|
currentUserId,
|
|
367
381
|
infiniteKey: threadQueryKey,
|
|
368
382
|
pageSize,
|
|
383
|
+
sort,
|
|
369
384
|
});
|
|
370
385
|
|
|
371
386
|
const handlePost = async (body: string) => {
|
|
@@ -164,6 +164,11 @@ export function useInfiniteComments(
|
|
|
164
164
|
parentId?: string | null;
|
|
165
165
|
status?: "pending" | "approved" | "spam";
|
|
166
166
|
currentUserId?: string;
|
|
167
|
+
/**
|
|
168
|
+
* Sort direction by `createdAt`. Default: `"asc"` (oldest first) — matches
|
|
169
|
+
* the server-side default. Pass `"desc"` for newest-first threads.
|
|
170
|
+
*/
|
|
171
|
+
sort?: "asc" | "desc";
|
|
167
172
|
pageSize?: number;
|
|
168
173
|
},
|
|
169
174
|
options?: { enabled?: boolean },
|
|
@@ -178,6 +183,7 @@ export function useInfiniteComments(
|
|
|
178
183
|
parentId: params.parentId ?? null,
|
|
179
184
|
status: params.status,
|
|
180
185
|
currentUserId: params.currentUserId,
|
|
186
|
+
sort: params.sort,
|
|
181
187
|
limit: pageSize,
|
|
182
188
|
});
|
|
183
189
|
|
|
@@ -266,6 +272,17 @@ export function usePostComment(
|
|
|
266
272
|
* `nextOffset` from `lastPage.limit` instead of a hardcoded fallback.
|
|
267
273
|
*/
|
|
268
274
|
pageSize?: number;
|
|
275
|
+
/**
|
|
276
|
+
* Sort direction of the surrounding infinite thread.
|
|
277
|
+
* - `"asc"` (default): newest comments belong on the LAST page → optimistic
|
|
278
|
+
* item is appended to `pages[last].items`.
|
|
279
|
+
* - `"desc"`: newest comments belong on the FIRST page → optimistic item
|
|
280
|
+
* is prepended to `pages[0].items`.
|
|
281
|
+
*
|
|
282
|
+
* Must match the `sort` passed to `useInfiniteComments` so the optimistic
|
|
283
|
+
* item appears in the same position the server will place it.
|
|
284
|
+
*/
|
|
285
|
+
sort?: "asc" | "desc";
|
|
269
286
|
},
|
|
270
287
|
) {
|
|
271
288
|
const queryClient = useQueryClient();
|
|
@@ -350,6 +367,7 @@ export function usePostComment(
|
|
|
350
367
|
const previous =
|
|
351
368
|
queryClient.getQueryData<InfiniteData<CommentListResult>>(listKey);
|
|
352
369
|
|
|
370
|
+
const isDesc = params.sort === "desc";
|
|
353
371
|
queryClient.setQueryData<InfiniteData<CommentListResult>>(
|
|
354
372
|
listKey,
|
|
355
373
|
(old) => {
|
|
@@ -366,16 +384,21 @@ export function usePostComment(
|
|
|
366
384
|
pageParams: [0],
|
|
367
385
|
};
|
|
368
386
|
}
|
|
369
|
-
|
|
387
|
+
// For asc (oldest-first) threads the new comment lives at the end →
|
|
388
|
+
// append to the last page. For desc (newest-first) threads it lives
|
|
389
|
+
// at the top → prepend to the first page.
|
|
390
|
+
const targetIdx = isDesc ? 0 : old.pages.length - 1;
|
|
370
391
|
return {
|
|
371
392
|
...old,
|
|
372
393
|
// Increment `total` on every page so the header count (which reads
|
|
373
394
|
// pages[0].total) stays in sync even after multiple pages are loaded.
|
|
374
395
|
pages: old.pages.map((page, idx) =>
|
|
375
|
-
idx ===
|
|
396
|
+
idx === targetIdx
|
|
376
397
|
? {
|
|
377
398
|
...page,
|
|
378
|
-
items:
|
|
399
|
+
items: isDesc
|
|
400
|
+
? [optimistic, ...page.items]
|
|
401
|
+
: [...page.items, optimistic],
|
|
379
402
|
total: page.total + 1,
|
|
380
403
|
}
|
|
381
404
|
: { ...page, total: page.total + 1 },
|
|
@@ -75,6 +75,16 @@ export interface CommentsPluginOverrides {
|
|
|
75
75
|
*/
|
|
76
76
|
defaultCommentPageSize?: number;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Default sort direction (by `createdAt`) for top-level comments in
|
|
80
|
+
* `CommentThread`.
|
|
81
|
+
* - `"desc"` (default): newest comments first.
|
|
82
|
+
* - `"asc"`: oldest comments first.
|
|
83
|
+
*
|
|
84
|
+
* Can be overridden per-instance via the `sort` prop on `CommentThread`.
|
|
85
|
+
*/
|
|
86
|
+
defaultCommentSort?: "asc" | "desc";
|
|
87
|
+
|
|
78
88
|
/**
|
|
79
89
|
* When false, the comment form and reply buttons are hidden in all
|
|
80
90
|
* `CommentThread` instances. Users can still read existing comments.
|
|
@@ -142,6 +142,7 @@ function createCommentsThreadQueries(
|
|
|
142
142
|
parentId?: string | null;
|
|
143
143
|
status?: "pending" | "approved" | "spam";
|
|
144
144
|
currentUserId?: string;
|
|
145
|
+
sort?: "asc" | "desc";
|
|
145
146
|
limit?: number;
|
|
146
147
|
}) => ({
|
|
147
148
|
// Offset is excluded from the key — it is driven by pageParam.
|
|
@@ -162,6 +163,7 @@ function createCommentsThreadQueries(
|
|
|
162
163
|
// The server resolves the caller's identity server-side via the
|
|
163
164
|
// resolveCurrentUserId hook. It is still included in the queryKey
|
|
164
165
|
// above for client-side cache segregation.
|
|
166
|
+
sort: params?.sort,
|
|
165
167
|
limit: params?.limit ?? 20,
|
|
166
168
|
offset: pageParam ?? 0,
|
|
167
169
|
},
|