akanjs 3.0.0-alpha.97 → 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/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 +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
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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>;
|