akanjs 3.0.0-alpha.97 → 3.0.0-alpha.99
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/base/baseEnv.ts +2 -1
- 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/server/akanApp.ts +4 -1
- package/server/akanServer.ts +1 -1
- package/server/di/diLifecycle.ts +2 -2
- package/server/resolver/CascadeRunner.ts +1 -1
- package/store/action.ts +13 -0
- package/store/draftStore.ts +14 -0
- package/types/store/draftStore.d.ts +7 -0
- package/types/ui/InfiniteScroll.d.ts +5 -0
- 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/UiOverride/context.d.ts +2 -0
- package/ui/InfiniteScroll.tsx +56 -5
- package/ui/Model/DraftBar.tsx +76 -33
- package/ui/Model/Edit.tsx +6 -1
- package/ui/Model/EditModal.tsx +7 -1
- package/ui/UiOverride/context.ts +3 -0
package/base/baseEnv.ts
CHANGED
|
@@ -164,9 +164,10 @@ export const getEnv = (): ClientEnv => {
|
|
|
164
164
|
? (window.location.host.split(":")[0] ?? "unknown")
|
|
165
165
|
: "localhost");
|
|
166
166
|
|
|
167
|
+
const selfServerPort = serverHost === "localhost" ? process.env.PORT : undefined;
|
|
167
168
|
const serverPort =
|
|
168
169
|
side === "server"
|
|
169
|
-
? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? "8282")
|
|
170
|
+
? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? selfServerPort ?? "8282")
|
|
170
171
|
: parseInt(window.location.port || (window.location.protocol === "https:" ? "443" : "80"));
|
|
171
172
|
|
|
172
173
|
const serverHttpProtocol: "http:" | "https:" =
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -312,6 +312,7 @@ export class AkanApp {
|
|
|
312
312
|
const role = this.#getRole(0);
|
|
313
313
|
|
|
314
314
|
Object.assign(process.env, {
|
|
315
|
+
PORT: String(this.#port),
|
|
315
316
|
NODE_ENV: AkanApp.#defaultChildNodeEnv(),
|
|
316
317
|
AKAN_REPLICA: this.#replica.value,
|
|
317
318
|
AKAN_REPLICA_IDX: "0",
|
|
@@ -324,7 +325,7 @@ export class AkanApp {
|
|
|
324
325
|
});
|
|
325
326
|
|
|
326
327
|
resetEnvCache();
|
|
327
|
-
this.logger.
|
|
328
|
+
this.logger.debug(`Starting ${role} replica in this process (solo); set AKAN_SOLO=false for the gateway`);
|
|
328
329
|
const mod = (await import(this.#serverPath)) as { server?: SoloServer; app?: SoloServer };
|
|
329
330
|
const server = mod.server ?? mod.app;
|
|
330
331
|
if (!server?.start) throw new Error("server.ts must export server or app with start()");
|
|
@@ -401,6 +402,8 @@ export class AkanApp {
|
|
|
401
402
|
cwd: process.cwd(),
|
|
402
403
|
env: {
|
|
403
404
|
...process.env,
|
|
405
|
+
|
|
406
|
+
PORT: String(this.#port),
|
|
404
407
|
NODE_ENV: AkanApp.#defaultChildNodeEnv(),
|
|
405
408
|
AKAN_REPLICA: this.#replica.value,
|
|
406
409
|
AKAN_REPLICA_IDX: String(idx),
|
package/server/akanServer.ts
CHANGED
|
@@ -329,7 +329,7 @@ export class AkanServer {
|
|
|
329
329
|
};
|
|
330
330
|
if (!requestedWeb.ssr) {
|
|
331
331
|
this.web = requestedWeb;
|
|
332
|
-
this.logger.
|
|
332
|
+
this.logger.debug("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
|
|
333
333
|
return noWeb();
|
|
334
334
|
}
|
|
335
335
|
const { WebRouter } = await import("./webRouter");
|
package/server/di/diLifecycle.ts
CHANGED
|
@@ -263,7 +263,7 @@ export class DiLifecycle {
|
|
|
263
263
|
for (const dependency of dependencies) if (candidates.has(dependency)) pending.push(dependency);
|
|
264
264
|
}
|
|
265
265
|
const mounted = [...selected].sort((a, b) => a.localeCompare(b)).join(", ");
|
|
266
|
-
this.logger.
|
|
266
|
+
this.logger.debug(`Mounting ${selected.size} of ${candidates.size} module(s): ${mounted}`);
|
|
267
267
|
return selected;
|
|
268
268
|
}
|
|
269
269
|
|
|
@@ -662,7 +662,7 @@ export class DiLifecycle {
|
|
|
662
662
|
},
|
|
663
663
|
})),
|
|
664
664
|
);
|
|
665
|
-
if (liveKeys.length) this.logger.
|
|
665
|
+
if (liveKeys.length) this.logger.verbose(`Live sync: ${liveKeys.length} live slice(s) — ${liveKeys.join(", ")}`);
|
|
666
666
|
return { routes, wsRoutes, routeOptions };
|
|
667
667
|
}
|
|
668
668
|
|
|
@@ -205,7 +205,7 @@ export class CascadeRunner {
|
|
|
205
205
|
|
|
206
206
|
if (!this.#anyEdges.length) return;
|
|
207
207
|
const wildcards = this.#anyEdges.map((edge) => `${edge.refName}.${edge.key}`).join(", ");
|
|
208
|
-
this.#logger.
|
|
208
|
+
this.#logger.verbose(
|
|
209
209
|
`${this.#anyEdges.length} wildcard removeWith edge(s) (${wildcards}): every removal probes them, ` +
|
|
210
210
|
`and no cascade removes in one query`,
|
|
211
211
|
);
|
package/store/action.ts
CHANGED
|
@@ -584,6 +584,12 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
584
584
|
|
|
585
585
|
const current = (this.get() as { [key: string]: any })[names.modelDraft] as DraftState | null;
|
|
586
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
|
+
}
|
|
587
593
|
let form: object;
|
|
588
594
|
try {
|
|
589
595
|
form = DraftStore.decodeForm(refName, record.form);
|
|
@@ -1222,6 +1228,13 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1222
1228
|
* asking for what comes after them cannot drift from what is displayed. It also leaves `pageOf<Model>` at
|
|
1223
1229
|
* 1, which is what keeps live placement — refused anywhere but the first page — working past the first
|
|
1224
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.
|
|
1225
1238
|
*/
|
|
1226
1239
|
[namesOfSlice.loadMoreOfModel]: async function (this: SetGet, options?: FetchPolicy) {
|
|
1227
1240
|
const currentState = this.get() as { [key: string]: any };
|
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;
|
|
@@ -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>;
|
|
@@ -2,6 +2,11 @@ export interface InfiniteScrollProps {
|
|
|
2
2
|
hasMore: boolean;
|
|
3
3
|
onLoadMore: () => Promise<void>;
|
|
4
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
|
+
*/
|
|
5
10
|
reverse?: boolean;
|
|
6
11
|
}
|
|
7
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 {};
|
|
@@ -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
|
@@ -6,19 +6,70 @@ export interface InfiniteScrollProps {
|
|
|
6
6
|
hasMore: boolean;
|
|
7
7
|
onLoadMore: () => Promise<void>;
|
|
8
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
|
+
*/
|
|
9
14
|
reverse?: boolean;
|
|
10
15
|
}
|
|
11
16
|
|
|
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
|
+
|
|
12
56
|
export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => {
|
|
13
57
|
const [isFetching, setIsFetching] = useState(false);
|
|
14
58
|
const isFetchingRef = useRef(false);
|
|
15
59
|
const target = useRef<HTMLDivElement>(null);
|
|
16
60
|
|
|
17
61
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
);
|
|
22
73
|
if (target.current) observer.observe(target.current);
|
|
23
74
|
return () => {
|
|
24
75
|
observer.disconnect();
|
|
@@ -28,7 +79,7 @@ export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: Infin
|
|
|
28
79
|
const fetchMoreItems = async () => {
|
|
29
80
|
if (isFetchingRef.current) return;
|
|
30
81
|
|
|
31
|
-
const scroller = reverse ?
|
|
82
|
+
const scroller = reverse ? scrollerOf(target.current) : null;
|
|
32
83
|
const prevScrollHeight = scroller?.scrollHeight ?? 0;
|
|
33
84
|
const prevScrollTop = scroller?.scrollTop ?? 0;
|
|
34
85
|
|
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/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>;
|