clan-app: Improved UX of handling toasts

This commit is contained in:
Qubasa
2025-05-12 18:54:53 +02:00
parent 7f0a430ec0
commit a834f210a0
5 changed files with 192 additions and 112 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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.");
*/

View File

@@ -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);

View File

@@ -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;
}