From 1ef0d1563398411dd34c0f58f071ab655ab57ebb Mon Sep 17 00:00:00 2001 From: lucferbux Date: Wed, 21 Aug 2024 01:48:20 +0200 Subject: [PATCH] Add app context and revamps App.tsx Signed-off-by: lucferbux --- clients/ui/frontend/package-lock.json | 36 ++-- clients/ui/frontend/package.json | 1 + clients/ui/frontend/src/app/App.tsx | 100 ++++++++++-- clients/ui/frontend/src/app/AppContext.ts | 13 ++ clients/ui/frontend/src/app/useSettings.tsx | 85 ++++++++++ .../frontend/src/app/useTimeBasedRefresh.ts | 48 ++++++ clients/ui/frontend/src/app/useUser.tsx | 0 .../browserStorage/BrowserStorageContext.tsx | 154 ++++++++++++++++++ .../src/components/browserStorage/index.ts | 1 + clients/ui/frontend/src/index.tsx | 20 +-- clients/ui/frontend/src/types.ts | 29 ++++ clients/ui/frontend/src/utilities/const.ts | 5 + .../src/utilities/useDeepCompareMemoize.ts | 12 ++ .../src/utilities/useEventListener.ts | 14 ++ 14 files changed, 476 insertions(+), 42 deletions(-) create mode 100644 clients/ui/frontend/src/app/AppContext.ts create mode 100644 clients/ui/frontend/src/app/useSettings.tsx create mode 100644 clients/ui/frontend/src/app/useTimeBasedRefresh.ts create mode 100644 clients/ui/frontend/src/app/useUser.tsx create mode 100644 clients/ui/frontend/src/components/browserStorage/BrowserStorageContext.tsx create mode 100644 clients/ui/frontend/src/components/browserStorage/index.ts create mode 100644 clients/ui/frontend/src/types.ts create mode 100644 clients/ui/frontend/src/utilities/const.ts create mode 100644 clients/ui/frontend/src/utilities/useDeepCompareMemoize.ts create mode 100644 clients/ui/frontend/src/utilities/useEventListener.ts diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index 609d8d9b0..363ead644 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -12,6 +12,7 @@ "@patternfly/react-core": "6.0.0-alpha.102", "@patternfly/react-icons": "6.0.0-alpha.35", "@patternfly/react-styles": "6.0.0-alpha.35", + "lodash-es": "^4.17.21", "npm-run-all": "^4.1.5", "react": "^18", "react-dom": "^18" @@ -3290,13 +3291,14 @@ } }, "node_modules/@patternfly/react-core": { - "version": "6.0.0-alpha.102", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0-alpha.102.tgz", - "integrity": "sha512-NjnLhgYwJ3LuA3/DDwzM10X9dlZyR9ICAKOXI2FlxRM0kTAnAK0kLx7MuQjZ7wboIjxpRaA3lG8c9zlHDrCWdQ==", - "dependencies": { - "@patternfly/react-icons": "^6.0.0-alpha.36", - "@patternfly/react-styles": "^6.0.0-alpha.35", - "@patternfly/react-tokens": "^6.0.0-alpha.35", + "version": "6.0.0-alpha.68", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0-alpha.68.tgz", + "integrity": "sha512-YhZY4xuDF0WJyDAAzHdvhgYCECs5bY4+QYUbkPe+dGLNLnwVMU3ZwZlLD9ARHRwfXDRV3g+ttS8HRbigbHgtpQ==", + "license": "MIT", + "dependencies": { + "@patternfly/react-icons": "^6.0.0-alpha.24", + "@patternfly/react-styles": "^6.0.0-alpha.24", + "@patternfly/react-tokens": "^6.0.0-alpha.24", "focus-trap": "7.5.4", "react-dropzone": "^14.2.3", "tslib": "^2.6.2" @@ -3316,18 +3318,20 @@ } }, "node_modules/@patternfly/react-icons": { - "version": "6.0.0-alpha.35", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0-alpha.35.tgz", - "integrity": "sha512-3gdFXGME/BVUW647W6wt3w+P95/l5zrK/EtLQ59Gx11faOlClwGniqloxRC5Cv+yo8kPpIFezjiZiia4vGGO7w==", + "version": "6.0.0-alpha.34", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0-alpha.34.tgz", + "integrity": "sha512-YApbSfIEevWhTila9OzG6RHmbu/f+08XCUiNjTri2ZY54flvX/+GZDdt6gLzS+JQlysA6MCtzRxLgWAKh27Uew==", + "license": "MIT", "peerDependencies": { "react": "^17 || ^18", "react-dom": "^17 || ^18" } }, "node_modules/@patternfly/react-styles": { - "version": "6.0.0-alpha.35", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.0.0-alpha.35.tgz", - "integrity": "sha512-9ddQpDJ1CXDbsuV5lYmynw8hqGncKXxnhNwvUKc+s/i50pNBAMmNO9CP5dkKhnZPcjHQj0A35aleQ7xdRgNWQw==" + "version": "6.0.0-alpha.34", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.0.0-alpha.34.tgz", + "integrity": "sha512-mJSpYrgI/Sgu+gwSv5h8ZP+prvzNUbfEkWP+LV6hZuw70N0UnarJacTsMD0zouCWUwIVt1NOJfawLOVmzAsxbA==", + "license": "MIT" }, "node_modules/@patternfly/react-tokens": { "version": "6.0.0-alpha.35", @@ -13848,6 +13852,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index 7a902c75e..60231d683 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -86,6 +86,7 @@ "@patternfly/react-core": "6.0.0-alpha.102", "@patternfly/react-icons": "6.0.0-alpha.35", "@patternfly/react-styles": "6.0.0-alpha.35", + "lodash-es": "^4.17.21", "npm-run-all": "^4.1.5", "react": "^18", "react-dom": "^18" diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 2183e990a..f2591537d 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -3,22 +3,88 @@ import '@patternfly/react-core/dist/styles/base.css'; import AppRoutes from './AppRoutes'; import './app.css'; import { + Alert, + Bullseye, + Button, Flex, Masthead, MastheadContent, MastheadToggle, Page, + PageSection, PageToggleButton, - Title -} from '@patternfly/react-core'; -import NavSidebar from './NavSidebar'; -import { BarsIcon } from '@patternfly/react-icons'; + Spinner, + Stack, + StackItem, + Title, +} from "@patternfly/react-core"; +import NavSidebar from "./NavSidebar"; +import { BarsIcon } from "@patternfly/react-icons"; +import { AppContext } from "./AppContext"; +import { useSettings } from "./useSettings"; const App: React.FC = () => { + const { + configSettings, + userSettings, + loaded: configLoaded, + loadError: configError, + } = useSettings(); + + const contextValue = React.useMemo( + () => + configSettings && userSettings + ? { + config: configSettings!, + user: userSettings!, + } + : null, + [configSettings, userSettings] + ); + + // We lack the critical data to startup the app + if (configError) { + // There was an error fetching critical data + return ( + + + + + +

+ {configError.message || + "Unknown error occurred during startup."} +

+

Logging out and logging back in may solve the issue.

+
+
+ + + +
+
+
+ ); + } + + // Waiting on the API to finish + const loading = !configLoaded || !userSettings || !configSettings || !contextValue; + const masthead = ( - + @@ -33,15 +99,21 @@ const App: React.FC = () => { ); - return ( - } - > - - + return loading ? ( + + + + ) : ( + + } + > + + + ); }; diff --git a/clients/ui/frontend/src/app/AppContext.ts b/clients/ui/frontend/src/app/AppContext.ts new file mode 100644 index 000000000..d1258d74b --- /dev/null +++ b/clients/ui/frontend/src/app/AppContext.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { UserSettings, ConfigSettings } from '~/types'; + + +type AppContextProps = { + config: ConfigSettings; + user: UserSettings; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const AppContext = React.createContext({} as AppContextProps); + +export const useAppContext = (): AppContextProps => React.useContext(AppContext); diff --git a/clients/ui/frontend/src/app/useSettings.tsx b/clients/ui/frontend/src/app/useSettings.tsx new file mode 100644 index 000000000..20470c3d2 --- /dev/null +++ b/clients/ui/frontend/src/app/useSettings.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; +import useTimeBasedRefresh from "./useTimeBasedRefresh"; +import { ConfigSettings, PlatformType, UserSettings } from "../types"; +import { POLL_INTERVAL } from "~/utilities/const"; +import { useDeepCompareMemoize } from "../utilities/useDeepCompareMemoize"; + +export const useSettings = (): { + configSettings: ConfigSettings | null; + userSettings: UserSettings | null; + loaded: boolean; + loadError: Error | undefined; +} => { + const [loaded, setLoaded] = React.useState(false); + const [loadError, setLoadError] = React.useState(); + const [config, setConfig] = React.useState(null); + const [user, setUser] = React.useState(null); + const setRefreshMarker = useTimeBasedRefresh(); + + React.useEffect(() => { + let watchHandle: ReturnType; + let cancelled = false; + const watchConfig = () => { + Promise.all([fetchConfig(), fetchUser()]) + .then(([config, user]) => { + if (cancelled) { + return; + } + setConfig(config); + setUser(user); + setLoaded(true); + setLoadError(undefined); + }) + .catch((e) => { + if (e?.message?.includes("Error getting Oauth Info for user")) { + // NOTE: this endpoint only requests oauth because of the security layer, this is not an ironclad use-case + // Something went wrong on the server with the Oauth, let us just log them out and refresh for them + /* eslint-disable-next-line no-console */ + console.error( + "Something went wrong with the oauth token, please log out...", + e.message, + e + ); + setRefreshMarker(new Date()); + return; + } + setLoadError(e); + }); + watchHandle = setTimeout(watchConfig, POLL_INTERVAL); + }; + watchConfig(); + + return () => { + cancelled = true; + clearTimeout(watchHandle); + }; + }, [setRefreshMarker]); + + const retConfig = useDeepCompareMemoize(config); + const retUser = useDeepCompareMemoize(user); + + return { configSettings: retConfig, userSettings: retUser, loaded, loadError }; +}; + +// Mock a settings config call +// TODO: replace with thea actual call once we have the endpoint +export const fetchConfig = async (): Promise => { + return { + platform: PlatformType.KUBEFLOW, + common: { + featureFlags: { + modelRegistry: true, + }, + }, + }; +}; + +// Mock a settings user call +// TODO: replace with thea actual call once we have the endpoint +export const fetchUser = async (): Promise => { + return { + username: "admin", + isAdmin: true, + isAllowed: true, + }; +}; diff --git a/clients/ui/frontend/src/app/useTimeBasedRefresh.ts b/clients/ui/frontend/src/app/useTimeBasedRefresh.ts new file mode 100644 index 000000000..6edc4612c --- /dev/null +++ b/clients/ui/frontend/src/app/useTimeBasedRefresh.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { useBrowserStorage } from '~/components/browserStorage'; + + +export type SetTime = (refreshDateMarker: Date) => void; + +const useTimeBasedRefresh = (): SetTime => { + const KEY_NAME = 'kf.dashboard.last.auto.refresh'; + const [lastRefreshTimestamp, setLastRefreshTimestamp] = useBrowserStorage( + KEY_NAME, + '0', + false, + true, + ); + const ref = React.useRef<{ + lastRefreshTimestamp: string; + setLastRefreshTimestamp: (newValue: string) => void; + }>({ lastRefreshTimestamp, setLastRefreshTimestamp }); + ref.current = { lastRefreshTimestamp, setLastRefreshTimestamp }; + + return React.useCallback((refreshDateMarker) => { + // Intentionally avoid referential changes. We want the value at call time. + // Recomputing the ref is not needed and will impact usage in hooks if it does. + const lastDate = new Date(ref.current.lastRefreshTimestamp); + const setNewDateString = ref.current.setLastRefreshTimestamp; + + /* eslint-disable no-console */ + // Print into the console in case we are not refreshing or the browser has preserve log enabled + console.warn('Attempting to re-trigger an auto refresh'); + console.log('Last refresh was on:', lastDate); + console.log('Refreshing requested after:', refreshDateMarker); + + lastDate.setHours(lastDate.getHours() + 1); + if (lastDate < refreshDateMarker) { + setNewDateString(refreshDateMarker.toString()); + console.log('Logging out and refreshing'); + // TODO: Replace with actual logout function + //logout().then(() => window.location.reload()); + } else { + console.error( + `We should have refreshed but it appears the last time we auto-refreshed was less than an hour ago. '${KEY_NAME}' session storage setting can be cleared for this to refresh again within the hour from the last refresh.`, + ); + } + /* eslint-enable no-console */ + }, []); +}; + +export default useTimeBasedRefresh; diff --git a/clients/ui/frontend/src/app/useUser.tsx b/clients/ui/frontend/src/app/useUser.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/clients/ui/frontend/src/components/browserStorage/BrowserStorageContext.tsx b/clients/ui/frontend/src/components/browserStorage/BrowserStorageContext.tsx new file mode 100644 index 000000000..b3b2e5b04 --- /dev/null +++ b/clients/ui/frontend/src/components/browserStorage/BrowserStorageContext.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; +import { useEventListener } from '~/utilities/useEventListener'; + + +type ValueMap = { [storageKey: string]: unknown }; +export type BrowserStorageContextType = { + /** Based on parseJSON it can be any jsonify-able item */ + getValue: (storageKey: string, parseJSON: boolean, isSessionStorage?: boolean) => unknown; + /** Returns a boolean if it was able to json-ify it. */ + setJSONValue: (storageKey: string, value: unknown, isSessionStorage?: boolean) => boolean; + setStringValue: (storageKey: string, value: string, isSessionStorage?: boolean) => void; +}; + +const BrowserStorageContext = React.createContext({ + getValue: () => null, + setJSONValue: () => false, + setStringValue: () => undefined, +}); + +/** + * @returns {boolean} if it was successful, false if it was not + */ +export type SetBrowserStorageHook = (value: T) => boolean; + +/** + * useBrowserStorage will handle all the effort behind managing localStorage or sessionStorage. + */ +export const useBrowserStorage = ( + storageKey: string, + defaultValue: T, + jsonify = true, + isSessionStorage = false, +): [T, SetBrowserStorageHook] => { + const { getValue, setJSONValue, setStringValue } = React.useContext(BrowserStorageContext); + + const setValue = React.useCallback>( + (value) => { + if (jsonify) { + return setJSONValue(storageKey, value, isSessionStorage); + } + if (typeof value === 'string') { + setStringValue(storageKey, value, isSessionStorage); + return true; + } + /* eslint-disable-next-line no-console */ + console.error('Was not a string value provided, cannot stringify'); + return false; + }, + [isSessionStorage, jsonify, setJSONValue, setStringValue, storageKey], + ); + + const value = useDeepCompareMemoize( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (getValue(storageKey, jsonify, isSessionStorage) as T) ?? defaultValue, + ); + return [value, setValue]; +}; + +type BrowserStorageContextProviderProps = { + children: React.ReactNode; +}; + +const getStorage = (isSessionStorage: boolean): Storage => { + if (isSessionStorage) { + return sessionStorage; + } + + return localStorage; +}; + +/** + * @see useBrowserStorage + */ +export const BrowserStorageContextProvider: React.FC = ({ + children, +}) => { + const [values, setValues] = React.useState({}); + + /** + * Only listen to other storage changes (windows/tabs) -- which are localStorage. + * Session storage does not have cross instance storages. + * See MDN for more: https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage + */ + useEventListener(window, 'storage', () => { + // Another browser tab has updated storage, sync up the data + const keys = Object.keys(values); + setValues( + keys.reduce((acc, key) => { + const value = localStorage.getItem(key); + return { ...acc, [key]: value }; + }, {}), + ); + }); + + const getValue = React.useCallback( + (key, parseJSON, isSessionStorage = false) => { + const value = getStorage(isSessionStorage).getItem(key); + if (value === null) { + return value; + } + + if (parseJSON) { + try { + return JSON.parse(value); + } catch (e) { + /* eslint-disable-next-line no-console */ + console.warn(`Failed to parse storage value "${key}"`); + return null; + } + } else { + return value; + } + }, + [], + ); + + const setJSONValue = React.useCallback( + (storageKey, value, isSessionStorage = false) => { + try { + const storageValue = JSON.stringify(value); + getStorage(isSessionStorage).setItem(storageKey, storageValue); + setValues((oldValues) => ({ ...oldValues, [storageKey]: storageValue })); + + return true; + } catch (e) { + /* eslint-disable-next-line no-console */ + console.warn( + 'Could not store a value because it was requested to be stringified but was an invalid value for stringification.', + ); + return false; + } + }, + [], + ); + const setStringValue = React.useCallback( + (storageKey, value, isSessionStorage = false) => { + getStorage(isSessionStorage).setItem(storageKey, value); + setValues((oldValues) => ({ ...oldValues, [storageKey]: value })); + }, + [], + ); + + const contextValue = React.useMemo( + () => ({ getValue, setJSONValue, setStringValue }), + // Also trigger a context update if `values` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [getValue, setJSONValue, setStringValue, values], + ); + + return ( + {children} + ); +}; diff --git a/clients/ui/frontend/src/components/browserStorage/index.ts b/clients/ui/frontend/src/components/browserStorage/index.ts new file mode 100644 index 000000000..d2c825ef5 --- /dev/null +++ b/clients/ui/frontend/src/components/browserStorage/index.ts @@ -0,0 +1 @@ +export { useBrowserStorage } from './BrowserStorageContext'; diff --git a/clients/ui/frontend/src/index.tsx b/clients/ui/frontend/src/index.tsx index d889f9866..1fd2399af 100644 --- a/clients/ui/frontend/src/index.tsx +++ b/clients/ui/frontend/src/index.tsx @@ -2,25 +2,15 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router-dom'; import App from './app/App'; +import { BrowserStorageContextProvider } from "./components/browserStorage/BrowserStorageContext"; -if (process.env.NODE_ENV !== 'production') { - const config = { - rules: [ - { - id: 'color-contrast', - enabled: false - } - ] - }; - // eslint-disable-next-line @typescript-eslint/no-var-requires, no-undef -} - -const root = ReactDOM.createRoot(document.getElementById('root') as Element); - +const root = ReactDOM.createRoot(document.getElementById("root") as Element); root.render( - + + + ); diff --git a/clients/ui/frontend/src/types.ts b/clients/ui/frontend/src/types.ts new file mode 100644 index 000000000..6a6dee99c --- /dev/null +++ b/clients/ui/frontend/src/types.ts @@ -0,0 +1,29 @@ + +// TODO: Get the status config params +export type UserSettings = { + username: string; + isAdmin: boolean; + isAllowed: boolean; +}; + +// TODO: Add more config parameters +export type ConfigSettings = { + platform: PlatformType; + common: CommonConfig; +}; + +// TODO: Add more config parameters +export type CommonConfig = { + featureFlags: FeatureFlag; +}; + +// TODO: Add more config parameters +export type FeatureFlag = { + modelRegistry: boolean; +}; + +export enum PlatformType { + ODH = 'odh', + RHOAI = 'rhoai', + KUBEFLOW = 'kubeflow', +} \ No newline at end of file diff --git a/clients/ui/frontend/src/utilities/const.ts b/clients/ui/frontend/src/utilities/const.ts new file mode 100644 index 000000000..919bdd771 --- /dev/null +++ b/clients/ui/frontend/src/utilities/const.ts @@ -0,0 +1,5 @@ + +// TODO: Fetch the .env variable here. +const POLL_INTERVAL = 30000; + +export { POLL_INTERVAL }; \ No newline at end of file diff --git a/clients/ui/frontend/src/utilities/useDeepCompareMemoize.ts b/clients/ui/frontend/src/utilities/useDeepCompareMemoize.ts new file mode 100644 index 000000000..b034759d1 --- /dev/null +++ b/clients/ui/frontend/src/utilities/useDeepCompareMemoize.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import * as _ from 'lodash-es'; + +export const useDeepCompareMemoize = (value: T): T => { + const ref = React.useRef(value); + + if (!_.isEqual(value, ref.current)) { + ref.current = value; + } + + return ref.current; +}; diff --git a/clients/ui/frontend/src/utilities/useEventListener.ts b/clients/ui/frontend/src/utilities/useEventListener.ts new file mode 100644 index 000000000..c368d4530 --- /dev/null +++ b/clients/ui/frontend/src/utilities/useEventListener.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; + +export const useEventListener = ( + eventTarget: EventTarget, + event: keyof WindowEventMap, + cb: EventListener, +): void => { + React.useEffect(() => { + eventTarget.addEventListener(event, cb); + return () => { + eventTarget.removeEventListener(event, cb); + }; + }, [cb, event, eventTarget]); +};