akanjs 3.0.0-alpha.96 → 3.0.0-alpha.98
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/fetch/client/sliceInitHandle.ts +2 -0
- package/fetch/fetchType/appliedReturn.type.ts +2 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/store/action.ts +79 -17
- package/store/draftStore.ts +14 -0
- package/store/liveInsert.ts +9 -2
- package/store/sliceRole.ts +1 -1
- package/store/state.ts +16 -0
- package/store/storeInstance.ts +6 -2
- package/types/fetch/fetchType/appliedReturn.type.d.ts +2 -0
- package/types/store/action.d.ts +4 -2
- package/types/store/baseSt.d.ts +2 -2
- package/types/store/draftStore.d.ts +7 -0
- package/types/store/liveInsert.d.ts +5 -1
- package/types/store/sliceRole.d.ts +1 -1
- package/types/store/state.d.ts +5 -1
- package/types/ui/InfiniteScroll.d.ts +8 -8
- package/types/ui/Model/DraftBar.d.ts +17 -7
- package/types/ui/Model/Edit.d.ts +4 -1
- package/types/ui/Model/EditModal.d.ts +3 -1
- package/types/ui/More.d.ts +3 -2
- package/types/ui/UiOverride/context.d.ts +2 -0
- package/ui/InfiniteScroll.tsx +65 -31
- package/ui/Load/Pagination.tsx +0 -2
- package/ui/Load/Units.tsx +16 -5
- package/ui/Model/DraftBar.tsx +76 -33
- package/ui/Model/Edit.tsx +6 -1
- package/ui/Model/EditModal.tsx +7 -1
- package/ui/More.tsx +9 -16
- package/ui/UiOverride/context.ts +3 -0
|
@@ -78,6 +78,8 @@ export class SliceInitHandle {
|
|
|
78
78
|
[`pageOf${capRefName}`]: page,
|
|
79
79
|
[`lastPageOf${capRefName}`]: lastPage,
|
|
80
80
|
[`limitOf${capRefName}`]: limit,
|
|
81
|
+
|
|
82
|
+
[`hasMoreOf${capRefName}`]: !!limit && modelObjList.length >= limit,
|
|
81
83
|
[`queryArgsOf${capRefName}`]: queryArgs,
|
|
82
84
|
[`sortOf${capRefName}`]: sort,
|
|
83
85
|
[`${refName}InitAt`]: new Date(),
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/store/action.ts
CHANGED
|
@@ -177,7 +177,7 @@ export type SliceAction<
|
|
|
177
177
|
} & {
|
|
178
178
|
[K in `setPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
|
|
179
179
|
} & {
|
|
180
|
-
[K in `
|
|
180
|
+
[K in `loadMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (options?: FetchPolicy) => Promise<void>;
|
|
181
181
|
} & {
|
|
182
182
|
[K in `setLimitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (
|
|
183
183
|
limit: number,
|
|
@@ -273,11 +273,14 @@ type DefaultSliceActionFields<
|
|
|
273
273
|
} & {
|
|
274
274
|
[Suffix in _Suffixes as
|
|
275
275
|
| `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`
|
|
276
|
-
| `addPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`
|
|
277
276
|
| `setLimitOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
|
|
278
277
|
value: number,
|
|
279
278
|
options?: FetchPolicy,
|
|
280
279
|
) => Promise<void>;
|
|
280
|
+
} & {
|
|
281
|
+
[Suffix in _Suffixes as `loadMoreOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
|
|
282
|
+
options?: FetchPolicy,
|
|
283
|
+
) => Promise<void>;
|
|
281
284
|
} & {
|
|
282
285
|
[Suffix in _Suffixes as `setQueryArgsOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (
|
|
283
286
|
...args:
|
|
@@ -528,7 +531,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
528
531
|
refreshModel: `refresh${className}`,
|
|
529
532
|
selectModel: `select${className}`,
|
|
530
533
|
setPageOfModel: `setPageOf${className}`,
|
|
531
|
-
|
|
534
|
+
loadMoreOfModel: `loadMoreOf${className}`,
|
|
532
535
|
setLimitOfModel: `setLimitOf${className}`,
|
|
533
536
|
setQueryArgsOfModel: `setQueryArgsOf${className}`,
|
|
534
537
|
setSortOfModel: `setSortOf${className}`,
|
|
@@ -537,6 +540,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
537
540
|
lastPageOfModel: `lastPageOf${className}`,
|
|
538
541
|
pageOfModel: `pageOf${className}`,
|
|
539
542
|
limitOfModel: `limitOf${className}`,
|
|
543
|
+
hasMoreOfModel: `hasMoreOf${className}`,
|
|
544
|
+
isCumulativeOfModel: `isCumulativeOf${className}`,
|
|
540
545
|
queryArgsOfModel: `queryArgsOf${className}`,
|
|
541
546
|
sortOfModel: `sortOf${className}`,
|
|
542
547
|
};
|
|
@@ -579,6 +584,12 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
579
584
|
|
|
580
585
|
const current = (this.get() as { [key: string]: any })[names.modelDraft] as DraftState | null;
|
|
581
586
|
if (current?.key !== draft.key) return;
|
|
587
|
+
|
|
588
|
+
const openedForm = (this.get() as { [key: string]: any })[names.modelForm] as object;
|
|
589
|
+
if (DraftStore.contentHash(record.form) === DraftStore.contentHash(DraftStore.encodeForm(refName, openedForm))) {
|
|
590
|
+
await DraftStore.remove(draft.key);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
582
593
|
let form: object;
|
|
583
594
|
try {
|
|
584
595
|
form = DraftStore.decodeForm(refName, record.form);
|
|
@@ -1034,7 +1045,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1034
1045
|
refreshModel: SliceName.replace(names.Model, names.refreshModel),
|
|
1035
1046
|
selectModel: SliceName.replace(names.Model, names.selectModel),
|
|
1036
1047
|
setPageOfModel: SliceName.replace(names.Model, names.setPageOfModel),
|
|
1037
|
-
|
|
1048
|
+
loadMoreOfModel: SliceName.replace(names.Model, names.loadMoreOfModel),
|
|
1038
1049
|
setLimitOfModel: SliceName.replace(names.Model, names.setLimitOfModel),
|
|
1039
1050
|
setQueryArgsOfModel: SliceName.replace(names.Model, names.setQueryArgsOfModel),
|
|
1040
1051
|
setSortOfModel: SliceName.replace(names.Model, names.setSortOfModel),
|
|
@@ -1043,10 +1054,23 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1043
1054
|
lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
|
|
1044
1055
|
pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
|
|
1045
1056
|
limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
|
|
1057
|
+
hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
|
|
1058
|
+
isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
|
|
1046
1059
|
queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
|
|
1047
1060
|
sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
|
|
1048
1061
|
modelSelection: SliceName.replace(names.Model, names.modelSelection),
|
|
1049
1062
|
};
|
|
1063
|
+
/**
|
|
1064
|
+
* Whether the server still holds rows past the ones just returned.
|
|
1065
|
+
*
|
|
1066
|
+
* Read off the batch rather than off `<model>Insight`, because the count is a second query — absent entirely
|
|
1067
|
+
* under `{ insight: false }`, and one live event out of step with the list the rest of the time. A short
|
|
1068
|
+
* batch is the server saying it had nothing more; a full one costs at most one extra request that comes back
|
|
1069
|
+
* empty when the total happens to be a multiple of the limit.
|
|
1070
|
+
*/
|
|
1071
|
+
const hasMoreFrom = (batch: unknown[], askedFor: number) => ({
|
|
1072
|
+
[namesOfSlice.hasMoreOfModel]: batch.length >= askedFor && askedFor > 0,
|
|
1073
|
+
});
|
|
1050
1074
|
const singleSliceAction = {
|
|
1051
1075
|
[namesOfSlice.initModel]: async function (
|
|
1052
1076
|
this: SetGet,
|
|
@@ -1057,7 +1081,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1057
1081
|
FetchPolicy;
|
|
1058
1082
|
const queryArgs = new Array(initArgLength).fill(null).map((_, i) => args[i] as object);
|
|
1059
1083
|
const defaultModel = new cnst.full().set(initForm.default ?? {}) as unknown as Full;
|
|
1060
|
-
this.set({ [names.defaultModel]: defaultModel });
|
|
1084
|
+
this.set({ [names.defaultModel]: defaultModel, [namesOfSlice.isCumulativeOfModel]: false });
|
|
1061
1085
|
await ((this as unknown as DynamicRecord)[namesOfSlice.refreshModel] as (...args: any[]) => Promise<void>)({
|
|
1062
1086
|
...initForm,
|
|
1063
1087
|
queryArgs,
|
|
@@ -1089,6 +1113,10 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1089
1113
|
...fetchPolicy
|
|
1090
1114
|
} = initForm;
|
|
1091
1115
|
const modelOperation = currentState[names.modelOperation] as string;
|
|
1116
|
+
const isCumulative = currentState[namesOfSlice.isCumulativeOfModel] as boolean;
|
|
1117
|
+
const loadedLength = (currentState[namesOfSlice.modelList] as DataList<Light>).length;
|
|
1118
|
+
|
|
1119
|
+
const fetchLimit = isCumulative ? Math.max(limit, loadedLength) : limit;
|
|
1092
1120
|
const queryArgsOfModel = currentState[namesOfSlice.queryArgsOfModel] as object[];
|
|
1093
1121
|
const pageOfModel = currentState[namesOfSlice.pageOfModel] as number;
|
|
1094
1122
|
const limitOfModel = currentState[namesOfSlice.limitOfModel] as number;
|
|
@@ -1112,7 +1140,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1112
1140
|
(fetch[namesOfSlice.modelList] as (...args: any[]) => Promise<Light[]>)(
|
|
1113
1141
|
...fetchQueryArgs,
|
|
1114
1142
|
(page - 1) * limit,
|
|
1115
|
-
|
|
1143
|
+
fetchLimit,
|
|
1116
1144
|
sort,
|
|
1117
1145
|
{ ...fetchPolicy, onError: initForm.onError },
|
|
1118
1146
|
),
|
|
@@ -1134,6 +1162,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1134
1162
|
[namesOfSlice.modelInitList]: modelList,
|
|
1135
1163
|
[namesOfSlice.modelInitAt]: new Date(),
|
|
1136
1164
|
[namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / (limit || 20)) + 1, 1),
|
|
1165
|
+
...hasMoreFrom(modelDataList, fetchLimit),
|
|
1137
1166
|
[namesOfSlice.limitOfModel]: limit,
|
|
1138
1167
|
[namesOfSlice.queryArgsOfModel]: queryArgs,
|
|
1139
1168
|
[namesOfSlice.sortOfModel]: sort,
|
|
@@ -1183,35 +1212,58 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1183
1212
|
this.set({
|
|
1184
1213
|
[namesOfSlice.modelList]: new DataList(modelDataList),
|
|
1185
1214
|
[namesOfSlice.pageOfModel]: page,
|
|
1215
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
1216
|
+
...hasMoreFrom(modelDataList, limitOfModel),
|
|
1186
1217
|
});
|
|
1187
1218
|
} finally {
|
|
1188
1219
|
if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
|
|
1189
1220
|
}
|
|
1190
1221
|
},
|
|
1191
|
-
|
|
1222
|
+
/**
|
|
1223
|
+
* Appends the rows after the ones already loaded, turning this slice's window into a cumulative list.
|
|
1224
|
+
*
|
|
1225
|
+
* The offset is the list on screen rather than a page number, and that is the whole point. A page number
|
|
1226
|
+
* assumes the server's ordering baseline has not moved, which a live insertion at the front breaks on the
|
|
1227
|
+
* first event; the rows in hand are the first `n` the query returns whatever has been inserted since, so
|
|
1228
|
+
* asking for what comes after them cannot drift from what is displayed. It also leaves `pageOf<Model>` at
|
|
1229
|
+
* 1, which is what keeps live placement — refused anywhere but the first page — working past the first
|
|
1230
|
+
* "more".
|
|
1231
|
+
*
|
|
1232
|
+
* It reads `<model>ListLoading` and never sets it: an append leaves the rows on screen, so raising it
|
|
1233
|
+
* would put the whole-list spinner over one and would make `applyLive<Model>` drop every live event until
|
|
1234
|
+
* the batch lands. So concurrent calls are NOT rejected — two of them fetch the same offset, since the
|
|
1235
|
+
* list they measure has not grown yet. That is a wasted round trip and never a wrong list: the ticket
|
|
1236
|
+
* makes only the newest response apply, and both asked for the same rows. A caller that minds the wasted
|
|
1237
|
+
* request holds its own in-flight flag, the way `InfiniteScroll` does.
|
|
1238
|
+
*/
|
|
1239
|
+
[namesOfSlice.loadMoreOfModel]: async function (this: SetGet, options?: FetchPolicy) {
|
|
1192
1240
|
const currentState = this.get() as { [key: string]: any };
|
|
1241
|
+
|
|
1242
|
+
if (currentState[namesOfSlice.modelListLoading] as boolean) return;
|
|
1243
|
+
if (!(currentState[namesOfSlice.hasMoreOfModel] as boolean)) return;
|
|
1193
1244
|
const modelList = currentState[namesOfSlice.modelList] as DataList<Light>;
|
|
1194
1245
|
const queryArgsOfModel = currentState[namesOfSlice.queryArgsOfModel] as object[];
|
|
1195
1246
|
const pageOfModel = currentState[namesOfSlice.pageOfModel] as number;
|
|
1196
|
-
const limitOfModel = currentState[namesOfSlice.limitOfModel] as number;
|
|
1247
|
+
const limitOfModel = (currentState[namesOfSlice.limitOfModel] as number) || 20;
|
|
1197
1248
|
const sortOfModel = currentState[namesOfSlice.sortOfModel] as Sort;
|
|
1198
|
-
if (pageOfModel === page) return;
|
|
1199
|
-
const addFront = page < pageOfModel;
|
|
1200
1249
|
const ticket = requests.claim();
|
|
1201
1250
|
const fetchQueryArgs = expandQueryArgs(queryArgsOfModel, slice.args);
|
|
1202
1251
|
const modelDataList = await (fetch[namesOfSlice.modelList] as (...args: any[]) => Promise<Light[]>)(
|
|
1203
1252
|
...fetchQueryArgs,
|
|
1204
|
-
(
|
|
1253
|
+
(pageOfModel - 1) * limitOfModel + modelList.length,
|
|
1205
1254
|
limitOfModel,
|
|
1206
1255
|
sortOfModel,
|
|
1207
1256
|
options,
|
|
1208
1257
|
);
|
|
1209
1258
|
|
|
1210
1259
|
if (!requests.isCurrent(ticket)) return;
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1260
|
+
|
|
1261
|
+
const currentModelList = (this.get() as { [key: string]: any })[namesOfSlice.modelList] as DataList<Light>;
|
|
1262
|
+
this.set({
|
|
1263
|
+
[namesOfSlice.modelList]: new DataList([...currentModelList.values, ...modelDataList]),
|
|
1264
|
+
[namesOfSlice.isCumulativeOfModel]: true,
|
|
1265
|
+
...hasMoreFrom(modelDataList, limitOfModel),
|
|
1266
|
+
});
|
|
1215
1267
|
},
|
|
1216
1268
|
[namesOfSlice.setLimitOfModel]: async function (this: SetGet, limit: number, options?: FetchPolicy) {
|
|
1217
1269
|
const currentState = this.get() as { [key: string]: any };
|
|
@@ -1240,6 +1292,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1240
1292
|
[namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / limit) + 1, 1),
|
|
1241
1293
|
[namesOfSlice.limitOfModel]: limit,
|
|
1242
1294
|
[namesOfSlice.pageOfModel]: page,
|
|
1295
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
1296
|
+
...hasMoreFrom(modelDataList, limit),
|
|
1243
1297
|
});
|
|
1244
1298
|
} finally {
|
|
1245
1299
|
if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
|
|
@@ -1291,6 +1345,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1291
1345
|
[namesOfSlice.modelInsight]: modelInsight,
|
|
1292
1346
|
[namesOfSlice.lastPageOfModel]: Math.max(Math.floor((modelInsight.count - 1) / limitOfModel) + 1, 1),
|
|
1293
1347
|
[namesOfSlice.pageOfModel]: 1,
|
|
1348
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
1349
|
+
...hasMoreFrom(modelDataList, limitOfModel),
|
|
1294
1350
|
[namesOfSlice.modelSelection]: new Map(),
|
|
1295
1351
|
});
|
|
1296
1352
|
} finally {
|
|
@@ -1319,6 +1375,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1319
1375
|
[namesOfSlice.modelList]: new DataList(modelDataList),
|
|
1320
1376
|
[namesOfSlice.sortOfModel]: sort,
|
|
1321
1377
|
[namesOfSlice.pageOfModel]: 1,
|
|
1378
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
1379
|
+
...hasMoreFrom(modelDataList, limitOfModel),
|
|
1322
1380
|
});
|
|
1323
1381
|
} finally {
|
|
1324
1382
|
if (requests.isCurrent(ticket)) this.set({ [namesOfSlice.modelListLoading]: false });
|
|
@@ -1329,7 +1387,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1329
1387
|
*
|
|
1330
1388
|
* The verb already answers membership — the server evaluated this slice's own query against the document
|
|
1331
1389
|
* before and after the write, so arriving here at all is the proof, and nothing is re-checked. What is left
|
|
1332
|
-
* is the window: `<slice>List` is
|
|
1390
|
+
* is the window: `<slice>List` is a prefix of the list, not the list, so an insertion is only ever attempted
|
|
1333
1391
|
* where its position can be known. Everywhere else the list is stamped stale and refetched, which is always
|
|
1334
1392
|
* correct and merely costs a round trip.
|
|
1335
1393
|
*/
|
|
@@ -1344,6 +1402,7 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1344
1402
|
}
|
|
1345
1403
|
const modelInsight = currentState[namesOfSlice.modelInsight] as Insight & BaseInsight;
|
|
1346
1404
|
const limit = (currentState[namesOfSlice.limitOfModel] as number) || 20;
|
|
1405
|
+
const isCumulative = currentState[namesOfSlice.isCumulativeOfModel] as boolean;
|
|
1347
1406
|
const countedTo = (count: number) => ({
|
|
1348
1407
|
[namesOfSlice.modelInsight]: new cnst.insight().set({ ...modelInsight, count }),
|
|
1349
1408
|
[namesOfSlice.lastPageOfModel]: Math.max(Math.floor((count - 1) / limit) + 1, 1),
|
|
@@ -1372,6 +1431,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1372
1431
|
row: light as unknown as LiveSortableRow,
|
|
1373
1432
|
page: currentState[namesOfSlice.pageOfModel] as number,
|
|
1374
1433
|
limit,
|
|
1434
|
+
cumulative: isCumulative,
|
|
1435
|
+
hasMore: currentState[namesOfSlice.hasMoreOfModel] as boolean,
|
|
1375
1436
|
sortKey: String(currentState[namesOfSlice.sortOfModel]),
|
|
1376
1437
|
allowedSorts: slice.live?.sort ?? [],
|
|
1377
1438
|
sorts: fetch.sortValueMap?.get(refName),
|
|
@@ -1382,7 +1443,8 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1382
1443
|
return;
|
|
1383
1444
|
}
|
|
1384
1445
|
|
|
1385
|
-
const
|
|
1446
|
+
const inserted = [...modelList.slice(0, placement), light, ...modelList.slice(placement)];
|
|
1447
|
+
const placed = isCumulative ? inserted : inserted.slice(0, limit);
|
|
1386
1448
|
this.set({
|
|
1387
1449
|
[namesOfSlice.modelList]: new DataList(placed as Light[]),
|
|
1388
1450
|
...countedTo(modelInsight.count + 1),
|
package/store/draftStore.ts
CHANGED
|
@@ -17,6 +17,9 @@ const MAX_DRAFTS_PER_IDENTITY = 30;
|
|
|
17
17
|
*/
|
|
18
18
|
const VOLATILE_CLAIMS = ["iat", "exp", "nbf", "jti"] as const;
|
|
19
19
|
|
|
20
|
+
/** The fields a form carries from its row rather than from what the user typed. */
|
|
21
|
+
const RECORD_STAMPS = new Set(["createdAt", "updatedAt", "removedAt"]);
|
|
22
|
+
|
|
20
23
|
export interface DraftRecord {
|
|
21
24
|
v: number;
|
|
22
25
|
/** ISO. Drives both the "n minutes ago" label and the TTL/LRU sweep. */
|
|
@@ -143,6 +146,17 @@ export class DraftStore {
|
|
|
143
146
|
return DraftStore.#hash8(DraftStore.#stableStringify(DraftStore.encodeForm(refName, form)));
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
/**
|
|
150
|
+
* What two encoded forms holding the same values hash to, whatever record stamps they carry.
|
|
151
|
+
*
|
|
152
|
+
* The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
|
|
153
|
+
* would otherwise make a draft look different from the record that already holds it.
|
|
154
|
+
*/
|
|
155
|
+
static contentHash(encoded: Record<string, unknown>): string {
|
|
156
|
+
const content = Object.fromEntries(Object.entries(encoded).filter(([key]) => !RECORD_STAMPS.has(key)));
|
|
157
|
+
return DraftStore.#hash8(DraftStore.#stableStringify(content));
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
static async read(key: string): Promise<DraftRecord | null> {
|
|
147
161
|
const storage = DraftStore.#storage();
|
|
148
162
|
if (!storage) return null;
|
package/store/liveInsert.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface LivePlacementProps {
|
|
|
8
8
|
/** Which page of the list is on screen. Only the first can place a row. */
|
|
9
9
|
page: number;
|
|
10
10
|
limit: number;
|
|
11
|
+
/** Whether the list is pages `1..N` concatenated rather than one window. */
|
|
12
|
+
cumulative: boolean;
|
|
13
|
+
/** Whether the server still holds rows past the ones in hand. */
|
|
14
|
+
hasMore: boolean;
|
|
11
15
|
/** The sort key the window is ordered by. */
|
|
12
16
|
sortKey: string;
|
|
13
17
|
/** The sort keys the slice declared a subscriber may reproduce. */
|
|
@@ -38,6 +42,8 @@ export const livePlacementIndex = ({
|
|
|
38
42
|
row,
|
|
39
43
|
page,
|
|
40
44
|
limit,
|
|
45
|
+
cumulative,
|
|
46
|
+
hasMore,
|
|
41
47
|
sortKey,
|
|
42
48
|
allowedSorts,
|
|
43
49
|
sorts,
|
|
@@ -49,9 +55,10 @@ export const livePlacementIndex = ({
|
|
|
49
55
|
const paths = Object.entries(sort);
|
|
50
56
|
if (paths.some(([path]) => comparableOf(row[path]) === null)) return null;
|
|
51
57
|
const index = list.findIndex((item) => compareRows(row, item, paths) < 0);
|
|
58
|
+
if (index !== -1) return index;
|
|
52
59
|
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
60
|
+
if (cumulative) return hasMore ? null : list.length;
|
|
61
|
+
return list.length < limit ? list.length : null;
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
const compareRows = (left: LiveSortableRow, right: LiveSortableRow, paths: [string, 1 | -1][]): number => {
|
package/store/sliceRole.ts
CHANGED
package/store/state.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type SliceStateKey =
|
|
|
49
49
|
| "lastPageOfModel"
|
|
50
50
|
| "pageOfModel"
|
|
51
51
|
| "limitOfModel"
|
|
52
|
+
| "hasMoreOfModel"
|
|
53
|
+
| "isCumulativeOfModel"
|
|
52
54
|
| "queryArgsOfModel"
|
|
53
55
|
| "sortOfModel";
|
|
54
56
|
type _SliceMap<S extends SliceCls> = StoreSliceMap<S>;
|
|
@@ -114,6 +116,10 @@ export type SliceState<
|
|
|
114
116
|
[K in `pageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
|
|
115
117
|
} & {
|
|
116
118
|
[K in `limitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
|
|
119
|
+
} & {
|
|
120
|
+
[K in
|
|
121
|
+
| `hasMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`
|
|
122
|
+
| `isCumulativeOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: boolean;
|
|
117
123
|
} & {
|
|
118
124
|
[K in `queryArgsOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: Args;
|
|
119
125
|
} & {
|
|
@@ -150,6 +156,10 @@ type DefaultSliceStateFields<
|
|
|
150
156
|
| `lastPageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
|
|
151
157
|
| `pageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
|
|
152
158
|
| `limitOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: number;
|
|
159
|
+
} & {
|
|
160
|
+
[Suffix in _Suffixes as
|
|
161
|
+
| `hasMoreOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`
|
|
162
|
+
| `isCumulativeOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: boolean;
|
|
153
163
|
} & {
|
|
154
164
|
[Suffix in _Suffixes as `queryArgsOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: StoreSliceArgs<
|
|
155
165
|
SlceCls,
|
|
@@ -206,6 +216,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
|
|
|
206
216
|
lastPageOfModel: `lastPageOf${className}`,
|
|
207
217
|
pageOfModel: `pageOf${className}`,
|
|
208
218
|
limitOfModel: `limitOf${className}`,
|
|
219
|
+
hasMoreOfModel: `hasMoreOf${className}`,
|
|
220
|
+
isCumulativeOfModel: `isCumulativeOf${className}`,
|
|
209
221
|
queryArgsOfModel: `queryArgsOf${className}`,
|
|
210
222
|
sortOfModel: `sortOf${className}`,
|
|
211
223
|
};
|
|
@@ -225,6 +237,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
|
|
|
225
237
|
lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
|
|
226
238
|
pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
|
|
227
239
|
limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
|
|
240
|
+
hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
|
|
241
|
+
isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
|
|
228
242
|
queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
|
|
229
243
|
sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
|
|
230
244
|
};
|
|
@@ -240,6 +254,8 @@ export const createSliceState = (refName: string, slice: { [key: string]: Serial
|
|
|
240
254
|
[namesOfSlice.lastPageOfModel]: 1,
|
|
241
255
|
[namesOfSlice.pageOfModel]: 1,
|
|
242
256
|
[namesOfSlice.limitOfModel]: 20,
|
|
257
|
+
[namesOfSlice.hasMoreOfModel]: false,
|
|
258
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
243
259
|
[namesOfSlice.queryArgsOfModel]: [],
|
|
244
260
|
[namesOfSlice.sortOfModel]: "latest",
|
|
245
261
|
};
|
package/store/storeInstance.ts
CHANGED
|
@@ -433,6 +433,8 @@ export class StoreInstance {
|
|
|
433
433
|
modelStaleAt: `${fieldName}StaleAt`,
|
|
434
434
|
pageOfModel: `pageOf${className}`,
|
|
435
435
|
limitOfModel: `limitOf${className}`,
|
|
436
|
+
hasMoreOfModel: `hasMoreOf${className}`,
|
|
437
|
+
isCumulativeOfModel: `isCumulativeOf${className}`,
|
|
436
438
|
queryArgsOfModel: `queryArgsOf${className}`,
|
|
437
439
|
sortOfModel: `sortOf${className}`,
|
|
438
440
|
modelSelection: `${fieldName}Selection`,
|
|
@@ -440,7 +442,7 @@ export class StoreInstance {
|
|
|
440
442
|
refreshModel: `refresh${className}`,
|
|
441
443
|
selectModel: `select${className}`,
|
|
442
444
|
setPageOfModel: `setPageOf${className}`,
|
|
443
|
-
|
|
445
|
+
loadMoreOfModel: `loadMoreOf${className}`,
|
|
444
446
|
setLimitOfModel: `setLimitOf${className}`,
|
|
445
447
|
setQueryArgsOfModel: `setQueryArgsOf${className}`,
|
|
446
448
|
setSortOfModel: `setSortOf${className}`,
|
|
@@ -460,6 +462,8 @@ export class StoreInstance {
|
|
|
460
462
|
lastPageOfModel: SliceName.replace(names.Model, names.lastPageOfModel),
|
|
461
463
|
pageOfModel: SliceName.replace(names.Model, names.pageOfModel),
|
|
462
464
|
limitOfModel: SliceName.replace(names.Model, names.limitOfModel),
|
|
465
|
+
hasMoreOfModel: SliceName.replace(names.Model, names.hasMoreOfModel),
|
|
466
|
+
isCumulativeOfModel: SliceName.replace(names.Model, names.isCumulativeOfModel),
|
|
463
467
|
queryArgsOfModel: SliceName.replace(names.Model, names.queryArgsOfModel),
|
|
464
468
|
sortOfModel: SliceName.replace(names.Model, names.sortOfModel),
|
|
465
469
|
modelSelection: SliceName.replace(names.Model, names.modelSelection),
|
|
@@ -469,7 +473,7 @@ export class StoreInstance {
|
|
|
469
473
|
refreshModel: SliceName.replace(names.Model, names.refreshModel),
|
|
470
474
|
selectModel: SliceName.replace(names.Model, names.selectModel),
|
|
471
475
|
setPageOfModel: SliceName.replace(names.Model, names.setPageOfModel),
|
|
472
|
-
|
|
476
|
+
loadMoreOfModel: SliceName.replace(names.Model, names.loadMoreOfModel),
|
|
473
477
|
setLimitOfModel: SliceName.replace(names.Model, names.setLimitOfModel),
|
|
474
478
|
setQueryArgsOfModel: SliceName.replace(names.Model, names.setQueryArgsOfModel),
|
|
475
479
|
setSortOfModel: SliceName.replace(names.Model, names.setSortOfModel),
|
|
@@ -32,6 +32,8 @@ type ServerInitShape<RefName extends string, QueryArgs, CapRefName extends strin
|
|
|
32
32
|
[K in `lastPageOf${CapRefName}`]: number;
|
|
33
33
|
} & {
|
|
34
34
|
[K in `limitOf${CapRefName}`]: number;
|
|
35
|
+
} & {
|
|
36
|
+
[K in `hasMoreOf${CapRefName}`]: boolean;
|
|
35
37
|
} & {
|
|
36
38
|
[K in `queryArgsOf${CapRefName}`]: QueryArgs;
|
|
37
39
|
} & {
|
package/types/store/action.d.ts
CHANGED
|
@@ -100,7 +100,7 @@ export type SliceAction<RefName extends string, Suffix extends string, Input, Li
|
|
|
100
100
|
} & {
|
|
101
101
|
[K in `setPageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (page: number, options?: FetchPolicy) => Promise<void>;
|
|
102
102
|
} & {
|
|
103
|
-
[K in `
|
|
103
|
+
[K in `loadMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (options?: FetchPolicy) => Promise<void>;
|
|
104
104
|
} & {
|
|
105
105
|
[K in `setLimitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: (limit: number, options?: FetchPolicy) => Promise<void>;
|
|
106
106
|
} & {
|
|
@@ -143,7 +143,9 @@ type DefaultSliceActionFields<SlceCls extends SliceCls, _CapRef extends string,
|
|
|
143
143
|
remove?: boolean;
|
|
144
144
|
}) => void;
|
|
145
145
|
} & {
|
|
146
|
-
[Suffix in _Suffixes as `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `
|
|
146
|
+
[Suffix in _Suffixes as `setPageOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `setLimitOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (value: number, options?: FetchPolicy) => Promise<void>;
|
|
147
|
+
} & {
|
|
148
|
+
[Suffix in _Suffixes as `loadMoreOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (options?: FetchPolicy) => Promise<void>;
|
|
147
149
|
} & {
|
|
148
150
|
[Suffix in _Suffixes as `setQueryArgsOf${_CapRef}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: (...args: [...args: StoreSliceArgs<SlceCls, Suffix>, options?: FetchPolicy] | [
|
|
149
151
|
setQueryArgs: (...prevQueryArgs: StoreSliceArgs<SlceCls, Suffix>) => StoreSliceArgs<SlceCls, Suffix>,
|
package/types/store/baseSt.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ declare const RootStore_base: import("./rootStore.d.ts").RootStoreCls<"root", im
|
|
|
112
112
|
} & {
|
|
113
113
|
[x: `setPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
114
114
|
} & {
|
|
115
|
-
[x: `
|
|
115
|
+
[x: `loadMoreOf${any}`]: (options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
116
116
|
} & {
|
|
117
117
|
[x: `setLimitOf${any}`]: (limit: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
118
118
|
} & {
|
|
@@ -418,7 +418,7 @@ export declare const st: import("./agentic.d.ts").StAgentic & {
|
|
|
418
418
|
} & {
|
|
419
419
|
[x: `setPageOf${any}`]: (page: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
420
420
|
} & {
|
|
421
|
-
[x: `
|
|
421
|
+
[x: `loadMoreOf${any}`]: (options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
422
422
|
} & {
|
|
423
423
|
[x: `setLimitOf${any}`]: (limit: number, options?: import("../common.d.ts").FetchPolicy) => Promise<void>;
|
|
424
424
|
} & {
|
|
@@ -36,6 +36,13 @@ export declare class DraftStore {
|
|
|
36
36
|
static decodeForm(refName: string, plain: Record<string, unknown>): object;
|
|
37
37
|
/** What the dirty check compares. Cheap enough to take once per debounce window, not once per keystroke. */
|
|
38
38
|
static formHash(refName: string, form: object): string;
|
|
39
|
+
/**
|
|
40
|
+
* What two encoded forms holding the same values hash to, whatever record stamps they carry.
|
|
41
|
+
*
|
|
42
|
+
* The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
|
|
43
|
+
* would otherwise make a draft look different from the record that already holds it.
|
|
44
|
+
*/
|
|
45
|
+
static contentHash(encoded: Record<string, unknown>): string;
|
|
39
46
|
static read(key: string): Promise<DraftRecord | null>;
|
|
40
47
|
static write(key: string, record: DraftRecord): Promise<void>;
|
|
41
48
|
static remove(key: string): Promise<void>;
|
|
@@ -9,6 +9,10 @@ export interface LivePlacementProps {
|
|
|
9
9
|
/** Which page of the list is on screen. Only the first can place a row. */
|
|
10
10
|
page: number;
|
|
11
11
|
limit: number;
|
|
12
|
+
/** Whether the list is pages `1..N` concatenated rather than one window. */
|
|
13
|
+
cumulative: boolean;
|
|
14
|
+
/** Whether the server still holds rows past the ones in hand. */
|
|
15
|
+
hasMore: boolean;
|
|
12
16
|
/** The sort key the window is ordered by. */
|
|
13
17
|
sortKey: string;
|
|
14
18
|
/** The sort keys the slice declared a subscriber may reproduce. */
|
|
@@ -37,4 +41,4 @@ export interface LivePlacementProps {
|
|
|
37
41
|
* And every sorted field has to be on the row, because a comparison against a value that is not there silently
|
|
38
42
|
* places the row at one end.
|
|
39
43
|
*/
|
|
40
|
-
export declare const livePlacementIndex: ({ list, row, page, limit, sortKey, allowedSorts, sorts, }: LivePlacementProps) => number | null;
|
|
44
|
+
export declare const livePlacementIndex: ({ list, row, page, limit, cumulative, hasMore, sortKey, allowedSorts, sorts, }: LivePlacementProps) => number | null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SerializedArg } from "akanjs/signal";
|
|
2
2
|
import type { SliceStateKey } from "./state.d.ts";
|
|
3
3
|
/** The generated slice actions, named by what they do rather than by the key any one slice publishes them under. */
|
|
4
|
-
export type SliceActionKey = "initModel" | "refreshModel" | "selectModel" | "setPageOfModel" | "
|
|
4
|
+
export type SliceActionKey = "initModel" | "refreshModel" | "selectModel" | "setPageOfModel" | "loadMoreOfModel" | "setLimitOfModel" | "setQueryArgsOfModel" | "setSortOfModel" | "applyLiveModel" | "watchLiveModel";
|
|
5
5
|
/**
|
|
6
6
|
* What a generated key on `st.do` / `st.use` actually is.
|
|
7
7
|
*
|
package/types/store/state.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface DraftState {
|
|
|
25
25
|
/** Set while a draft is the thing in the form. Drives the "continuing, saved n minutes ago" chip. */
|
|
26
26
|
appliedAt: Date | null;
|
|
27
27
|
}
|
|
28
|
-
export type SliceStateKey = "defaultModel" | "modelInsight" | "modelList" | "modelListLoading" | "modelInitList" | "modelInitAt" | "modelStaleAt" | "modelSelection" | "lastPageOfModel" | "pageOfModel" | "limitOfModel" | "queryArgsOfModel" | "sortOfModel";
|
|
28
|
+
export type SliceStateKey = "defaultModel" | "modelInsight" | "modelList" | "modelListLoading" | "modelInitList" | "modelInitAt" | "modelStaleAt" | "modelSelection" | "lastPageOfModel" | "pageOfModel" | "limitOfModel" | "hasMoreOfModel" | "isCumulativeOfModel" | "queryArgsOfModel" | "sortOfModel";
|
|
29
29
|
type _SliceMap<S extends SliceCls> = StoreSliceMap<S>;
|
|
30
30
|
type _StateRefName<S extends SliceCls> = SlceCnstRefName<S>;
|
|
31
31
|
type _StateCap<S extends SliceCls> = SlceCnstCapitalizedRefName<S>;
|
|
@@ -78,6 +78,8 @@ export type SliceState<RefName extends string, Suffix extends string, Full, Ligh
|
|
|
78
78
|
[K in `pageOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
|
|
79
79
|
} & {
|
|
80
80
|
[K in `limitOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: number;
|
|
81
|
+
} & {
|
|
82
|
+
[K in `hasMoreOf${_CapitalizedRefName}${_CapitalizedSuffix}` | `isCumulativeOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: boolean;
|
|
81
83
|
} & {
|
|
82
84
|
[K in `queryArgsOf${_CapitalizedRefName}${_CapitalizedSuffix}`]: Args;
|
|
83
85
|
} & {
|
|
@@ -97,6 +99,8 @@ type DefaultSliceStateFields<SlceCls extends SliceCls, _RefName extends string,
|
|
|
97
99
|
[Suffix in _Suffixes as `${_RefName}Insight${StoreSliceSuffixCap<SlceCls, Suffix>}`]: _Insight;
|
|
98
100
|
} & {
|
|
99
101
|
[Suffix in _Suffixes as `lastPageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `pageOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `limitOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: number;
|
|
102
|
+
} & {
|
|
103
|
+
[Suffix in _Suffixes as `hasMoreOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}` | `isCumulativeOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: boolean;
|
|
100
104
|
} & {
|
|
101
105
|
[Suffix in _Suffixes as `queryArgsOf${_CapRefName}${StoreSliceSuffixCap<SlceCls, Suffix>}`]: StoreSliceArgs<SlceCls, Suffix>;
|
|
102
106
|
} & {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export interface InfiniteScrollProps {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
itemsPerPage: number;
|
|
5
|
-
onAddPage: (page: number) => Promise<void>;
|
|
6
|
-
onPageSelect: (page: number, option?: {
|
|
7
|
-
scrollToTop?: boolean;
|
|
8
|
-
}) => void;
|
|
2
|
+
hasMore: boolean;
|
|
3
|
+
onLoadMore: () => Promise<void>;
|
|
9
4
|
children: React.ReactNode;
|
|
5
|
+
/**
|
|
6
|
+
* Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
|
|
7
|
+
* normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
|
|
8
|
+
* itself — and until it does, the sentinel is on screen and loads one window unasked.
|
|
9
|
+
*/
|
|
10
10
|
reverse?: boolean;
|
|
11
11
|
}
|
|
12
|
-
export declare const InfiniteScroll: ({
|
|
12
|
+
export declare const InfiniteScroll: ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { SliceMeta } from "akanjs/fetch";
|
|
2
|
+
export interface DraftBarViewProps {
|
|
3
|
+
className?: string;
|
|
4
|
+
/** The model the recovered form belongs to. */
|
|
5
|
+
refName: string;
|
|
6
|
+
/**
|
|
7
|
+
* `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
|
|
8
|
+
* shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
|
|
9
|
+
* screen, and the bar is the way back.
|
|
10
|
+
*/
|
|
11
|
+
state: "conflict" | "applied";
|
|
12
|
+
/** When the draft was taken. */
|
|
13
|
+
savedAt: Date;
|
|
14
|
+
/** Puts the offered draft into the form. Passed in the `conflict` state only. */
|
|
15
|
+
onRestore?: () => void;
|
|
16
|
+
/** Drops the draft and keeps the form as it was opened. */
|
|
17
|
+
onDiscard: () => void;
|
|
18
|
+
}
|
|
2
19
|
interface DraftBarProps {
|
|
3
20
|
className?: string;
|
|
4
21
|
slice: SliceMeta;
|
|
5
22
|
}
|
|
6
|
-
/**
|
|
7
|
-
* What the user is told about a recovered form.
|
|
8
|
-
*
|
|
9
|
-
* Two states, and the difference is whether the draft is already in the form. A pending one is a conflict the
|
|
10
|
-
* user has to settle — the record moved since the draft was taken, so the form shows the server's value and this
|
|
11
|
-
* offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
|
|
12
|
-
*/
|
|
13
23
|
export default function DraftBar({ className, slice }: DraftBarProps): import("react/jsx-runtime").JSX.Element | null;
|
|
14
24
|
export {};
|
package/types/ui/Model/Edit.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SliceMeta } from "akanjs/fetch";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import type { DraftProp } from "./draftScope.d.ts";
|
|
3
4
|
interface EditProps {
|
|
4
5
|
type?: "icon" | "button";
|
|
5
6
|
className?: string;
|
|
@@ -11,6 +12,8 @@ interface EditProps {
|
|
|
11
12
|
renderTitle?: ((model: {
|
|
12
13
|
id: string;
|
|
13
14
|
}) => string | ReactNode) | string;
|
|
15
|
+
/** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
|
|
16
|
+
draft?: DraftProp;
|
|
14
17
|
}
|
|
15
|
-
export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, }: EditProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, draft, }: EditProps): import("react/jsx-runtime").JSX.Element;
|
|
16
19
|
export {};
|
|
@@ -9,6 +9,8 @@ interface EditModelProps<Full> {
|
|
|
9
9
|
slice: SliceMeta;
|
|
10
10
|
/** Additional classes for the wrapper. */
|
|
11
11
|
className?: string;
|
|
12
|
+
/** Additional classes for the recovered-form banner this shell draws above the form. */
|
|
13
|
+
draftBarClassName?: string;
|
|
12
14
|
/** Re-check submit eligibility when form state changes. */
|
|
13
15
|
checkSubmit?: boolean;
|
|
14
16
|
/** Client edit promise or partial form seed. */
|
|
@@ -54,5 +56,5 @@ interface EditModalProps<Full extends {
|
|
|
54
56
|
}
|
|
55
57
|
export default function EditModal<Full extends {
|
|
56
58
|
id: string;
|
|
57
|
-
}>({ type, slice, id, className, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
|
|
59
|
+
}>({ type, slice, id, className, draftBarClassName, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
|
|
58
60
|
export {};
|
package/types/ui/More.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ interface MoreProps {
|
|
|
2
2
|
total: number;
|
|
3
3
|
itemsPerPage: number;
|
|
4
4
|
currentPage: number;
|
|
5
|
-
|
|
5
|
+
hasMore: boolean;
|
|
6
|
+
onLoadMore: () => Promise<void>;
|
|
6
7
|
onPageSelect: (page: number, option?: {
|
|
7
8
|
scrollToTop?: boolean;
|
|
8
9
|
}) => void;
|
|
@@ -10,5 +11,5 @@ interface MoreProps {
|
|
|
10
11
|
className?: string;
|
|
11
12
|
reverse?: boolean;
|
|
12
13
|
}
|
|
13
|
-
export declare const More: ({ total, itemsPerPage, currentPage,
|
|
14
|
+
export declare const More: ({ total, itemsPerPage, currentPage, hasMore, onLoadMore, onPageSelect, children, className, reverse, }: MoreProps) => import("react/jsx-runtime").JSX.Element;
|
|
14
15
|
export {};
|
|
@@ -22,6 +22,7 @@ import type { SkeletonProps } from "../Loading/Skeleton.d.ts";
|
|
|
22
22
|
import type { SpinProps } from "../Loading/Spin.d.ts";
|
|
23
23
|
import type { MenuProps } from "../Menu.d.ts";
|
|
24
24
|
import type { ModalProps } from "../Modal.d.ts";
|
|
25
|
+
import type { DraftBarViewProps } from "../Model/DraftBar.d.ts";
|
|
25
26
|
import type { PaginationProps } from "../Pagination.d.ts";
|
|
26
27
|
import type { PopconfirmProps } from "../Popconfirm.d.ts";
|
|
27
28
|
import type { ItemProps as RadioItemProps, RadioProps } from "../Radio.d.ts";
|
|
@@ -54,6 +55,7 @@ export interface AkanUiOverrides {
|
|
|
54
55
|
Menu: ComponentType<MenuProps>;
|
|
55
56
|
Tooltip: ComponentType<TooltipProps>;
|
|
56
57
|
Unauthorized: ComponentType<UnauthorizedProps>;
|
|
58
|
+
DraftBar: ComponentType<DraftBarViewProps>;
|
|
57
59
|
AgentChat: ComponentType<AgentChatProps>;
|
|
58
60
|
AgentLauncher: ComponentType<AgentLauncherProps>;
|
|
59
61
|
AgentBubble: ComponentType<AgentBubbleProps>;
|
package/ui/InfiniteScroll.tsx
CHANGED
|
@@ -3,56 +3,90 @@ import { useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { BiLoaderAlt } from "react-icons/bi";
|
|
4
4
|
|
|
5
5
|
export interface InfiniteScrollProps {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
itemsPerPage: number;
|
|
9
|
-
onAddPage: (page: number) => Promise<void>;
|
|
10
|
-
onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
|
|
6
|
+
hasMore: boolean;
|
|
7
|
+
onLoadMore: () => Promise<void>;
|
|
11
8
|
children: React.ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
|
|
11
|
+
* normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
|
|
12
|
+
* itself — and until it does, the sentinel is on screen and loads one window unasked.
|
|
13
|
+
*/
|
|
12
14
|
reverse?: boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
let warnedColumnReverse = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The sentinel is positioned by DOM order alone — first child to load earlier, last child to load more — so a
|
|
21
|
+
* `column-reverse` parent paints it at the opposite end from the rows it controls, and `scrollTop: 0` is then
|
|
22
|
+
* that same end, so it also fires at mount. `flex-col-reverse` is the usual no-JS way to pin a chat to the
|
|
23
|
+
* bottom, so a caller reaching for `reverse` may well already have it; the result reads as a control placed
|
|
24
|
+
* wrongly rather than as an error, which is why it is worth saying out loud once.
|
|
25
|
+
*/
|
|
26
|
+
const warnColumnReverse = (sentinel: Element | null) => {
|
|
27
|
+
if (warnedColumnReverse || process.env.AKAN_PUBLIC_ENV !== "local") return;
|
|
28
|
+
const parent = sentinel?.parentElement;
|
|
29
|
+
if (!parent || getComputedStyle(parent).flexDirection !== "column-reverse") return;
|
|
30
|
+
warnedColumnReverse = true;
|
|
31
|
+
console.warn(
|
|
32
|
+
"<InfiniteScroll> sits in a `flex-col-reverse` parent, which paints its load sentinel at the end opposite the rows it loads, and fires it at mount. Drop `flex-col-reverse` and let `reverse` hold the reading position instead.",
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const scrollableOverflows = new Set(["auto", "scroll", "overlay"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The element that actually scrolls the sentinel — the document only once no ancestor has taken the job.
|
|
40
|
+
*
|
|
41
|
+
* A chat timeline or a log tail scrolls inside its own `overflow-y-auto` box, and there the document does not
|
|
42
|
+
* move at all, so anchoring `document.scrollingElement` restores a position nothing changed. Resolved per load
|
|
43
|
+
* rather than once, because the box that scrolls is a layout outcome and a caller cannot be asked to name it.
|
|
44
|
+
*/
|
|
45
|
+
const scrollerOf = (sentinel: Element | null) => {
|
|
46
|
+
if (typeof document === "undefined") return null;
|
|
47
|
+
let el = sentinel?.parentElement ?? null;
|
|
48
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
49
|
+
|
|
50
|
+
if (el.scrollHeight > el.clientHeight && scrollableOverflows.has(getComputedStyle(el).overflowY)) return el;
|
|
51
|
+
el = el.parentElement;
|
|
52
|
+
}
|
|
53
|
+
return document.scrollingElement;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => {
|
|
24
57
|
const [isFetching, setIsFetching] = useState(false);
|
|
25
58
|
const isFetchingRef = useRef(false);
|
|
26
59
|
const target = useRef<HTMLDivElement>(null);
|
|
27
|
-
const page = useRef<number>(currentPage);
|
|
28
|
-
const totalPages = Math.ceil(total / (itemsPerPage || 1));
|
|
29
60
|
|
|
30
61
|
useEffect(() => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
62
|
+
|
|
63
|
+
warnColumnReverse(target.current);
|
|
64
|
+
const scroller = scrollerOf(target.current);
|
|
65
|
+
const root = scroller && scroller !== document.scrollingElement ? scroller : null;
|
|
66
|
+
const observer = new IntersectionObserver(
|
|
67
|
+
(entries) => {
|
|
68
|
+
const [entry] = entries;
|
|
69
|
+
if (entry.isIntersecting) void fetchMoreItems();
|
|
70
|
+
},
|
|
71
|
+
{ root },
|
|
72
|
+
);
|
|
35
73
|
if (target.current) observer.observe(target.current);
|
|
36
74
|
return () => {
|
|
37
75
|
observer.disconnect();
|
|
38
76
|
};
|
|
39
|
-
}, []);
|
|
77
|
+
}, [hasMore]);
|
|
40
78
|
|
|
41
79
|
const fetchMoreItems = async () => {
|
|
42
80
|
if (isFetchingRef.current) return;
|
|
43
|
-
const nextPage = page.current + 1;
|
|
44
|
-
if (nextPage > totalPages) return;
|
|
45
81
|
|
|
46
|
-
const scroller = reverse ?
|
|
82
|
+
const scroller = reverse ? scrollerOf(target.current) : null;
|
|
47
83
|
const prevScrollHeight = scroller?.scrollHeight ?? 0;
|
|
48
84
|
const prevScrollTop = scroller?.scrollTop ?? 0;
|
|
49
85
|
|
|
50
86
|
isFetchingRef.current = true;
|
|
51
87
|
setIsFetching(true);
|
|
52
88
|
try {
|
|
53
|
-
await
|
|
54
|
-
onPageSelect(nextPage, { scrollToTop: false });
|
|
55
|
-
page.current = nextPage;
|
|
89
|
+
await onLoadMore();
|
|
56
90
|
|
|
57
91
|
const restoreScroll = () => {
|
|
58
92
|
if (scroller) {
|
|
@@ -76,15 +110,15 @@ export const InfiniteScroll = ({
|
|
|
76
110
|
|
|
77
111
|
return (
|
|
78
112
|
<>
|
|
79
|
-
{reverse ? (
|
|
113
|
+
{reverse && hasMore ? (
|
|
80
114
|
<div ref={target} className="flex w-full items-end justify-center">
|
|
81
|
-
{isFetching
|
|
115
|
+
{isFetching ? <BiLoaderAlt className="h-10 animate-spin pb-4 text-2xl" /> : null}
|
|
82
116
|
</div>
|
|
83
117
|
) : null}
|
|
84
118
|
{children}
|
|
85
|
-
{!reverse ? (
|
|
119
|
+
{!reverse && hasMore ? (
|
|
86
120
|
<div ref={target} className="flex h-32 w-full justify-center pt-4">
|
|
87
|
-
{isFetching
|
|
121
|
+
{isFetching ? <BiLoaderAlt className="animate-spin text-2xl" /> : null}
|
|
88
122
|
</div>
|
|
89
123
|
) : null}
|
|
90
124
|
</>
|
package/ui/Load/Pagination.tsx
CHANGED
|
@@ -39,7 +39,6 @@ function Render<RefName extends string, Light>({ className, init, scrollToTop }:
|
|
|
39
39
|
lastPageOfModel: `lastPageOf${ModelName}`,
|
|
40
40
|
limitOfModel: `limitOf${ModelName}`,
|
|
41
41
|
setPageOfModel: `setPageOf${ModelName}`,
|
|
42
|
-
addPageOfModel: `addPageOf${ModelName}`,
|
|
43
42
|
};
|
|
44
43
|
const namesOfSlice = {
|
|
45
44
|
modelInsight: sliceName.replace(names.model, names.modelInsight),
|
|
@@ -47,7 +46,6 @@ function Render<RefName extends string, Light>({ className, init, scrollToTop }:
|
|
|
47
46
|
lastPageOfModel: sliceName.replace(names.model, names.lastPageOfModel),
|
|
48
47
|
pageOfModel: sliceName.replace(names.model, names.pageOfModel),
|
|
49
48
|
setPageOfModel: sliceName.replace(names.model, names.setPageOfModel),
|
|
50
|
-
addPageOfModel: sliceName.replace(names.model, names.addPageOfModel),
|
|
51
49
|
};
|
|
52
50
|
const modelInsight = storeUse[namesOfSlice.modelInsight]() as BaseInsight;
|
|
53
51
|
const limitOfModel = storeUse[namesOfSlice.limitOfModel]() as number;
|
package/ui/Load/Units.tsx
CHANGED
|
@@ -85,10 +85,12 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
85
85
|
pageOfModel: `pageOf${ModelName}`,
|
|
86
86
|
lastPageOfModel: `lastPageOf${ModelName}`,
|
|
87
87
|
limitOfModel: `limitOf${ModelName}`,
|
|
88
|
+
hasMoreOfModel: `hasMoreOf${ModelName}`,
|
|
89
|
+
isCumulativeOfModel: `isCumulativeOf${ModelName}`,
|
|
88
90
|
queryArgsOfModel: `queryArgsOf${ModelName}`,
|
|
89
91
|
sortOfModel: `sortOf${ModelName}`,
|
|
90
92
|
setPageOfModel: `setPageOf${ModelName}`,
|
|
91
|
-
|
|
93
|
+
loadMoreOfModel: `loadMoreOf${ModelName}`,
|
|
92
94
|
refreshModel: `refresh${ModelName}`,
|
|
93
95
|
watchLiveModel: `watchLive${ModelName}`,
|
|
94
96
|
};
|
|
@@ -102,10 +104,12 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
102
104
|
pageOfModel: sliceName.replace(names.model, names.pageOfModel),
|
|
103
105
|
lastPageOfModel: sliceName.replace(names.model, names.lastPageOfModel),
|
|
104
106
|
limitOfModel: sliceName.replace(names.model, names.limitOfModel),
|
|
107
|
+
hasMoreOfModel: sliceName.replace(names.model, names.hasMoreOfModel),
|
|
108
|
+
isCumulativeOfModel: sliceName.replace(names.model, names.isCumulativeOfModel),
|
|
105
109
|
queryArgsOfModel: sliceName.replace(names.model, names.queryArgsOfModel),
|
|
106
110
|
sortOfModel: sliceName.replace(names.model, names.sortOfModel),
|
|
107
111
|
setPageOfModel: sliceName.replace(names.model, names.setPageOfModel),
|
|
108
|
-
|
|
112
|
+
loadMoreOfModel: sliceName.replace(names.model, names.loadMoreOfModel),
|
|
109
113
|
refreshModel: sliceName.replace(names.model, names.refreshModel),
|
|
110
114
|
watchLiveModel: sliceName.replace(names.model, names.watchLiveModel),
|
|
111
115
|
};
|
|
@@ -116,6 +120,7 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
116
120
|
const initModelObjInsight = (init as DynamicRecord)[names.modelObjInsight] as BaseInsight | null;
|
|
117
121
|
const initLimitOfModel = (init as DynamicRecord)[names.limitOfModel] as number;
|
|
118
122
|
const initPageOfModel = (init as DynamicRecord)[names.pageOfModel] as number;
|
|
123
|
+
const initHasMoreOfModel = (init as DynamicRecord)[names.hasMoreOfModel] as boolean;
|
|
119
124
|
const initSignature = JSON.stringify(initQueryArgs);
|
|
120
125
|
|
|
121
126
|
const useCache =
|
|
@@ -155,6 +160,9 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
155
160
|
[namesOfSlice.pageOfModel]: initPageOfModel,
|
|
156
161
|
[namesOfSlice.lastPageOfModel]: initLastPageOfModel,
|
|
157
162
|
[namesOfSlice.limitOfModel]: initLimitOfModel,
|
|
163
|
+
|
|
164
|
+
[namesOfSlice.hasMoreOfModel]: initHasMoreOfModel,
|
|
165
|
+
[namesOfSlice.isCumulativeOfModel]: false,
|
|
158
166
|
[namesOfSlice.queryArgsOfModel]: initQueryArgsOfModel,
|
|
159
167
|
[namesOfSlice.sortOfModel]: initSortOfModel,
|
|
160
168
|
});
|
|
@@ -180,6 +188,7 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
180
188
|
const modelInsight = storeUse[namesOfSlice.modelInsight]() as BaseInsight;
|
|
181
189
|
const limitOfModel = storeUse[namesOfSlice.limitOfModel]() as number;
|
|
182
190
|
const pageOfModel = storeUse[namesOfSlice.pageOfModel]() as number;
|
|
191
|
+
const hasMoreOfModel = storeUse[namesOfSlice.hasMoreOfModel]() as boolean;
|
|
183
192
|
const insight = loaded ? modelInsight : initModelObjInsight;
|
|
184
193
|
const limit = loaded ? limitOfModel : initLimitOfModel;
|
|
185
194
|
const page = loaded ? pageOfModel : initPageOfModel;
|
|
@@ -188,8 +197,9 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
188
197
|
total,
|
|
189
198
|
currentPage: page,
|
|
190
199
|
itemsPerPage: limit || total,
|
|
191
|
-
|
|
192
|
-
|
|
200
|
+
hasMore: loaded ? hasMoreOfModel : initHasMoreOfModel,
|
|
201
|
+
onLoadMore: async () => {
|
|
202
|
+
await storeDo[namesOfSlice.loadMoreOfModel]();
|
|
193
203
|
},
|
|
194
204
|
onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => {
|
|
195
205
|
void storeDo[namesOfSlice.setPageOfModel](page);
|
|
@@ -342,7 +352,8 @@ interface MoreProps {
|
|
|
342
352
|
total: number;
|
|
343
353
|
itemsPerPage: number;
|
|
344
354
|
currentPage: number;
|
|
345
|
-
|
|
355
|
+
hasMore: boolean;
|
|
356
|
+
onLoadMore: () => Promise<void>;
|
|
346
357
|
onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
|
|
347
358
|
children?: React.ReactNode;
|
|
348
359
|
className?: string;
|
package/ui/Model/DraftBar.tsx
CHANGED
|
@@ -9,21 +9,75 @@ import { AiOutlineDelete, AiOutlineHistory, AiOutlineRollback } from "react-icon
|
|
|
9
9
|
import { agentAttrs } from "../agentAttrs";
|
|
10
10
|
import { Button } from "../Button";
|
|
11
11
|
import { RecentTime } from "../RecentTime";
|
|
12
|
+
import { createOverridable } from "../UiOverride";
|
|
12
13
|
|
|
13
|
-
interface
|
|
14
|
+
export interface DraftBarViewProps {
|
|
14
15
|
className?: string;
|
|
15
|
-
|
|
16
|
+
/** The model the recovered form belongs to. */
|
|
17
|
+
refName: string;
|
|
18
|
+
/**
|
|
19
|
+
* `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
|
|
20
|
+
* shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
|
|
21
|
+
* screen, and the bar is the way back.
|
|
22
|
+
*/
|
|
23
|
+
state: "conflict" | "applied";
|
|
24
|
+
/** When the draft was taken. */
|
|
25
|
+
savedAt: Date;
|
|
26
|
+
/** Puts the offered draft into the form. Passed in the `conflict` state only. */
|
|
27
|
+
onRestore?: () => void;
|
|
28
|
+
/** Drops the draft and keeps the form as it was opened. */
|
|
29
|
+
onDiscard: () => void;
|
|
16
30
|
}
|
|
17
31
|
|
|
32
|
+
const DefaultDraftBar = ({ className, state, savedAt, onRestore, onDiscard }: DraftBarViewProps) => {
|
|
33
|
+
const { l } = usePage();
|
|
34
|
+
if (state === "conflict")
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
"mb-4 flex flex-wrap items-center gap-2 rounded-box border border-warning/40 bg-warning/10 p-3",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<AiOutlineHistory className="text-warning" />
|
|
43
|
+
<span className="flex-1 text-foreground/80 text-sm">
|
|
44
|
+
{l("base.draftConflict")} <RecentTime date={savedAt} />
|
|
45
|
+
</span>
|
|
46
|
+
<Button {...agentAttrs(onRestore)} size="sm" onClick={() => onRestore?.()}>
|
|
47
|
+
<AiOutlineRollback /> {l("base.draftRestore")}
|
|
48
|
+
</Button>
|
|
49
|
+
<Button {...agentAttrs(onDiscard)} size="sm" variant="ghost" onClick={() => onDiscard()}>
|
|
50
|
+
<AiOutlineDelete /> {l("base.draftDiscard")}
|
|
51
|
+
</Button>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("mb-4 flex flex-wrap items-center gap-2 text-foreground/60 text-xs", className)}>
|
|
56
|
+
<AiOutlineHistory />
|
|
57
|
+
<span className="flex-1">
|
|
58
|
+
{l("base.draftApplied")} <RecentTime date={savedAt} />
|
|
59
|
+
</span>
|
|
60
|
+
<Button {...agentAttrs(onDiscard)} size="xs" variant="ghost" onClick={() => onDiscard()}>
|
|
61
|
+
{l("base.draftStartOver")}
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
18
67
|
/**
|
|
19
|
-
*
|
|
68
|
+
* The banner itself, route-overridable through `page/**\/_overrides.tsx` (slot `DraftBar`).
|
|
20
69
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
|
|
70
|
+
* The shell below keeps the draft state and publishes the two agent tools, so a replacement re-skins the notice
|
|
71
|
+
* without reaching into the store under string keys or re-declaring what an agent may pull.
|
|
24
72
|
*/
|
|
73
|
+
const DraftBarView = createOverridable("DraftBar", DefaultDraftBar);
|
|
74
|
+
|
|
75
|
+
interface DraftBarProps {
|
|
76
|
+
className?: string;
|
|
77
|
+
slice: SliceMeta;
|
|
78
|
+
}
|
|
79
|
+
|
|
25
80
|
export default function DraftBar({ className, slice }: DraftBarProps) {
|
|
26
|
-
const { l } = usePage();
|
|
27
81
|
const { refName } = slice;
|
|
28
82
|
const [modelName, ModelName] = useMemo(() => [lowerlize(refName), capitalize(refName)], []);
|
|
29
83
|
const names = useMemo(
|
|
@@ -50,35 +104,24 @@ export default function DraftBar({ className, slice }: DraftBarProps) {
|
|
|
50
104
|
|
|
51
105
|
if (draft?.pending)
|
|
52
106
|
return (
|
|
53
|
-
<
|
|
54
|
-
className={
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{l("base.draftConflict")} <RecentTime date={draft.pending.savedAt} />
|
|
62
|
-
</span>
|
|
63
|
-
<Button {...agentAttrs(restoreDraft)} size="sm" onClick={() => restoreDraft()}>
|
|
64
|
-
<AiOutlineRollback /> {l("base.draftRestore")}
|
|
65
|
-
</Button>
|
|
66
|
-
<Button {...agentAttrs(discardDraft)} size="sm" variant="ghost" onClick={() => discardDraft()}>
|
|
67
|
-
<AiOutlineDelete /> {l("base.draftDiscard")}
|
|
68
|
-
</Button>
|
|
69
|
-
</div>
|
|
107
|
+
<DraftBarView
|
|
108
|
+
className={className}
|
|
109
|
+
refName={refName}
|
|
110
|
+
state="conflict"
|
|
111
|
+
savedAt={draft.pending.savedAt}
|
|
112
|
+
onRestore={restoreDraft}
|
|
113
|
+
onDiscard={discardDraft}
|
|
114
|
+
/>
|
|
70
115
|
);
|
|
71
116
|
if (draft?.appliedAt)
|
|
72
117
|
return (
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
</Button>
|
|
81
|
-
</div>
|
|
118
|
+
<DraftBarView
|
|
119
|
+
className={className}
|
|
120
|
+
refName={refName}
|
|
121
|
+
state="applied"
|
|
122
|
+
savedAt={draft.appliedAt}
|
|
123
|
+
onDiscard={discardDraft}
|
|
124
|
+
/>
|
|
82
125
|
);
|
|
83
126
|
return null;
|
|
84
127
|
}
|
package/ui/Model/Edit.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { SliceMeta } from "akanjs/fetch";
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { AiOutlineEdit } from "react-icons/ai";
|
|
5
5
|
|
|
6
|
+
import type { DraftProp } from "./draftScope";
|
|
6
7
|
import EditModal from "./EditModal";
|
|
7
8
|
import EditWrapper from "./EditWrapper";
|
|
8
9
|
|
|
@@ -15,6 +16,8 @@ interface EditProps {
|
|
|
15
16
|
modelId: string;
|
|
16
17
|
modal?: string | null;
|
|
17
18
|
renderTitle?: ((model: { id: string }) => string | ReactNode) | string;
|
|
19
|
+
/** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
|
|
20
|
+
draft?: DraftProp;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export default function Edit({
|
|
@@ -26,6 +29,7 @@ export default function Edit({
|
|
|
26
29
|
modelId,
|
|
27
30
|
modal,
|
|
28
31
|
renderTitle,
|
|
32
|
+
draft,
|
|
29
33
|
}: EditProps) {
|
|
30
34
|
const { l } = usePage();
|
|
31
35
|
return (
|
|
@@ -35,10 +39,11 @@ export default function Edit({
|
|
|
35
39
|
slice={slice}
|
|
36
40
|
modelId={modelId}
|
|
37
41
|
modal={modal}
|
|
42
|
+
draft={draft}
|
|
38
43
|
>
|
|
39
44
|
<AiOutlineEdit /> {type === "button" ? l("base.edit") : null}
|
|
40
45
|
</EditWrapper>
|
|
41
|
-
<EditModal renderTitle={renderTitle} slice={slice} id={modelId}>
|
|
46
|
+
<EditModal renderTitle={renderTitle} slice={slice} id={modelId} draft={draft}>
|
|
42
47
|
{children}
|
|
43
48
|
</EditModal>
|
|
44
49
|
</div>
|
package/ui/Model/EditModal.tsx
CHANGED
|
@@ -27,6 +27,8 @@ interface EditModelProps<Full> {
|
|
|
27
27
|
slice: SliceMeta;
|
|
28
28
|
/** Additional classes for the wrapper. */
|
|
29
29
|
className?: string;
|
|
30
|
+
/** Additional classes for the recovered-form banner this shell draws above the form. */
|
|
31
|
+
draftBarClassName?: string;
|
|
30
32
|
/** Re-check submit eligibility when form state changes. */
|
|
31
33
|
checkSubmit?: boolean;
|
|
32
34
|
/** Client edit promise or partial form seed. */
|
|
@@ -52,6 +54,7 @@ const EditModel = <Full,>({
|
|
|
52
54
|
type = "modal",
|
|
53
55
|
slice,
|
|
54
56
|
className,
|
|
57
|
+
draftBarClassName,
|
|
55
58
|
checkSubmit = true,
|
|
56
59
|
edit,
|
|
57
60
|
modal,
|
|
@@ -107,7 +110,7 @@ const EditModel = <Full,>({
|
|
|
107
110
|
|
|
108
111
|
return (
|
|
109
112
|
<LoadingWrapper className={cn("w-full", className)}>
|
|
110
|
-
<DraftBar slice={slice} />
|
|
113
|
+
<DraftBar className={draftBarClassName} slice={slice} />
|
|
111
114
|
{children}
|
|
112
115
|
</LoadingWrapper>
|
|
113
116
|
);
|
|
@@ -142,6 +145,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
142
145
|
slice,
|
|
143
146
|
id,
|
|
144
147
|
className,
|
|
148
|
+
draftBarClassName,
|
|
145
149
|
disabled,
|
|
146
150
|
checkSubmit = true,
|
|
147
151
|
modalClassName,
|
|
@@ -368,6 +372,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
368
372
|
type={type}
|
|
369
373
|
slice={slice}
|
|
370
374
|
className={className}
|
|
375
|
+
draftBarClassName={draftBarClassName}
|
|
371
376
|
checkSubmit={checkSubmit}
|
|
372
377
|
edit={edit}
|
|
373
378
|
modal={modal}
|
|
@@ -385,6 +390,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
385
390
|
type={type}
|
|
386
391
|
slice={slice}
|
|
387
392
|
className={className}
|
|
393
|
+
draftBarClassName={draftBarClassName}
|
|
388
394
|
checkSubmit={checkSubmit}
|
|
389
395
|
edit={edit}
|
|
390
396
|
modal={modal}
|
package/ui/More.tsx
CHANGED
|
@@ -8,7 +8,8 @@ interface MoreProps {
|
|
|
8
8
|
total: number;
|
|
9
9
|
itemsPerPage: number;
|
|
10
10
|
currentPage: number;
|
|
11
|
-
|
|
11
|
+
hasMore: boolean;
|
|
12
|
+
onLoadMore: () => Promise<void>;
|
|
12
13
|
onPageSelect: (page: number, option?: { scrollToTop?: boolean }) => void;
|
|
13
14
|
children?: React.ReactNode;
|
|
14
15
|
className?: string;
|
|
@@ -19,7 +20,8 @@ export const More = ({
|
|
|
19
20
|
total,
|
|
20
21
|
itemsPerPage,
|
|
21
22
|
currentPage,
|
|
22
|
-
|
|
23
|
+
hasMore,
|
|
24
|
+
onLoadMore,
|
|
23
25
|
onPageSelect,
|
|
24
26
|
children,
|
|
25
27
|
className,
|
|
@@ -31,24 +33,15 @@ export const More = ({
|
|
|
31
33
|
setIsMobile(isMobileDevice());
|
|
32
34
|
}, []);
|
|
33
35
|
|
|
34
|
-
if (
|
|
35
|
-
return <>{children}</>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (isMobile) {
|
|
36
|
+
if (isMobile)
|
|
39
37
|
return (
|
|
40
|
-
<InfiniteScroll
|
|
41
|
-
total={total}
|
|
42
|
-
currentPage={currentPage}
|
|
43
|
-
itemsPerPage={itemsPerPage}
|
|
44
|
-
onAddPage={onAddPage}
|
|
45
|
-
onPageSelect={onPageSelect}
|
|
46
|
-
reverse={reverse}
|
|
47
|
-
>
|
|
38
|
+
<InfiniteScroll hasMore={hasMore} onLoadMore={onLoadMore} reverse={reverse}>
|
|
48
39
|
{children}
|
|
49
40
|
</InfiniteScroll>
|
|
50
41
|
);
|
|
51
|
-
|
|
42
|
+
|
|
43
|
+
if (total <= itemsPerPage) return <>{children}</>;
|
|
44
|
+
|
|
52
45
|
return (
|
|
53
46
|
<>
|
|
54
47
|
{children}
|
package/ui/UiOverride/context.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { SkeletonProps } from "../Loading/Skeleton";
|
|
|
24
24
|
import type { SpinProps } from "../Loading/Spin";
|
|
25
25
|
import type { MenuProps } from "../Menu";
|
|
26
26
|
import type { ModalProps } from "../Modal";
|
|
27
|
+
import type { DraftBarViewProps } from "../Model/DraftBar";
|
|
27
28
|
import type { PaginationProps } from "../Pagination";
|
|
28
29
|
import type { PopconfirmProps } from "../Popconfirm";
|
|
29
30
|
import type { ItemProps as RadioItemProps, RadioProps } from "../Radio";
|
|
@@ -58,6 +59,8 @@ export interface AkanUiOverrides {
|
|
|
58
59
|
Menu: ComponentType<MenuProps>;
|
|
59
60
|
Tooltip: ComponentType<TooltipProps>;
|
|
60
61
|
Unauthorized: ComponentType<UnauthorizedProps>;
|
|
62
|
+
|
|
63
|
+
DraftBar: ComponentType<DraftBarViewProps>;
|
|
61
64
|
AgentChat: ComponentType<AgentChatProps>;
|
|
62
65
|
|
|
63
66
|
AgentLauncher: ComponentType<AgentLauncherProps>;
|