From 0fab369518b02f26f46360cb152a25433d3c342b Mon Sep 17 00:00:00 2001 From: Donkie Date: Sun, 15 Oct 2023 15:28:08 +0200 Subject: [PATCH] Added live updates to spools in client Will automatically refresh the list and show pages if any of the shown spools are updated in the backend. On the edit page, it will warn you if the spool has been updated since you opened the page. --- client/public/locales/en/common.json | 3 +- client/src/App.tsx | 2 + client/src/components/liveProvider.ts | 109 ++++++++++++++++++++++++++ client/src/pages/spools/edit.tsx | 16 +++- client/src/pages/spools/list.tsx | 95 +++++++++++++++++----- client/src/pages/spools/show.tsx | 4 +- 6 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 client/src/components/liveProvider.ts 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}
= () => {