Skip to content

Commit 0c3fb04

Browse files
authored
feat(toast): add toast notifications for user feedback across app (#124, RDFA-243)
Introduce a global toast notification system to enhance user feedback for actions like imports, exports, edits, and errors. Includes `Toast`, `ToastContainer`, and a lightweight `toastStore` for managing notifications. Signed-off-by: Jan-Hendrik Spahn <jan-hendrik.spahn@soptim.de>
1 parent 4a2676f commit 0c3fb04

40 files changed

Lines changed: 1156 additions & 105 deletions

frontend/src/lib/GraphExport.svelte

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import { DropdownMenu } from "$lib/components/bitsui/dropdown/index";
2626
import DatasetAndGraphSelection from "$lib/components/DatasetAndGraphSelection.svelte";
2727
import { PUBLIC_BACKEND_URL } from "$lib/config/runtime";
28+
import { toastStore } from "$lib/eventhandling/toastStore.svelte.js";
2829
import { ReactiveOntology } from "$lib/models/reactive/models/ontology/reactive-ontology.svelte.js";
2930
import { forceReloadTrigger } from "$lib/sharedState.svelte.js";
3031
import { saveFile, supportedRDFMediaTypes } from "$lib/utils/fileUtils.ts";
@@ -150,22 +151,44 @@
150151
ontology.entries.append(entry);
151152
}
152153
}
153-
await bec.putOntology(
154+
const ontologyRes = await bec.putOntology(
154155
selectedDatasetName,
155156
graphURI,
156157
ontology.getPlainObject(),
157158
);
159+
if (ontologyRes && ontologyRes.ok === false) {
160+
toastStore.error(
161+
"Profile header update failed",
162+
"Could not persist the generated profile header entries; export aborted.",
163+
);
164+
return;
165+
}
158166
forceReloadTrigger.trigger();
159167
}
160168
try {
161169
const response = await fetchGraphFile(getAPIRoute);
170+
if (!response.ok) {
171+
toastStore.error(
172+
"Export failed",
173+
`Could not export "${graphURI}".`,
174+
);
175+
return;
176+
}
162177
const blob = await response.blob();
163178
const suggestedFilename = response.headers.get(
164179
"content-disposition",
165180
);
166181
saveFile(blob, suggestedFilename, selectedMediaType);
182+
toastStore.success(
183+
"Export ready",
184+
`"${graphURI}" downloaded as ${selectedMediaType.name}.`,
185+
);
167186
} catch (e) {
168187
console.error("Failed to download graph:", e);
188+
toastStore.error(
189+
"Export failed",
190+
"An unexpected error occurred while exporting.",
191+
);
169192
} finally {
170193
showDialog = false;
171194
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (c) 2024-2026 SOPTIM AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
/**
19+
* Shared actions for toggling the read-only state of a dataset.
20+
*
21+
* Each function performs the API call, surfaces a success or error toast,
22+
* and returns `true` when the write succeeded. Reactive state updates that
23+
* are local to a call site (refreshing local `readonly` flags, triggering
24+
* editor state, etc.) remain the caller's responsibility.
25+
*/
26+
27+
import { BackendConnection } from "$lib/api/backend.js";
28+
import { PUBLIC_BACKEND_URL } from "$lib/config/runtime";
29+
import { toastStore } from "$lib/eventhandling/toastStore.svelte.js";
30+
31+
const bec = new BackendConnection(fetch, PUBLIC_BACKEND_URL);
32+
33+
/**
34+
* Make the dataset editable. Toasts on success and failure.
35+
*
36+
* @param {string} datasetName
37+
* @returns {Promise<boolean>} `true` when the dataset is now editable.
38+
*/
39+
export async function enableEditing(datasetName) {
40+
if (!datasetName) return false;
41+
const res = await bec.enableEditing(datasetName);
42+
if (res && res.ok === false) {
43+
toastStore.error(
44+
"Could not enable editing",
45+
`Dataset "${datasetName}" remains read-only.`,
46+
);
47+
return false;
48+
}
49+
toastStore.success(
50+
"Editing enabled",
51+
`Dataset "${datasetName}" is now editable.`,
52+
);
53+
return true;
54+
}
55+
56+
/**
57+
* Mark the dataset read-only. Toasts on success and failure.
58+
*
59+
* @param {string} datasetName
60+
* @returns {Promise<boolean>} `true` when the dataset is now read-only.
61+
*/
62+
export async function disableEditing(datasetName) {
63+
if (!datasetName) return false;
64+
const res = await bec.disableEditing(datasetName);
65+
if (res && res.ok === false) {
66+
toastStore.error(
67+
"Could not disable editing",
68+
`Dataset "${datasetName}" remains editable.`,
69+
);
70+
return false;
71+
}
72+
toastStore.success(
73+
"Editing disabled",
74+
`Dataset "${datasetName}" is now read-only.`,
75+
);
76+
return true;
77+
}

frontend/src/lib/actions/versionControlActions.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import { PUBLIC_BACKEND_URL } from "$lib/config/runtime";
19+
import { toastStore } from "$lib/eventhandling/toastStore.svelte.js";
1920
import { editorState } from "$lib/sharedState.svelte.js";
2021

2122
function resolveTargets(datasetName, graphURI) {
@@ -71,6 +72,7 @@ export async function undo(datasetName, graphURI) {
7172
}
7273

7374
console.log("Undo failed.");
75+
toastStore.error("Undo failed", "Could not undo the last change.");
7476
return false;
7577
}
7678

@@ -113,5 +115,6 @@ export async function redo(datasetName, graphURI) {
113115
}
114116

115117
console.log("Redo failed.");
118+
toastStore.error("Redo failed", "Could not redo the change.");
116119
return false;
117120
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright (c) 2024-2026 SOPTIM AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
/**
19+
* Backend connection monitor.
20+
*
21+
* Installs a wrapper around `window.fetch` that detects network-level
22+
* failures (TypeError from `fetch`) on requests to the backend and surfaces
23+
* them as a single sticky toast. HTTP error responses (4xx / 5xx) are NOT
24+
* intercepted — those are application-level errors and remain the
25+
* responsibility of the individual call site.
26+
*
27+
* On the first request that succeeds after an offline period the sticky toast
28+
* is dismissed and a short "connection restored" toast is shown.
29+
*/
30+
31+
import { PUBLIC_BACKEND_URL } from "$lib/config/runtime";
32+
import { toastStore } from "$lib/eventhandling/toastStore.svelte.js";
33+
34+
/** Reactive connection state — components can read `.isOffline` if needed. */
35+
export const backendConnection = $state({ isOffline: false });
36+
37+
let offlineToastId = null;
38+
let installed = false;
39+
40+
/**
41+
* Resolve the URL of a `fetch()` first argument to a string we can pattern-match.
42+
* `fetch` accepts string | URL | Request, so we have to handle all three.
43+
*/
44+
function resolveUrl(input) {
45+
if (typeof input === "string") return input;
46+
if (input instanceof URL) return input.href;
47+
if (typeof Request !== "undefined" && input instanceof Request) {
48+
return input.url;
49+
}
50+
return "";
51+
}
52+
53+
function isBackendUrl(url) {
54+
if (!PUBLIC_BACKEND_URL) return false;
55+
return url.startsWith(PUBLIC_BACKEND_URL);
56+
}
57+
58+
function isNetworkError(error) {
59+
if (!error) return false;
60+
if (error.name === "AbortError") return false;
61+
return error instanceof TypeError;
62+
}
63+
64+
function markOffline() {
65+
backendConnection.isOffline = true;
66+
if (offlineToastId !== null) return;
67+
offlineToastId = toastStore.error(
68+
"Backend unreachable",
69+
"Could not reach the backend. Check that the server is running and try again.",
70+
{ duration: 0 },
71+
);
72+
}
73+
74+
function markOnline() {
75+
if (!backendConnection.isOffline) return;
76+
backendConnection.isOffline = false;
77+
if (offlineToastId !== null) {
78+
toastStore.dismiss(offlineToastId);
79+
offlineToastId = null;
80+
}
81+
toastStore.success(
82+
"Backend connection restored",
83+
"The connection to the backend has been restored.",
84+
);
85+
}
86+
87+
/**
88+
* Install the global fetch interceptor. Safe to call multiple times.
89+
* Returns an `uninstall` function.
90+
*/
91+
export function installBackendFetchInterceptor() {
92+
if (typeof window === "undefined") return () => {};
93+
if (installed) return () => {};
94+
installed = true;
95+
96+
const originalFetch = window.fetch.bind(window);
97+
98+
window.fetch = async function interceptedFetch(input, init) {
99+
const url = resolveUrl(input);
100+
const watched = isBackendUrl(url);
101+
102+
try {
103+
const response = await originalFetch(input, init);
104+
if (watched) {
105+
markOnline();
106+
}
107+
return response;
108+
} catch (error) {
109+
if (watched && isNetworkError(error)) {
110+
markOffline();
111+
}
112+
throw error;
113+
}
114+
};
115+
116+
return function uninstall() {
117+
if (!installed) return;
118+
window.fetch = originalFetch;
119+
installed = false;
120+
};
121+
}
122+
123+
/**
124+
* Active probe — call this on app start so a cold-start with the backend
125+
* already down surfaces the toast before the user clicks anything.
126+
*/
127+
export async function probeBackendConnection() {
128+
if (!PUBLIC_BACKEND_URL || typeof window === "undefined") return;
129+
try {
130+
await fetch(`${PUBLIC_BACKEND_URL}/datasets`, {
131+
method: "GET",
132+
credentials: "include",
133+
});
134+
} catch {
135+
// Ignore errors here; the interceptor will mark offline if it's a network error.
136+
}
137+
}

0 commit comments

Comments
 (0)