clan-app: Improved UX of handling toasts
This commit is contained in:
@@ -57,6 +57,21 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
status="success",
|
status="success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
*,
|
||||||
|
op_key: str,
|
||||||
|
) -> SuccessDataClass[list[str]] | ErrorDataClass:
|
||||||
|
"""List all tasks."""
|
||||||
|
log.info("Listing all tasks.")
|
||||||
|
with webview.lock:
|
||||||
|
tasks = list(webview.threads.keys())
|
||||||
|
return SuccessDataClass(
|
||||||
|
op_key=op_key,
|
||||||
|
data=tasks,
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
|
||||||
|
API.overwrite_fn(list_tasks)
|
||||||
API.overwrite_fn(open_file)
|
API.overwrite_fn(open_file)
|
||||||
API.overwrite_fn(cancel_task)
|
API.overwrite_fn(cancel_task)
|
||||||
webview.bind_jsonschema_api(API)
|
webview.bind_jsonschema_api(API)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Schema as Inventory } from "@/api/Inventory";
|
|||||||
import { toast, Toast } from "solid-toast";
|
import { toast, Toast } from "solid-toast";
|
||||||
import {
|
import {
|
||||||
ErrorToastComponent,
|
ErrorToastComponent,
|
||||||
InfoToastComponent,
|
CancelToastComponent,
|
||||||
} from "@/src/components/toast";
|
} from "@/src/components/toast";
|
||||||
export type OperationNames = keyof API;
|
export type OperationNames = keyof API;
|
||||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||||
@@ -62,10 +62,14 @@ const _callApi = <K extends OperationNames>(
|
|||||||
return { promise, op_key };
|
return { promise, op_key };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async (ops_key: string) => {
|
const handleCancel = async <K extends OperationNames>(
|
||||||
|
ops_key: string,
|
||||||
|
orig_task: Promise<OperationResponse<K>>,
|
||||||
|
) => {
|
||||||
console.log("Canceling operation: ", ops_key);
|
console.log("Canceling operation: ", ops_key);
|
||||||
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
|
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
|
||||||
const resp = await promise;
|
const resp = await promise;
|
||||||
|
|
||||||
if (resp.status === "error") {
|
if (resp.status === "error") {
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
@@ -79,14 +83,8 @@ const handleCancel = async (ops_key: string) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.custom(
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(t) => (
|
(orig_task as any).cancelled = true;
|
||||||
<InfoToastComponent t={t} message={"Canceled operation: " + ops_key} />
|
|
||||||
),
|
|
||||||
{
|
|
||||||
duration: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
console.log("Cancel response: ", resp);
|
console.log("Cancel response: ", resp);
|
||||||
};
|
};
|
||||||
@@ -102,10 +100,10 @@ export const callApi = async <K extends OperationNames>(
|
|||||||
(
|
(
|
||||||
t, // t is the Toast object, t.id is the id of THIS toast instance
|
t, // t is the Toast object, t.id is the id of THIS toast instance
|
||||||
) => (
|
) => (
|
||||||
<InfoToastComponent
|
<CancelToastComponent
|
||||||
t={t}
|
t={t}
|
||||||
message={"Exectuting " + method}
|
message={"Exectuting " + method}
|
||||||
onCancel={handleCancel.bind(null, op_key)}
|
onCancel={handleCancel.bind(null, op_key, promise)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -114,16 +112,23 @@ export const callApi = async <K extends OperationNames>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const response = await promise;
|
const response = await promise;
|
||||||
if (response.status === "error") {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const cancelled = (promise as any).cancelled;
|
||||||
|
if (cancelled) {
|
||||||
|
console.log("Not printing toast because operation was cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === "error" && !cancelled) {
|
||||||
toast.remove(toastId);
|
toast.remove(toastId);
|
||||||
toast.error(
|
toast.custom(
|
||||||
<div>
|
(t) => (
|
||||||
{response.errors.map((err) => (
|
<ErrorToastComponent
|
||||||
<p>{err.message}</p>
|
t={t}
|
||||||
))}
|
message={"Error: " + response.errors[0].message}
|
||||||
</div>,
|
/>
|
||||||
|
),
|
||||||
{
|
{
|
||||||
duration: 5000,
|
duration: Infinity,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast, Toast } from "solid-toast"; // Make sure to import Toast type
|
import { toast, Toast } from "solid-toast"; // Make sure to import Toast type
|
||||||
import { Component, JSX } from "solid-js";
|
import { Component, JSX, createSignal, onCleanup } from "solid-js";
|
||||||
|
|
||||||
// --- Icon Components ---
|
// --- Icon Components ---
|
||||||
|
|
||||||
@@ -108,43 +108,83 @@ const closeButtonStyle: JSX.CSSProperties = {
|
|||||||
|
|
||||||
// Error Toast
|
// Error Toast
|
||||||
export const ErrorToastComponent: Component<BaseToastProps> = (props) => {
|
export const ErrorToastComponent: Component<BaseToastProps> = (props) => {
|
||||||
const handleCancelClick = () => {
|
// Local state for click feedback and exit animation
|
||||||
if (props.onCancel) {
|
let timeoutId: number | undefined;
|
||||||
props.onCancel();
|
const [clicked, setClicked] = createSignal(false);
|
||||||
}
|
const [exiting, setExiting] = createSignal(false);
|
||||||
toast.dismiss(props.t.id);
|
|
||||||
|
const handleToastClick = () => {
|
||||||
|
setClicked(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setExiting(true);
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
toast.dismiss(props.t.id);
|
||||||
|
}, 300); // Match exit animation duration
|
||||||
|
}, 100); // Brief color feedback before animating out
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cleanup timeout if unmounted early
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={baseToastStyle}>
|
<div
|
||||||
|
style={{
|
||||||
|
...baseToastStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||||
|
background: clicked()
|
||||||
|
? "#ffeaea"
|
||||||
|
: exiting()
|
||||||
|
? "#fff"
|
||||||
|
: baseToastStyle.background,
|
||||||
|
opacity: exiting() ? 0 : 1,
|
||||||
|
transform: exiting() ? "translateY(-20px)" : "none",
|
||||||
|
}}
|
||||||
|
onClick={handleToastClick}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||||
>
|
>
|
||||||
<ErrorIcon />
|
<ErrorIcon />
|
||||||
<span>{props.message}</span>
|
<span>{props.message}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleCancelClick}
|
|
||||||
style={closeButtonStyle}
|
|
||||||
aria-label="Close notification"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Info Toast
|
// Info Toast
|
||||||
export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
export const CancelToastComponent: Component<BaseToastProps> = (props) => {
|
||||||
const handleCancelClick = () => {
|
let timeoutId: number | undefined;
|
||||||
if (props.onCancel) {
|
const [clicked, setClicked] = createSignal(false);
|
||||||
props.onCancel();
|
const [exiting, setExiting] = createSignal(false);
|
||||||
}
|
|
||||||
|
// Cleanup timeout if unmounted early
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleButtonClick = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (props.onCancel) props.onCancel();
|
||||||
toast.dismiss(props.t.id);
|
toast.dismiss(props.t.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={baseToastStyle}>
|
<div
|
||||||
|
style={{
|
||||||
|
...baseToastStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||||
|
background: clicked()
|
||||||
|
? "#eaf4ff"
|
||||||
|
: exiting()
|
||||||
|
? "#fff"
|
||||||
|
: baseToastStyle.background,
|
||||||
|
opacity: exiting() ? 0 : 1,
|
||||||
|
transform: exiting() ? "translateY(-20px)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||||
>
|
>
|
||||||
@@ -152,11 +192,62 @@ export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
|||||||
<span>{props.message}</span>
|
<span>{props.message}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelClick}
|
onClick={(e) => {
|
||||||
style={closeButtonStyle}
|
setClicked(true);
|
||||||
aria-label="Close notification"
|
handleButtonClick(e);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...closeButtonStyle,
|
||||||
|
color: "#2196F3",
|
||||||
|
"font-size": "1em",
|
||||||
|
"font-weight": "normal",
|
||||||
|
padding: "4px 12px",
|
||||||
|
border: "1px solid #2196F3",
|
||||||
|
"border-radius": "4px",
|
||||||
|
background: clicked() ? "#bbdefb" : "#eaf4ff",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
display: "flex",
|
||||||
|
"align-items": "center",
|
||||||
|
"justify-content": "center",
|
||||||
|
width: "70px",
|
||||||
|
height: "32px",
|
||||||
|
}}
|
||||||
|
aria-label="Cancel"
|
||||||
|
disabled={clicked()}
|
||||||
>
|
>
|
||||||
✕
|
{clicked() ? (
|
||||||
|
// Simple spinner SVG
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
r="20"
|
||||||
|
fill="none"
|
||||||
|
stroke="#2196F3"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="31.415, 31.415"
|
||||||
|
transform="rotate(72 25 25)"
|
||||||
|
>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
from="0 25 25"
|
||||||
|
to="360 25 25"
|
||||||
|
dur="0.8s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
"Cancel"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -164,78 +255,47 @@ export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
|||||||
|
|
||||||
// Warning Toast
|
// Warning Toast
|
||||||
export const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
export const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
||||||
const handleCancelClick = () => {
|
let timeoutId: number | undefined;
|
||||||
if (props.onCancel) {
|
const [clicked, setClicked] = createSignal(false);
|
||||||
props.onCancel();
|
const [exiting, setExiting] = createSignal(false);
|
||||||
}
|
|
||||||
toast.dismiss(props.t.id);
|
const handleToastClick = () => {
|
||||||
|
setClicked(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setExiting(true);
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
toast.dismiss(props.t.id);
|
||||||
|
}, 300);
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cleanup timeout if unmounted early
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={baseToastStyle}>
|
<div
|
||||||
|
style={{
|
||||||
|
...baseToastStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||||
|
background: clicked()
|
||||||
|
? "#fff8e1"
|
||||||
|
: exiting()
|
||||||
|
? "#fff"
|
||||||
|
: baseToastStyle.background,
|
||||||
|
opacity: exiting() ? 0 : 1,
|
||||||
|
transform: exiting() ? "translateY(-20px)" : "none",
|
||||||
|
}}
|
||||||
|
onClick={handleToastClick}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||||
>
|
>
|
||||||
<WarningIcon />
|
<WarningIcon />
|
||||||
<span>{props.message}</span>
|
<span>{props.message}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleCancelClick}
|
|
||||||
style={closeButtonStyle}
|
|
||||||
aria-label="Close notification"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Example Usage ---
|
|
||||||
/*
|
|
||||||
import { toast } from 'solid-toast';
|
|
||||||
import {
|
|
||||||
ErrorToastComponent,
|
|
||||||
InfoToastComponent,
|
|
||||||
WarningToastComponent
|
|
||||||
} from './your-toast-components-file'; // Adjust path as necessary
|
|
||||||
|
|
||||||
const logCancel = (type: string) => console.log(`${type} toast cancelled by user.`);
|
|
||||||
|
|
||||||
// Function to show an error toast
|
|
||||||
export const showErrorToast = (message: string) => {
|
|
||||||
toast.custom((t) => (
|
|
||||||
<ErrorToastComponent
|
|
||||||
t={t}
|
|
||||||
message={message}
|
|
||||||
onCancel={() => logCancel('Error')}
|
|
||||||
/>
|
|
||||||
), { duration: Infinity }); // Use Infinity duration if you want it to only close on X click
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to show an info toast
|
|
||||||
export const showInfoToast = (message: string) => {
|
|
||||||
toast.custom((t) => (
|
|
||||||
<InfoToastComponent
|
|
||||||
t={t}
|
|
||||||
message={message}
|
|
||||||
// onCancel not provided, so only dismisses
|
|
||||||
/>
|
|
||||||
), { duration: 6000 }); // Or some default duration
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to show a warning toast
|
|
||||||
export const showWarningToast = (message: string) => {
|
|
||||||
toast.custom((t) => (
|
|
||||||
<WarningToastComponent
|
|
||||||
t={t}
|
|
||||||
message={message}
|
|
||||||
onCancel={() => alert('Warning toast was cancelled!')}
|
|
||||||
/>
|
|
||||||
), { duration: Infinity });
|
|
||||||
};
|
|
||||||
|
|
||||||
// How to use them:
|
|
||||||
// showErrorToast("Target IP must be provided.");
|
|
||||||
// showInfoToast("Your profile has been updated successfully.");
|
|
||||||
// showWarningToast("Your session is about to expire in 5 minutes.");
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const createModulesQuery = (
|
|||||||
base_path: uri,
|
base_path: uri,
|
||||||
});
|
});
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
toast.error("Failed to fetch data");
|
console.error("Failed to fetch data");
|
||||||
} else {
|
} else {
|
||||||
console.log(response.data.localModules["hello-world"]["manifest"]);
|
console.log(response.data.localModules["hello-world"]["manifest"]);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -47,7 +47,7 @@ export const tagsQuery = (uri: string | null) =>
|
|||||||
flake: { identifier: uri },
|
flake: { identifier: uri },
|
||||||
});
|
});
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
toast.error("Failed to fetch data");
|
console.error("Failed to fetch data");
|
||||||
} else {
|
} else {
|
||||||
const machines = response.data.machines || {};
|
const machines = response.data.machines || {};
|
||||||
const tags = Object.values(machines).flatMap((m) => m.tags || []);
|
const tags = Object.values(machines).flatMap((m) => m.tags || []);
|
||||||
@@ -68,7 +68,7 @@ export const machinesQuery = (uri: string | null) =>
|
|||||||
flake: { identifier: uri },
|
flake: { identifier: uri },
|
||||||
});
|
});
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
toast.error("Failed to fetch data");
|
console.error("Failed to fetch data");
|
||||||
} else {
|
} else {
|
||||||
const machines = response.data.machines || {};
|
const machines = response.data.machines || {};
|
||||||
return Object.keys(machines);
|
return Object.keys(machines);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const MachineListView: Component = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === "error") {
|
if (response.status === "error") {
|
||||||
toast.error("Failed to fetch data");
|
console.error("Failed to fetch data");
|
||||||
} else {
|
} else {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user