diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 2df5dd3..1f4a4c5 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -165,7 +165,8 @@ "archive": "Archive Spool" }, "form": { - "new_location_prompt": "Enter a new location" + "new_location_prompt": "Enter a new location", + "spool_updated": "This spool has been updated by someone/something else since you opened this page. Saving will overwrite those changes!" }, "messages": { "archive": "Are you sure you want to archive this spool?" diff --git a/client/src/App.tsx b/client/src/App.tsx index 6caec8f..634bed4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,7 @@ import { languages } from "./i18n"; import loadable from "@loadable/component"; import SpoolmanNotificationProvider from "./components/notificationProvider"; import { SpoolmanLayout } from "./components/layout"; +import liveProvider from "./components/liveProvider"; interface PageProps { resource: "spools" | "filaments" | "vendors"; @@ -84,6 +85,7 @@ function App() { notificationProvider={SpoolmanNotificationProvider} i18nProvider={i18nProvider} routerProvider={routerBindings} + liveProvider={liveProvider(import.meta.env.VITE_APIURL)} resources={[ { name: "spool", diff --git a/client/src/components/liveProvider.ts b/client/src/components/liveProvider.ts new file mode 100644 index 0000000..3fb8f91 --- /dev/null +++ b/client/src/components/liveProvider.ts @@ -0,0 +1,109 @@ +import { BaseKey, LiveEvent, LiveProvider } from "@refinedev/core"; + +/** + * Converts an API URL to a WebSocket URL. + * E.g. "https://example.com/api/v1/..." -> "ws://example.com/api/v1/..." + * or "/api/v1/..." -> "ws://example.com/api/v1/..." + * @param apiUrl The API URL to convert + * @returns The WebSocket URL + */ +function toWebsocketURL(apiUrl: string) { + if (apiUrl[0] === "/") { + // Relative URL, e.g. "/api/v1/..." + + // Get the current browser URL + const currentURL = window.location.href; + + // Split the URL to separate the protocol, host, and path + const urlParts = currentURL.split('/'); + const host = urlParts[2]; + + // Create the WebSocket URL by adding "ws://" as the protocol and the relative URL as the path + return `ws://${host}${apiUrl}`; + } + else { + // Absolute URL, e.g. "https://example.com/api/v1/..." + + // Replace the protocol with "ws://" + return apiUrl.replace(/^http/, 'ws'); + } +} + +/** + * Subscribes to a single resource. + * @param apiUrl The API URL + * @param channel The channel name, not really used + * @param resource The resource name + * @param id The ID of the resource + * @param callback The callback to call when the resource is updated + * @returns A function to unsubscribe from the resource + */ +function subscribeSingle(apiUrl: string, channel: string, resource: string, id: BaseKey, callback: (event: LiveEvent) => void) { + // Verify that WebSockets are supported + if (!('WebSocket' in window)) { + console.warn("WebSockets are not supported in this browser. Live updates will not be available."); + return () => { }; + } + + const websocketURL = toWebsocketURL(`${apiUrl}/${resource}/${id}`); + + const ws = new WebSocket(websocketURL); + ws.onmessage = (message) => { + const liveEvent: LiveEvent = { + channel: channel, + type: "updated", + payload: { + data: JSON.parse(message.data), + ids: [id], + }, + date: new Date(), + } + + callback(liveEvent); + }; + + return () => { + ws.close(); + }; +} + +const liveProvider = ( + apiUrl: string, +): LiveProvider => ({ + subscribe: ({ channel, params, callback }) => { + const { + resource, + subscriptionType, + id, + ids, + } = params ?? {}; + + if (!subscriptionType) { + throw new Error( + "[useSubscription]: `subscriptionType` is required in `params`", + ); + } + + if (!resource) { + throw new Error( + "[useSubscription]: `resource` is required in `params`", + ); + } + + let idList: BaseKey[]; + if (ids) idList = ids; + else if (id) idList = [id]; + else throw new Error( + "[useSubscription]: `id` or `ids` is required in `params`", + ); + + return idList.map((id) => { + return subscribeSingle(apiUrl, channel, resource, id, callback); + }); + }, + unsubscribe: (closers: (() => void)[]) => { + closers.forEach((fn) => fn()); + }, +}) + +export default liveProvider; diff --git a/client/src/pages/spools/edit.tsx b/client/src/pages/spools/edit.tsx index ae41c9e..1bbce4b 100644 --- a/client/src/pages/spools/edit.tsx +++ b/client/src/pages/spools/edit.tsx @@ -1,18 +1,28 @@ import React, { useEffect, useState } from "react"; import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { Edit, useForm, useSelect } from "@refinedev/antd"; -import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider } from "antd"; +import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert } from "antd"; import dayjs from "dayjs"; import TextArea from "antd/es/input/TextArea"; import { IFilament } from "../filaments/model"; import { ISpool } from "./model"; import { numberFormatter, numberParser } from "../../utils/parsing"; import { useSpoolmanLocations } from "../../components/otherModels"; +import { message } from "antd/lib"; export const SpoolEdit: React.FC = () => { const t = useTranslate(); + const [messageApi, contextHolder] = message.useMessage(); + const [hasChanged, setHasChanged] = useState(false); - const { form, formProps, saveButtonProps } = useForm(); + const { form, formProps, saveButtonProps } = useForm({ + liveMode: "manual", + onLiveEvent() { + // Warn the user if the spool has been updated since the form was opened + messageApi.warning(t("spool.form.spool_updated")); + setHasChanged(true); + }, + }); const { queryResult } = useSelect({ resource: "filament", @@ -96,6 +106,7 @@ export const SpoolEdit: React.FC = () => { return ( + {contextHolder}
= () => {