From 9f0d94287ead73e7e52646c39da29e5ece084178 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 12 Jun 2023 10:55:58 +0300 Subject: [PATCH] feat: context field usage frontend (#3938) --- .../ContextFieldUsage.test.tsx | 55 ++++++++++++ .../ContextFieldUsage/ContextFieldUsage.tsx | 89 +++++++++++++++++++ .../context/ContextForm/ContextForm.tsx | 8 ++ .../useStrategiesByContext.ts | 39 ++++++++ src/lib/db/context-field-store.ts | 8 +- src/lib/db/segment-store.ts | 16 ++-- 6 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.test.tsx create mode 100644 frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.tsx create mode 100644 frontend/src/hooks/api/getters/useStrategiesByContext/useStrategiesByContext.ts diff --git a/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.test.tsx b/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.test.tsx new file mode 100644 index 00000000000..ad7b22c9a5c --- /dev/null +++ b/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.test.tsx @@ -0,0 +1,55 @@ +import { render } from '../../../utils/testRenderer'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { testServerRoute, testServerSetup } from '../../../utils/testServer'; +import { UIProviderContainer } from '../../providers/UIProvider/UIProviderContainer'; +import { ContextFieldUsage } from './ContextFieldUsage'; + +const server = testServerSetup(); +const contextFieldName = 'appName'; + +const setupRoutes = () => { + testServerRoute( + server, + `api/admin/context/${contextFieldName}/strategies`, + { + strategies: [ + { + id: '4b3ad603-4727-4782-bd61-efc530e37209', + projectId: 'faaa', + featureName: 'tests', + strategyName: 'flexibleRollout', + environment: 'development', + }, + ], + } + ); + testServerRoute(server, '/api/admin/ui-config', { + flags: { + segmentContextFieldUsage: true, + }, + }); + + testServerRoute(server, '/api/admin/projects', { + version: 1, + projects: [ + { + id: 'faaa', + }, + ], + }); +}; + +test('should show usage of context field', async () => { + setupRoutes(); + + const contextFieldName = 'appName'; + render( + + + + ); + + await screen.findByText('Usage of this context field:'); + await screen.findByText('tests (Gradual rollout)'); +}); diff --git a/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.tsx b/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.tsx new file mode 100644 index 00000000000..59f793ec1e6 --- /dev/null +++ b/frontend/src/component/context/ContextFieldUsage/ContextFieldUsage.tsx @@ -0,0 +1,89 @@ +import { Alert, styled } from '@mui/material'; +import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { Link } from 'react-router-dom'; +import { formatStrategyName } from 'utils/strategyNames'; +import { useStrategiesByContext } from 'hooks/api/getters/useStrategiesByContext/useStrategiesByContext'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; + +const StyledUl = styled('ul')(({ theme }) => ({ + listStyle: 'none', + paddingLeft: 0, +})); + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginTop: theme.spacing(1), +})); + +interface IContextFieldUsageProps { + contextName: string; +} + +export const ContextFieldUsage = ({ contextName }: IContextFieldUsageProps) => { + const { strategies } = useStrategiesByContext(contextName); + const { projects } = useProjects(); + + const projectsUsed = Array.from( + new Set( + strategies.map(({ projectId }) => projectId!).filter(Boolean) + ) + ); + + const projectList = ( + + {projectsUsed.map(projectId => ( +
  • + + {projects.find(({ id }) => id === projectId)?.name ?? + projectId} + +
      + {strategies + ?.filter( + strategy => strategy.projectId === projectId + ) + .map(strategy => ( +
    • + + {strategy.featureName!}{' '} + {formatStrategyNameParens(strategy)} + +
    • + ))} +
    +
  • + ))} +
    + ); + if (projectsUsed.length > 0) { + return ( + + Usage of this context field: + {projectList} + + ); + } + + return null; +}; + +const formatStrategyNameParens = (strategy: IFeatureStrategy): string => { + if (!strategy.strategyName) { + return ''; + } + + return `(${formatStrategyName(strategy.strategyName)})`; +}; diff --git a/frontend/src/component/context/ContextForm/ContextForm.tsx b/frontend/src/component/context/ContextForm/ContextForm.tsx index aa3b3a55c37..106a77c6ed9 100644 --- a/frontend/src/component/context/ContextForm/ContextForm.tsx +++ b/frontend/src/component/context/ContextForm/ContextForm.tsx @@ -13,6 +13,9 @@ import { Add } from '@mui/icons-material'; import { ILegalValue } from 'interfaces/context'; import { ContextFormChip } from 'component/context/ContectFormChip/ContextFormChip'; import { ContextFormChipList } from 'component/context/ContectFormChip/ContextFormChipList'; +import { ContextFieldUsage } from '../ContextFieldUsage/ContextFieldUsage'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; interface IContextForm { contextName: string; @@ -101,6 +104,7 @@ export const ContextForm: React.FC = ({ const [value, setValue] = useState(''); const [valueDesc, setValueDesc] = useState(''); const [valueFocused, setValueFocused] = useState(false); + const { uiConfig } = useUiConfig(); const isMissingValue = valueDesc.trim() && !value.trim(); @@ -263,6 +267,10 @@ export const ContextForm: React.FC = ({ /> {stickiness ? 'On' : 'Off'} + } + /> {children} diff --git a/frontend/src/hooks/api/getters/useStrategiesByContext/useStrategiesByContext.ts b/frontend/src/hooks/api/getters/useStrategiesByContext/useStrategiesByContext.ts new file mode 100644 index 00000000000..67eb1f04c83 --- /dev/null +++ b/frontend/src/hooks/api/getters/useStrategiesByContext/useStrategiesByContext.ts @@ -0,0 +1,39 @@ +import { mutate } from 'swr'; +import { useCallback } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; + +export interface IUseStrategiesByContextOutput { + strategies: IFeatureStrategy[]; + refetchUsedSegments: () => void; + loading: boolean; + error?: Error; +} + +export const useStrategiesByContext = ( + id?: string | number +): IUseStrategiesByContextOutput => { + const path = formatApiPath(`api/admin/context/${id}/strategies`); + const { data, error } = useConditionalSWR(id, [], path, () => + fetchUsedSegment(path) + ); + + const refetchUsedSegments = useCallback(() => { + mutate(path).catch(console.warn); + }, [path]); + + return { + strategies: data?.strategies || [], + refetchUsedSegments, + loading: !error && !data, + error, + }; +}; + +const fetchUsedSegment = (path: string) => { + return fetch(path, { method: 'GET' }) + .then(handleErrorResponses('Strategies by context')) + .then(res => res.json()); +}; diff --git a/src/lib/db/context-field-store.ts b/src/lib/db/context-field-store.ts index 40fdecfccaa..698c239f8db 100644 --- a/src/lib/db/context-field-store.ts +++ b/src/lib/db/context-field-store.ts @@ -40,8 +40,12 @@ const mapRow = (row: ContextFieldDB): IContextField => ({ sortOrder: row.sort_order, legalValues: row.legal_values || [], createdAt: row.created_at, - usedInProjects: row.used_in_projects ? Number(row.used_in_projects) : 0, - usedInFeatures: row.used_in_projects ? Number(row.used_in_features) : 0, + ...(row.used_in_projects && { + usedInProjects: Number(row.used_in_projects), + }), + ...(row.used_in_features && { + usedInFeatures: Number(row.used_in_features), + }), }); interface ICreateContextField { diff --git a/src/lib/db/segment-store.ts b/src/lib/db/segment-store.ts index aa2ae88e186..a44835cfd07 100644 --- a/src/lib/db/segment-store.ts +++ b/src/lib/db/segment-store.ts @@ -240,7 +240,7 @@ export default class SegmentStore implements ISegmentStore { throw new NotFoundError('No row'); } - const segment: ISegment = { + return { id: row.id, name: row.name, description: row.description, @@ -248,15 +248,13 @@ export default class SegmentStore implements ISegmentStore { constraints: row.constraints, createdBy: row.created_by, createdAt: row.created_at, - usedInProjects: row.used_in_projects - ? Number(row.used_in_projects) - : 0, - usedInFeatures: row.used_in_projects - ? Number(row.used_in_features) - : 0, + ...(row.used_in_projects && { + usedInProjects: Number(row.used_in_projects), + }), + ...(row.used_in_features && { + usedInFeatures: Number(row.used_in_features), + }), }; - - return segment; } destroy(): void {}