diff --git a/frontend/package.json b/frontend/package.json index dfcb785e53..2c6e391665 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -127,5 +127,8 @@ "ignorePatterns": [ "cypress" ] + }, + "dependencies": { + "dequal": "^2.0.3" } } diff --git a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx index e05f212a43..748faa4b2c 100644 --- a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx +++ b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx @@ -5,7 +5,7 @@ interface IAnimateOnMountProps { mounted: boolean; enter: string; start: string; - leave: string; + leave?: string; container?: string; style?: React.CSSProperties; onStart?: () => void; @@ -39,7 +39,7 @@ const AnimateOnMount: FC = ({ if (!leave) { setShow(false); } - setStyles(leave); + setStyles(leave || ''); } } }, [mounted, enter, onStart, leave]); diff --git a/frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx b/frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx new file mode 100644 index 0000000000..80e4237e92 --- /dev/null +++ b/frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx @@ -0,0 +1,75 @@ +import { Typography, Button, useTheme, useMediaQuery } from '@mui/material'; +import EventDiff from 'component/events/EventDiff/EventDiff'; +import { useThemeStyles } from 'themes/themeStyles'; +import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount'; + +interface IStaleDataNotification { + refresh: () => void; + afterSubmitAction: () => void; + data: unknown; + cache: unknown; + show: boolean; +} + +export const StaleDataNotification = ({ + refresh, + show, + afterSubmitAction, + data, + cache, +}: IStaleDataNotification) => { + const { classes: themeStyles } = useThemeStyles(); + const theme = useTheme(); + const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const getStyles = () => { + const base = { + padding: `${theme.spacing(3)} ${theme.spacing(4)}`, + boxShadow: theme.boxShadows.elevated, + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + maxWidth: theme.spacing(75), + zIndex: theme.zIndex.mobileStepper, + }; + if (isExtraSmallScreen) { + return { + ...base, + right: 0, + left: 0, + bottom: 0, + borderRadius: 0, + }; + } + return base; + }; + + return ( + + + Your data is stale + + + The data you have been working on is stale, would you like to + refresh your data? This may happen if someone has been making + changes to the data while you were working. + + + + + ); +}; diff --git a/frontend/src/component/events/EventDiff/EventDiff.tsx b/frontend/src/component/events/EventDiff/EventDiff.tsx index 81f48258f3..47c51102ea 100644 --- a/frontend/src/component/events/EventDiff/EventDiff.tsx +++ b/frontend/src/component/events/EventDiff/EventDiff.tsx @@ -11,7 +11,7 @@ const DIFF_PREFIXES: Record = { }; interface IEventDiffProps { - entry: IEvent; + entry: Partial; } const EventDiff = ({ entry }: IEventDiffProps) => { diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx index 39072d93b8..628de7737c 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; @@ -24,6 +24,9 @@ import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmu import { useFormErrors } from 'hooks/useFormErrors'; import { createFeatureStrategy } from 'utils/createFeatureStrategy'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; +import { useCollaborateData } from 'hooks/useCollaborateData'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { IFeatureToggle } from 'interfaces/featureToggle'; export const FeatureStrategyCreate = () => { const projectId = useRequiredPathParam('projectId'); @@ -42,10 +45,30 @@ export const FeatureStrategyCreate = () => { const { unleashUrl } = uiConfig; const navigate = useNavigate(); - const { feature, refetchFeature } = useFeatureImmutable( - projectId, - featureId - ); + const { feature, refetchFeature } = useFeature(projectId, featureId); + const ref = useRef(feature); + + const { data, staleDataNotification, forceRefreshCache } = + useCollaborateData( + { + unleashGetter: useFeature, + params: [projectId, featureId], + dataKey: 'feature', + refetchFunctionKey: 'refetchFeature', + options: {}, + }, + feature, + { + afterSubmitAction: refetchFeature, + } + ); + + useEffect(() => { + if (ref.current.name === '' && feature.name) { + forceRefreshCache(feature); + ref.current = feature; + } + }, [feature]); useEffect(() => { if (strategyDefinition) { @@ -81,6 +104,8 @@ export const FeatureStrategyCreate = () => { } }; + if (!data) return null; + return ( { } > { permission={CREATE_FEATURE_STRATEGY} errors={errors} /> + {staleDataNotification} ); }; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx index 6a452c78a1..581ea6b584 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; @@ -18,10 +18,12 @@ import { ISegment } from 'interfaces/segment'; import { useSegmentsApi } from 'hooks/api/actions/useSegmentsApi/useSegmentsApi'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { formatStrategyName } from 'utils/strategyNames'; -import { useFeatureImmutable } from 'hooks/api/getters/useFeature/useFeatureImmutable'; import { useFormErrors } from 'hooks/useFormErrors'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; import { sortStrategyParameters } from 'utils/sortStrategyParameters'; +import { useCollaborateData } from 'hooks/useCollaborateData'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { IFeatureToggle } from 'interfaces/featureToggle'; export const FeatureStrategyEdit = () => { const projectId = useRequiredPathParam('projectId'); @@ -40,10 +42,31 @@ export const FeatureStrategyEdit = () => { const { unleashUrl } = uiConfig; const navigate = useNavigate(); - const { feature, refetchFeature } = useFeatureImmutable( - projectId, - featureId - ); + const { feature, refetchFeature } = useFeature(projectId, featureId); + + const ref = useRef(feature); + + const { data, staleDataNotification, forceRefreshCache } = + useCollaborateData( + { + unleashGetter: useFeature, + params: [projectId, featureId], + dataKey: 'feature', + refetchFunctionKey: 'refetchFeature', + options: {}, + }, + feature, + { + afterSubmitAction: refetchFeature, + } + ); + + useEffect(() => { + if (ref.current.name === '' && feature.name) { + forceRefreshCache(feature); + ref.current = feature; + } + }, [feature]); const { segments: savedStrategySegments, @@ -51,11 +74,11 @@ export const FeatureStrategyEdit = () => { } = useSegments(strategyId); useEffect(() => { - const savedStrategy = feature.environments + const savedStrategy = data?.environments .flatMap(environment => environment.strategies) .find(strategy => strategy.id === strategyId); setStrategy(prev => ({ ...prev, ...savedStrategy })); - }, [strategyId, feature]); + }, [strategyId, data]); useEffect(() => { // Fill in the selected segments once they've been fetched. @@ -96,6 +119,8 @@ export const FeatureStrategyEdit = () => { return null; } + if (!data) return null; + return ( { } > { permission={UPDATE_FEATURE_STRATEGY} errors={errors} /> + {staleDataNotification} ); }; diff --git a/frontend/src/hooks/useCollaborateData.tsx b/frontend/src/hooks/useCollaborateData.tsx new file mode 100644 index 0000000000..f69598ef11 --- /dev/null +++ b/frontend/src/hooks/useCollaborateData.tsx @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; +import { SWRConfiguration } from 'swr'; +import { dequal } from 'dequal'; +import { StaleDataNotification } from 'component/common/StaleDataNotification/StaleDataNotification'; + +interface IFormatUnleashGetterOutput { + data: Type; + refetch: () => void; +} + +const formatUnleashGetter = ({ + unleashGetter, + dataKey = '', + refetchFunctionKey = '', + options = {}, + params = [''], +}: IGetterOptions): IFormatUnleashGetterOutput => { + const result = unleashGetter(...params, { refreshInterval: 5, ...options }); + + return { data: result[dataKey], refetch: result[refetchFunctionKey] }; +}; + +interface IGetterOptions { + dataKey: string; + unleashGetter: any; + options: SWRConfiguration; + refetchFunctionKey: string; + params: string[]; +} + +interface ICollaborateDataOutput { + staleDataNotification: JSX.Element; + data: Type | null; + refetch: () => void; + forceRefreshCache: (data: Type) => void; +} + +interface IStaleNotificationOptions { + afterSubmitAction: () => void; +} + +export const useCollaborateData = ( + getterOptions: IGetterOptions, + initialData: Type, + notificationOptions: IStaleNotificationOptions +): ICollaborateDataOutput => { + const { data, refetch } = formatUnleashGetter(getterOptions); + const [cache, setCache] = useState(initialData || null); + const [dataModified, setDataModified] = useState(false); + + const forceRefreshCache = (data: Type) => { + setDataModified(false); + setCache(data); + }; + + useEffect(() => { + if (cache === null) { + setCache(initialData); + } + }, [initialData]); + + useEffect(() => { + const equal = dequal(data, cache); + + if (!equal) { + setDataModified(true); + } + }, [data]); + + return { + data: cache, + refetch, + staleDataNotification: ( + forceRefreshCache(data)} + show={dataModified} + afterSubmitAction={notificationOptions.afterSubmitAction} + /> + ), + forceRefreshCache, + }; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b452546748..6ff67e6bc3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3603,6 +3603,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" diff --git a/package.json b/package.json index 4bb88bf796..eaa7934959 100644 --- a/package.json +++ b/package.json @@ -130,11 +130,14 @@ "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", "unleash-client": "3.15.0", + "use-deep-compare-effect": "^1.8.1", "uuid": "^8.3.2" }, "devDependencies": { "@apidevtools/swagger-parser": "10.1.0", "@babel/core": "7.18.13", + "@swc/core": "1.2.246", + "@swc/jest": "0.2.22", "@types/bcryptjs": "2.4.2", "@types/cors": "2.8.12", "@types/express": "4.17.13", @@ -176,8 +179,6 @@ "source-map-support": "0.5.21", "superagent": "8.0.0", "supertest": "6.2.4", - "@swc/core": "1.2.246", - "@swc/jest": "0.2.22", "ts-node": "10.9.1", "tsc-watch": "5.0.3", "typescript": "4.8.2" diff --git a/yarn.lock b/yarn.lock index f45ff6ede1..387ac78b81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -522,6 +522,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" + integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.16.7": version "7.16.7" resolved "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz" @@ -2686,6 +2693,11 @@ depd@~2.0.0: resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +dequal@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + destroy@~1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz" @@ -7100,6 +7112,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-deep-compare-effect@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz#ef0ce3b3271edb801da1ec23bf0754ef4189d0c6" + integrity sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q== + dependencies: + "@babel/runtime" "^7.12.5" + dequal "^2.0.2" + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"