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]

Reply via email to