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",
|
||||
)
|
||||
|
||||
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(cancel_task)
|
||||
webview.bind_jsonschema_api(API)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema as Inventory } from "@/api/Inventory";
|
||||
import { toast, Toast } from "solid-toast";
|
||||
import {
|
||||
ErrorToastComponent,
|
||||
InfoToastComponent,
|
||||
CancelToastComponent,
|
||||
} from "@/src/components/toast";
|
||||
export type OperationNames = keyof API;
|
||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||
@@ -62,10 +62,14 @@ const _callApi = <K extends OperationNames>(
|
||||
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);
|
||||
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
|
||||
const resp = await promise;
|
||||
|
||||
if (resp.status === "error") {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
@@ -79,14 +83,8 @@ const handleCancel = async (ops_key: string) => {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<InfoToastComponent t={t} message={"Canceled operation: " + ops_key} />
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(orig_task as any).cancelled = true;
|
||||
}
|
||||
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
|
||||
) => (
|
||||
<InfoToastComponent
|
||||
<CancelToastComponent
|
||||
t={t}
|
||||
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;
|
||||
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.error(
|
||||
<div>
|
||||
{response.errors.map((err) => (
|
||||
<p>{err.message}</p>
|
||||
))}
|
||||
</div>,
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Error: " + response.errors[0].message}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 ---
|
||||
|
||||
@@ -108,43 +108,83 @@ const closeButtonStyle: JSX.CSSProperties = {
|
||||
|
||||
// Error Toast
|
||||
export const ErrorToastComponent: Component<BaseToastProps> = (props) => {
|
||||
const handleCancelClick = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
toast.dismiss(props.t.id);
|
||||
// Local state for click feedback and exit animation
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
const [exiting, setExiting] = createSignal(false);
|
||||
|
||||
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 (
|
||||
<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
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
<ErrorIcon />
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancelClick}
|
||||
style={closeButtonStyle}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Info Toast
|
||||
export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
||||
const handleCancelClick = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
export const CancelToastComponent: Component<BaseToastProps> = (props) => {
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
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);
|
||||
};
|
||||
|
||||
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
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
@@ -152,11 +192,62 @@ export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancelClick}
|
||||
style={closeButtonStyle}
|
||||
aria-label="Close notification"
|
||||
onClick={(e) => {
|
||||
setClicked(true);
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -164,78 +255,47 @@ export const InfoToastComponent: Component<BaseToastProps> = (props) => {
|
||||
|
||||
// Warning Toast
|
||||
export const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
||||
const handleCancelClick = () => {
|
||||
if (props.onCancel) {
|
||||
props.onCancel();
|
||||
}
|
||||
toast.dismiss(props.t.id);
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
const [exiting, setExiting] = createSignal(false);
|
||||
|
||||
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 (
|
||||
<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
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
<WarningIcon />
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancelClick}
|
||||
style={closeButtonStyle}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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,
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
console.log(response.data.localModules["hello-world"]["manifest"]);
|
||||
return response.data;
|
||||
@@ -47,7 +47,7 @@ export const tagsQuery = (uri: string | null) =>
|
||||
flake: { identifier: uri },
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
const machines = response.data.machines || {};
|
||||
const tags = Object.values(machines).flatMap((m) => m.tags || []);
|
||||
@@ -68,7 +68,7 @@ export const machinesQuery = (uri: string | null) =>
|
||||
flake: { identifier: uri },
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
const machines = response.data.machines || {};
|
||||
return Object.keys(machines);
|
||||
|
||||
@@ -44,7 +44,7 @@ export const MachineListView: Component = () => {
|
||||
},
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user