1
0
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:
Fredrik Strand Oseberg 2022-09-16 15:23:08 +02:00 committed by GitHub
parent 1cf42d6527
commit 54633500fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 260 additions and 20 deletions

View File

@ -127,5 +127,8 @@
"ignorePatterns": [
"cypress"
]
},
"dependencies": {
"dequal": "^2.0.3"
}
}

View File

@ -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]);

View File

@ -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>
);
};

View File

@ -11,7 +11,7 @@ const DIFF_PREFIXES: Record<string, string> = {
};
interface IEventDiffProps {
entry: IEvent;
entry: Partial<IEvent>;
}
const EventDiff = ({ entry }: IEventDiffProps) => {

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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,
};
};

View File

@ -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"

View File

@ -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"

View File

@ -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"