mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-11 00:08:30 +01:00
Feat/use collaborate data (#2067)
* feat: initial architecture * feat: add generic types * fix: refactor * feat: style notification * feat: remove useFeatureImmutable * fix: remove casting * fix: ensure data is present * fix: revive useFeatureImmutable * Update frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx Co-authored-by: Nuno Góis <github@nunogois.com> * Update frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx Co-authored-by: Nuno Góis <github@nunogois.com> * Update frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx Co-authored-by: Nuno Góis <github@nunogois.com> * Update frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx Co-authored-by: Nuno Góis <github@nunogois.com> * Update frontend/src/component/common/StaleDataNotification/StaleDataNotification.tsx Co-authored-by: Nuno Góis <github@nunogois.com> * fix: pr comments * fix: change order Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
parent
1cf42d6527
commit
54633500fd
@ -127,5 +127,8 @@
|
||||
"ignorePatterns": [
|
||||
"cypress"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
}
|
||||
|
@ -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<IAnimateOnMountProps> = ({
|
||||
if (!leave) {
|
||||
setShow(false);
|
||||
}
|
||||
setStyles(leave);
|
||||
setStyles(leave || '');
|
||||
}
|
||||
}
|
||||
}, [mounted, enter, onStart, leave]);
|
||||
|
@ -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 (
|
||||
<AnimateOnMount
|
||||
mounted={show}
|
||||
start={themeStyles.fadeInBottomStartWithoutFixed}
|
||||
enter={themeStyles.fadeInBottomEnter}
|
||||
style={getStyles()}
|
||||
>
|
||||
<Typography variant="h5" sx={{ my: 2, mb: 2 }}>
|
||||
Your data is stale
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ my: 2, mb: 3 }}>
|
||||
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.
|
||||
</Typography>
|
||||
<EventDiff entry={{ preData: cache, data }} />
|
||||
<Button
|
||||
sx={{ mb: 2 }}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
refresh();
|
||||
afterSubmitAction();
|
||||
}}
|
||||
>
|
||||
Refresh data
|
||||
</Button>
|
||||
</AnimateOnMount>
|
||||
);
|
||||
};
|
@ -11,7 +11,7 @@ const DIFF_PREFIXES: Record<string, string> = {
|
||||
};
|
||||
|
||||
interface IEventDiffProps {
|
||||
entry: IEvent;
|
||||
entry: Partial<IEvent>;
|
||||
}
|
||||
|
||||
const EventDiff = ({ entry }: IEventDiffProps) => {
|
||||
|
@ -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<IFeatureToggle>(feature);
|
||||
|
||||
const { data, staleDataNotification, forceRefreshCache } =
|
||||
useCollaborateData<IFeatureToggle>(
|
||||
{
|
||||
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 (
|
||||
<FormTemplate
|
||||
modal
|
||||
@ -99,7 +124,7 @@ export const FeatureStrategyCreate = () => {
|
||||
}
|
||||
>
|
||||
<FeatureStrategyForm
|
||||
feature={feature}
|
||||
feature={data}
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
segments={segments}
|
||||
@ -110,6 +135,7 @@ export const FeatureStrategyCreate = () => {
|
||||
permission={CREATE_FEATURE_STRATEGY}
|
||||
errors={errors}
|
||||
/>
|
||||
{staleDataNotification}
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
||||
|
@ -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<IFeatureToggle>(feature);
|
||||
|
||||
const { data, staleDataNotification, forceRefreshCache } =
|
||||
useCollaborateData<IFeatureToggle>(
|
||||
{
|
||||
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 (
|
||||
<FormTemplate
|
||||
modal
|
||||
@ -115,7 +140,7 @@ export const FeatureStrategyEdit = () => {
|
||||
}
|
||||
>
|
||||
<FeatureStrategyForm
|
||||
feature={feature}
|
||||
feature={data}
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
segments={segments}
|
||||
@ -126,6 +151,7 @@ export const FeatureStrategyEdit = () => {
|
||||
permission={UPDATE_FEATURE_STRATEGY}
|
||||
errors={errors}
|
||||
/>
|
||||
{staleDataNotification}
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
||||
|
84
frontend/src/hooks/useCollaborateData.tsx
Normal file
84
frontend/src/hooks/useCollaborateData.tsx
Normal file
@ -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<Type> {
|
||||
data: Type;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const formatUnleashGetter = <Type,>({
|
||||
unleashGetter,
|
||||
dataKey = '',
|
||||
refetchFunctionKey = '',
|
||||
options = {},
|
||||
params = [''],
|
||||
}: IGetterOptions): IFormatUnleashGetterOutput<Type> => {
|
||||
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<Type> {
|
||||
staleDataNotification: JSX.Element;
|
||||
data: Type | null;
|
||||
refetch: () => void;
|
||||
forceRefreshCache: (data: Type) => void;
|
||||
}
|
||||
|
||||
interface IStaleNotificationOptions {
|
||||
afterSubmitAction: () => void;
|
||||
}
|
||||
|
||||
export const useCollaborateData = <Type,>(
|
||||
getterOptions: IGetterOptions,
|
||||
initialData: Type,
|
||||
notificationOptions: IStaleNotificationOptions
|
||||
): ICollaborateDataOutput<Type> => {
|
||||
const { data, refetch } = formatUnleashGetter<Type>(getterOptions);
|
||||
const [cache, setCache] = useState<Type | null>(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: (
|
||||
<StaleDataNotification
|
||||
cache={cache}
|
||||
data={data}
|
||||
refresh={() => forceRefreshCache(data)}
|
||||
show={dataModified}
|
||||
afterSubmitAction={notificationOptions.afterSubmitAction}
|
||||
/>
|
||||
),
|
||||
forceRefreshCache,
|
||||
};
|
||||
};
|
@ -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"
|
||||
|
@ -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"
|
||||
|
20
yarn.lock
20
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"
|
||||
|
Loading…
Reference in New Issue
Block a user