This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 8cb07c4 feat(provider): widen the add dialog and keep a draft
8cb07c4 is described below
commit 8cb07c402e61a66c736c9b40ffc060d4e2ad7d4c
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 20:06:35 2026 +0800
feat(provider): widen the add dialog and keep a draft
---
web/src/components/ProviderModelsField.tsx | 3 +
web/src/components/ProviderSourcePicker.tsx | 4 +-
web/src/components/shared/form-dialog.tsx | 17 +++--
web/src/lib/draft.ts | 56 ++++++++++++++
web/src/locales/en/data.json | 5 ++
web/src/locales/zh/data.json | 5 ++
web/src/pages/ProviderListPage.tsx | 110 ++++++++++++++++++++++++++--
7 files changed, 186 insertions(+), 14 deletions(-)
diff --git a/web/src/components/ProviderModelsField.tsx
b/web/src/components/ProviderModelsField.tsx
index c932ee8..514c5ef 100644
--- a/web/src/components/ProviderModelsField.tsx
+++ b/web/src/components/ProviderModelsField.tsx
@@ -35,10 +35,12 @@ import type {Provider} from "@/types";
export function ProviderModelsField({
provider,
hint,
+ className,
onChange,
}: {
provider: Provider;
hint?: React.ReactNode;
+ className?: string;
onChange: (models: string[]) => void;
}) {
const [fetching, setFetching] = React.useState(false);
@@ -104,6 +106,7 @@ export function ProviderModelsField({
</span>
}
hint={hint}
+ className={className}
>
<TagsInput
value={models}
diff --git a/web/src/components/ProviderSourcePicker.tsx
b/web/src/components/ProviderSourcePicker.tsx
index 284e593..0d9d08f 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -174,7 +174,7 @@ export function ProviderSourcePicker({
)}
{leading.length === 0 ? null : (
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{leading.map(source => (
<SourceCard key={source.key} source={source} onPick={onPick} />
))}
@@ -186,7 +186,7 @@ export function ProviderSourcePicker({
<span className="text-muted-foreground text-xs font-medium uppercase
tracking-wide">
{categoryTitle(group.category)}
</span>
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2
lg:grid-cols-3">
{group.sources.map(source => (
<SourceCard key={source.key} source={source} onPick={onPick} />
))}
diff --git a/web/src/components/shared/form-dialog.tsx
b/web/src/components/shared/form-dialog.tsx
index 31cc4bf..d69a545 100644
--- a/web/src/components/shared/form-dialog.tsx
+++ b/web/src/components/shared/form-dialog.tsx
@@ -46,6 +46,8 @@ export function FormDialog({
submitDisabled = false,
submitVariant = "default",
size = "default",
+ columns = 1,
+ note,
footer,
}: {
open: boolean;
@@ -60,6 +62,10 @@ export function FormDialog({
submitDisabled?: boolean;
submitVariant?: VariantProps<typeof buttonVariants>["variant"];
size?: "default" | "lg" | "xl";
+ /** Two columns keep a long form on one screen instead of a scrollbar. */
+ columns?: 1 | 2;
+ /** A line kept beside the buttons, out of the scrolling part of the form. */
+ note?: React.ReactNode;
footer?: React.ReactNode;
}) {
const sizeClass = {
@@ -75,16 +81,17 @@ export function FormDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className={cn("max-h-[90vh] overflow-hidden", sizeClass)}>
+ <DialogContent className={cn("flex max-h-[92vh] flex-col
overflow-hidden", sizeClass)}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription>
: null}
</DialogHeader>
- <form onSubmit={handleSubmit} className="flex min-h-0 flex-col gap-4">
- <div className="scrollbar-thin -mx-1 max-h-[60vh] overflow-y-auto
px-1 py-0.5">
- <div className="grid gap-4">{children}</div>
+ <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col
gap-4">
+ <div className="scrollbar-thin -mx-1 min-h-0 flex-1 overflow-y-auto
px-1 py-0.5">
+ <div className={cn("grid gap-4", columns === 2 &&
"md:grid-cols-2")}>{children}</div>
</div>
- <DialogFooter>
+ <DialogFooter className="sm:items-center">
+ {note ? <div className="sm:mr-auto">{note}</div> : null}
{footer ?? (
<>
<Button type="button" variant="outline" onClick={() =>
onOpenChange(false)}>
diff --git a/web/src/lib/draft.ts b/web/src/lib/draft.ts
new file mode 100644
index 0000000..2865c66
--- /dev/null
+++ b/web/src/lib/draft.ts
@@ -0,0 +1,56 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * A half-filled form kept in the browser so closing the dialog — or the tab —
+ * does not throw the typing away. Drafts live only here: nothing is sent to
the
+ * server until the form is submitted.
+ */
+export interface Draft<T> {
+ value: T;
+ savedAt: number;
+}
+
+const prefix = "casbin-gateway:draft:";
+
+export function readDraft<T>(key: string): Draft<T> | null {
+ try {
+ const raw = localStorage.getItem(prefix + key);
+ if (!raw) {
+ return null;
+ }
+ const draft = JSON.parse(raw) as Draft<T>;
+ return draft && draft.value !== undefined ? draft : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeDraft<T>(key: string, value: T): Draft<T> {
+ const draft = {value: value, savedAt: Date.now()};
+ try {
+ localStorage.setItem(prefix + key, JSON.stringify(draft));
+ } catch {
+ // A full or blocked storage costs the draft, not the form.
+ }
+ return draft;
+}
+
+export function clearDraft(key: string) {
+ try {
+ localStorage.removeItem(prefix + key);
+ } catch {
+ // Nothing to do: the draft is already unreachable.
+ }
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 9a9ccf9..3e86c64 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -739,6 +739,7 @@
"Connection Successful": "Connection Successful",
"Connectivity": "Connectivity",
"Connectivity hint": "A read-only probe against the upstream, which costs
nothing and answers whether this key works. It also runs once when the form is
submitted.",
+ "Continue draft": "Continue draft",
"Cooling down": "Cooling down",
"Currency": "Currency",
"Currency hint": "What the numbers are counted in, after the scale is
applied.",
@@ -747,6 +748,9 @@
"Custom vendor": "Custom",
"Custom vendor detail": "Any OpenAI- or Anthropic-compatible API. You give
it a base URL and a key.",
"Disabled": "Disabled",
+ "Discard draft": "Discard draft",
+ "Draft hint": "Kept in this browser only, the key included.",
+ "Draft saved": "Draft saved",
"Edit Provider": "Edit Provider",
"Enabled": "Enabled",
"Failed to add": "Failed to add",
@@ -820,6 +824,7 @@
"Test Connectivity": "Test Connectivity",
"This link cannot be read": "This link cannot be read",
"Type": "Type",
+ "Unfinished draft": "Unfinished draft",
"Usage": "Usage",
"Usage hint": "Point any client that speaks this provider's API at Gateway
with these variables.",
"Used": "used",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 1786186..5ffad97 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -760,6 +760,7 @@
"Connection Successful": "连接成功",
"Connectivity": "连通性",
"Connectivity hint": "对上游做一次只读探测,不产生费用,用来确认这个密钥是否可用。提交表单时也会自动探测一次。",
+ "Continue draft": "继续草稿",
"Cooling down": "冷却中",
"Currency": "币种",
"Currency hint": "换算之后这些数字的单位。",
@@ -768,6 +769,9 @@
"Custom vendor": "自定义",
"Custom vendor detail": "任何兼容 OpenAI 或 Anthropic 的 API,由你填写 base URL 和
Key。",
"Disabled": "已禁用",
+ "Discard draft": "丢弃草稿",
+ "Draft hint": "只保存在当前浏览器中,包含已填写的密钥。",
+ "Draft saved": "草稿已保存",
"Edit Provider": "编辑供应商",
"Enabled": "已启用",
"Failed to add": "添加失败",
@@ -841,6 +845,7 @@
"Test Connectivity": "测试连接",
"This link cannot be read": "这个链接读不出来",
"Type": "类型",
+ "Unfinished draft": "有未完成的草稿",
"Usage": "使用方法",
"Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。",
"Used": "已用",
diff --git a/web/src/pages/ProviderListPage.tsx
b/web/src/pages/ProviderListPage.tsx
index c163cfe..41688d7 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -17,6 +17,7 @@ import {Link, useNavigate} from "react-router-dom";
import {
ChevronDown,
ExternalLink,
+ FileClock,
Plug,
Plus,
RefreshCw,
@@ -45,6 +46,7 @@ import {Button} from "@/components/ui/button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
+import {clearDraft, readDraft, writeDraft, type Draft} from "@/lib/draft";
import {
authProvider,
authClient,
@@ -84,6 +86,13 @@ function providerFromSource(owner: string, source:
ProviderSource): Provider {
return {...newProvider(owner, sourceTitle(source)), ...source.provider};
}
+/** A half-filled add-provider form, kept in the browser until it is
submitted. */
+interface ProviderDraft {
+ provider: Provider;
+ /** Which source card it was started from, so resuming reopens the same
form. */
+ source: string;
+}
+
/**
* The fields the source already answered. They stay reachable — a preset is a
* starting point, not a lock — but out of the way of the one field, if any,
@@ -93,16 +102,16 @@ function Advanced({defaultOpen, children}: {defaultOpen:
boolean; children: Reac
const [open, setOpen] = React.useState(defaultOpen);
return (
- <div className="grid gap-4">
+ <div className="grid gap-4 md:col-span-2">
<button
type="button"
onClick={() => setOpen(!open)}
- className="text-muted-foreground hover:text-foreground flex
items-center gap-1 text-sm"
+ className="text-muted-foreground hover:text-foreground flex w-fit
items-center gap-1 text-sm"
>
<ChevronDown className={cn("size-3.5 transition-transform", open &&
"rotate-180")} />
{i18next.t("general:Advanced")}
</button>
- {open ? children : null}
+ {open ? <div className="grid gap-4 md:grid-cols-2">{children}</div> :
null}
</div>
);
}
@@ -135,8 +144,27 @@ export default function ProviderListPage({account}:
{account: Account}) {
const [refreshingQuotas, setRefreshingQuotas] = React.useState(false);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [binding, setBinding] = React.useState("");
+ const draftKey = `provider-new:${account.name}`;
+ const [draft, setDraft] = React.useState<Draft<ProviderDraft> | null>(() =>
+ readDraft<ProviderDraft>(draftKey),
+ );
const test = useProviderTest(form);
+ // What a picked source fills in by itself is not worth keeping, so a draft
+ // starts existing at the first thing typed on top of it.
+ const dirty =
+ source !== null && JSON.stringify(form) !==
JSON.stringify(providerFromSource(account.name, source));
+
+ // Closing the dialog — or the tab — must not cost what was typed, so the
form
+ // is written down as it is filled in and only dropped once it is stored.
+ React.useEffect(() => {
+ if (!addOpen || source === null || !dirty) {
+ return;
+ }
+ const id = setTimeout(() => setDraft(writeDraft(draftKey, {provider: form,
source: source.key})), 400);
+ return () => clearTimeout(id);
+ }, [addOpen, dirty, draftKey, form, source]);
+
const fetchProviders = React.useCallback(
(nextPage = page, nextPageSize = pageSize, nextSort = sort) => {
setLoading(true);
@@ -220,6 +248,29 @@ export default function ProviderListPage({account}:
{account: Account}) {
.then(() => loadQuotas(false));
}, [loadQuotas]);
+ const discardDraft = () => {
+ clearDraft(draftKey);
+ setDraft(null);
+ };
+
+ // Reopens the form exactly where it was left, source card included.
+ const resumeDraft = () => {
+ if (draft === null) {
+ return;
+ }
+ setForm(draft.value.provider);
+ setSource(providerSources.find(item => item.key === draft.value.source) ??
null);
+ setNameError("");
+ setAddOpen(true);
+ };
+
+ const closeAddDialog = (open: boolean) => {
+ setAddOpen(open);
+ if (!open && draft !== null) {
+ Setting.showMessage("info", i18next.t("provider:Draft saved"));
+ }
+ };
+
const openAddDialog = (start?: ProviderSource) => {
setForm(start ? providerFromSource(account.name, start) :
newProvider(account.name));
setSource(start ?? null);
@@ -275,6 +326,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
Setting.showMessage("error", `${i18next.t("provider:Failed to
add")}: ${res.msg}`);
} else {
Setting.showMessage("success", i18next.t("provider:Provider added
successfully"));
+ discardDraft();
setAddOpen(false);
fetchProviders();
}
@@ -343,6 +395,12 @@ export default function ProviderListPage({account}:
{account: Account}) {
</Link>
</Button>
) : null}
+ {draft === null ? null : (
+ <Button variant="outline" onClick={resumeDraft}>
+ <FileClock />
+ {i18next.t("provider:Continue draft")}
+ </Button>
+ )}
<Button onClick={() => openAddDialog()}>
<Plus />
{i18next.t("provider:New Provider")}
@@ -430,10 +488,22 @@ export default function ProviderListPage({account}:
{account: Account}) {
<FormDialog
open={addOpen}
- onOpenChange={setAddOpen}
+ onOpenChange={closeAddDialog}
title={i18next.t("provider:New Provider")}
description={source === null ? i18next.t("provider:Source hint") :
undefined}
- size={source === null ? "lg" : "default"}
+ size="xl"
+ columns={source === null ? 1 : 2}
+ note={
+ draft === null || source === null ? undefined : (
+ <span className="text-muted-foreground flex flex-wrap items-center
gap-2 text-xs">
+ <FileClock className="size-3.5" />
+ {i18next.t("provider:Draft saved")} · {new
Date(draft.savedAt).toLocaleTimeString()}
+ <Button type="button" size="xs" variant="ghost"
onClick={discardDraft}>
+ {i18next.t("provider:Discard draft")}
+ </Button>
+ </span>
+ )
+ }
submitting={adding || test.testing}
submitText={i18next.t("provider:Add provider")}
onSubmit={submitProvider}
@@ -448,10 +518,35 @@ export default function ProviderListPage({account}:
{account: Account}) {
}
>
{source === null ? (
- <ProviderSourcePicker onPick={pickSource} onLink={importLink} />
+ <>
+ {draft === null ? null : (
+ <MessageAlert
+ variant="info"
+ title={i18next.t("provider:Unfinished draft")}
+ description={
+ <>
+ <p>{`${draft.value.provider.displayName ||
i18next.t("provider:New Provider")} · ${new
Date(draft.savedAt).toLocaleString()}`}</p>
+ <p className="text-xs
opacity-80">{i18next.t("provider:Draft hint")}</p>
+ </>
+ }
+ action={
+ <div className="flex flex-wrap gap-2">
+ <Button type="button" size="xs" onClick={resumeDraft}>
+ <FileClock />
+ {i18next.t("provider:Continue draft")}
+ </Button>
+ <Button type="button" size="xs" variant="ghost"
onClick={discardDraft}>
+ {i18next.t("provider:Discard draft")}
+ </Button>
+ </div>
+ }
+ />
+ )}
+ <ProviderSourcePicker onPick={pickSource} onLink={importLink} />
+ </>
) : (
<>
- <Field label={i18next.t("provider:Source")}>
+ <Field label={i18next.t("provider:Source")}
className="md:col-span-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="info">{sourceTitle(source)}</Badge>
{usesClientAuth(form) ? (
@@ -516,6 +611,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
)}
<ProviderModelsField
provider={form}
+ className="md:col-span-2"
hint={usesClientAuth(form) ? i18next.t("provider:Any model
hint") : i18next.t("provider:Models hint")}
onChange={value => setFormField("models", value)}
/>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]