From 0efaa346c45b1d8aea064c7649ad617c9da1f499 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Tue, 6 Jun 2023 13:59:41 +0300 Subject: [PATCH] feat: usage on context fields in list (#3906) --- .../AddContextButton.tsx | 0 .../ContextActionsCell.tsx | 0 .../ContextList/ContextList.test.tsx | 42 +++++++++++ .../{ => ContextList}/ContextList.tsx | 42 ++++++++--- .../context/ContextList/UsedInCell.tsx | 32 ++++++++ frontend/src/component/menu/routes.ts | 2 +- .../segments/SegmentTable/SegmentTable.tsx | 14 +--- frontend/src/interfaces/context.ts | 2 + src/lib/db/context-field-store.ts | 73 +++++++++++++++---- src/lib/db/index.ts | 6 +- .../createExportImportService.ts | 6 +- .../createFeatureToggleService.ts | 6 +- src/lib/types/stores/context-field-store.ts | 2 + src/test/e2e/api/admin/context.e2e.test.ts | 4 +- 14 files changed, 190 insertions(+), 41 deletions(-) rename frontend/src/component/context/ContextList/{AddContextButton => }/AddContextButton.tsx (100%) rename frontend/src/component/context/ContextList/{ContextActionsCell => }/ContextActionsCell.tsx (100%) create mode 100644 frontend/src/component/context/ContextList/ContextList/ContextList.test.tsx rename frontend/src/component/context/ContextList/{ => ContextList}/ContextList.tsx (86%) create mode 100644 frontend/src/component/context/ContextList/UsedInCell.tsx diff --git a/frontend/src/component/context/ContextList/AddContextButton/AddContextButton.tsx b/frontend/src/component/context/ContextList/AddContextButton.tsx similarity index 100% rename from frontend/src/component/context/ContextList/AddContextButton/AddContextButton.tsx rename to frontend/src/component/context/ContextList/AddContextButton.tsx diff --git a/frontend/src/component/context/ContextList/ContextActionsCell/ContextActionsCell.tsx b/frontend/src/component/context/ContextList/ContextActionsCell.tsx similarity index 100% rename from frontend/src/component/context/ContextList/ContextActionsCell/ContextActionsCell.tsx rename to frontend/src/component/context/ContextList/ContextActionsCell.tsx diff --git a/frontend/src/component/context/ContextList/ContextList/ContextList.test.tsx b/frontend/src/component/context/ContextList/ContextList/ContextList.test.tsx new file mode 100644 index 0000000000..24beb1cf89 --- /dev/null +++ b/frontend/src/component/context/ContextList/ContextList/ContextList.test.tsx @@ -0,0 +1,42 @@ +import { render } from '../../../../utils/testRenderer'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { testServerRoute, testServerSetup } from '../../../../utils/testServer'; +import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer'; +import ContextList from './ContextList'; + +const server = testServerSetup(); + +const setupRoutes = () => { + testServerRoute(server, 'api/admin/context', [ + { + description: 'Allows you to constrain on application name', + legalValues: [], + name: 'appName', + sortOrder: 2, + stickiness: false, + usedInProjects: 3, + usedInFeatures: 2, + createdAt: '2023-05-24T06:23:07.797Z', + }, + ]); + testServerRoute(server, '/api/admin/ui-config', { + flags: { + SE: true, + segmentContextFieldUsage: true, + }, + }); +}; + +test('should show the count of projects and features used in', async () => { + setupRoutes(); + + render( + + + + ); + + await screen.findByText('2 feature toggles'); + await screen.findByText('3 projects'); +}); diff --git a/frontend/src/component/context/ContextList/ContextList.tsx b/frontend/src/component/context/ContextList/ContextList/ContextList.tsx similarity index 86% rename from frontend/src/component/context/ContextList/ContextList.tsx rename to frontend/src/component/context/ContextList/ContextList/ContextList.tsx index d8d06a3226..0b525a91da 100644 --- a/frontend/src/component/context/ContextList/ContextList.tsx +++ b/frontend/src/component/context/ContextList/ContextList/ContextList.tsx @@ -16,20 +16,23 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon import useContextsApi from 'hooks/api/actions/useContextsApi/useContextsApi'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { AddContextButton } from './AddContextButton/AddContextButton'; +import { AddContextButton } from '../AddContextButton'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { sortTypes } from 'utils/sortTypes'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; -import { ContextActionsCell } from './ContextActionsCell/ContextActionsCell'; +import { ContextActionsCell } from '../ContextActionsCell'; import { Adjust } from '@mui/icons-material'; import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { Search } from 'component/common/Search/Search'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { UsedInCell } from '../UsedInCell'; const ContextList: VFC = () => { const [showDelDialogue, setShowDelDialogue] = useState(false); const [name, setName] = useState(); const { context, refetchUnleashContext, loading } = useUnleashContext(); const { removeContext } = useContextsApi(); + const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); const data = useMemo(() => { @@ -41,11 +44,21 @@ const ContextList: VFC = () => { } return context - .map(({ name, description, sortOrder }) => ({ - name, - description, - sortOrder, - })) + .map( + ({ + name, + description, + sortOrder, + usedInProjects, + usedInFeatures, + }) => ({ + name, + description, + sortOrder, + usedInProjects, + usedInFeatures, + }) + ) .sort((a, b) => a.sortOrder - b.sortOrder); }, [context, loading]); @@ -59,7 +72,7 @@ const ContextList: VFC = () => { { Header: 'Name', accessor: 'name', - width: '90%', + width: '70%', Cell: ({ row: { original: { name, description }, @@ -73,6 +86,17 @@ const ContextList: VFC = () => { ), sortType: 'alphanumeric', }, + ...(uiConfig.flags.segmentContextFieldUsage + ? [ + { + Header: 'Used in', + width: '60%', + Cell: ({ row: { original } }: any) => ( + + ), + }, + ] + : []), { Header: 'Actions', id: 'Actions', @@ -104,7 +128,7 @@ const ContextList: VFC = () => { sortType: 'number', }, ], - [] + [uiConfig.flags.segmentContextFieldUsage] ); const initialState = useMemo( diff --git a/frontend/src/component/context/ContextList/UsedInCell.tsx b/frontend/src/component/context/ContextList/UsedInCell.tsx new file mode 100644 index 0000000000..57da85dfde --- /dev/null +++ b/frontend/src/component/context/ContextList/UsedInCell.tsx @@ -0,0 +1,32 @@ +import { VFC } from 'react'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import theme from 'themes/theme'; +import { Box } from '@mui/material'; +import { IUnleashContextDefinition } from 'interfaces/context'; + +interface IUsedInCellProps { + original: IUnleashContextDefinition; +} + +export const UsedInCell: VFC = ({ original }) => { + const projectText = original.usedInProjects === 1 ? 'project' : 'projects'; + const togglesText = original.usedInFeatures === 1 ? 'toggle' : 'toggles'; + return ( + + + {original.usedInProjects} {projectText} + + + {original.usedInFeatures} feature {togglesText} + + + ); +}; diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 6cfb5de0cd..ed5a8ebc4a 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -20,7 +20,7 @@ import CreateFeature from 'component/feature/CreateFeature/CreateFeature'; import EditFeature from 'component/feature/EditFeature/EditFeature'; import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit'; import { ApplicationList } from 'component/application/ApplicationList/ApplicationList'; -import ContextList from 'component/context/ContextList/ContextList'; +import ContextList from 'component/context/ContextList/ContextList/ContextList'; import RedirectFeatureView from 'component/feature/RedirectFeatureView/RedirectFeatureView'; import { CreateAddon } from 'component/addons/CreateAddon/CreateAddon'; import { EditAddon } from 'component/addons/EditAddon/EditAddon'; diff --git a/frontend/src/component/segments/SegmentTable/SegmentTable.tsx b/frontend/src/component/segments/SegmentTable/SegmentTable.tsx index 1bedb3d998..1453f1340c 100644 --- a/frontend/src/component/segments/SegmentTable/SegmentTable.tsx +++ b/frontend/src/component/segments/SegmentTable/SegmentTable.tsx @@ -29,6 +29,7 @@ import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColum import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { UsedInCell } from 'component/context/ContextList/UsedInCell'; export const SegmentTable = () => { const projectId = useOptionalPathParam('projectId'); @@ -206,18 +207,7 @@ const getColumns = (segmentContextFieldUsage?: boolean) => [ Header: 'Used in', width: '60%', Cell: ({ row: { original } }: any) => ( - - {original.usedInProjects} projects - {original.usedInFeatures} feature toggles - + ), }, ] diff --git a/frontend/src/interfaces/context.ts b/frontend/src/interfaces/context.ts index 7b76312029..a84cf37ec3 100644 --- a/frontend/src/interfaces/context.ts +++ b/frontend/src/interfaces/context.ts @@ -4,6 +4,8 @@ export interface IUnleashContextDefinition { createdAt: string; sortOrder: number; stickiness: boolean; + usedInProjects?: number; + usedInFeatures?: number; legalValues?: ILegalValue[]; } diff --git a/src/lib/db/context-field-store.ts b/src/lib/db/context-field-store.ts index 92b620d782..40fdecfcca 100644 --- a/src/lib/db/context-field-store.ts +++ b/src/lib/db/context-field-store.ts @@ -7,6 +7,7 @@ import { ILegalValue, } from '../types/stores/context-field-store'; import NotFoundError from '../error/notfound-error'; +import { IFlagResolver } from '../types'; const COLUMNS = [ 'name', @@ -16,13 +17,18 @@ const COLUMNS = [ 'legal_values', 'created_at', ]; -const TABLE = 'context_fields'; +const T = { + contextFields: 'context_fields', + featureStrategies: 'feature_strategies', +}; type ContextFieldDB = { name: string; description: string; stickiness: boolean; sort_order: number; + used_in_projects?: number; + used_in_features?: number; legal_values: ILegalValue[]; created_at: Date; }; @@ -34,6 +40,8 @@ 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, }); interface ICreateContextField { @@ -50,11 +58,18 @@ class ContextFieldStore implements IContextFieldStore { private logger: Logger; - constructor(db: Db, getLogger: LogProvider) { + private flagResolver: IFlagResolver; + + constructor(db: Db, getLogger: LogProvider, flagResolver: IFlagResolver) { this.db = db; + this.flagResolver = flagResolver; this.logger = getLogger('context-field-store.ts'); } + prefixColumns(columns: string[] = COLUMNS): string[] { + return columns.map((c) => `${T.contextFields}.${c}`); + } + fieldToRow( data: IContextFieldDto, ): Omit { @@ -68,18 +83,48 @@ class ContextFieldStore implements IContextFieldStore { } async getAll(): Promise { - const rows = await this.db - .select(COLUMNS) - .from(TABLE) - .orderBy('name', 'asc'); + if (this.flagResolver.isEnabled('segmentContextFieldUsage')) { + const rows = await this.db + .select( + this.prefixColumns(), + 'used_in_projects', + 'used_in_features', + ) + .countDistinct( + `${T.featureStrategies}.project_name AS used_in_projects`, + ) + .countDistinct( + `${T.featureStrategies}.feature_name AS used_in_features`, + ) + .from(T.contextFields) + .joinRaw( + `LEFT JOIN ${T.featureStrategies} ON EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${T.featureStrategies}.constraints) AS elem + WHERE elem ->> 'contextName' = ${T.contextFields}.name + )`, + ) + .groupBy( + this.prefixColumns( + COLUMNS.filter((column) => column !== 'legal_values'), + ), + ) + .orderBy('name', 'asc'); + return rows.map(mapRow); + } else { + const rows = await this.db + .select(COLUMNS) + .from(T.contextFields) + .orderBy('name', 'asc'); - return rows.map(mapRow); + return rows.map(mapRow); + } } async get(key: string): Promise { const row = await this.db .first(COLUMNS) - .from(TABLE) + .from(T.contextFields) .where({ name: key }); if (!row) { throw new NotFoundError( @@ -90,14 +135,14 @@ class ContextFieldStore implements IContextFieldStore { } async deleteAll(): Promise { - await this.db(TABLE).del(); + await this.db(T.contextFields).del(); } destroy(): void {} async exists(key: string): Promise { const result = await this.db.raw( - `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`, + `SELECT EXISTS (SELECT 1 FROM ${T.contextFields} WHERE name = ?) AS present`, [key], ); const { present } = result.rows[0]; @@ -106,7 +151,7 @@ class ContextFieldStore implements IContextFieldStore { // TODO: write tests for the changes you made here? async create(contextField: IContextFieldDto): Promise { - const [row] = await this.db(TABLE) + const [row] = await this.db(T.contextFields) .insert(this.fieldToRow(contextField)) .returning('*'); @@ -114,7 +159,7 @@ class ContextFieldStore implements IContextFieldStore { } async update(data: IContextFieldDto): Promise { - const [row] = await this.db(TABLE) + const [row] = await this.db(T.contextFields) .where({ name: data.name }) .update(this.fieldToRow(data)) .returning('*'); @@ -123,11 +168,11 @@ class ContextFieldStore implements IContextFieldStore { } async delete(name: string): Promise { - return this.db(TABLE).where({ name }).del(); + return this.db(T.contextFields).where({ name }).del(); } async count(): Promise { - return this.db(TABLE) + return this.db(T.contextFields) .count('*') .then((res) => Number(res[0].count)); } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 046bcca2b0..c69bffd9c4 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -61,7 +61,11 @@ export const createStores = ( getLogger, config.flagResolver, ), - contextFieldStore: new ContextFieldStore(db, getLogger), + contextFieldStore: new ContextFieldStore( + db, + getLogger, + config.flagResolver, + ), settingStore: new SettingStore(db, getLogger), userStore: new UserStore(db, getLogger), accountStore: new AccountStore(db, getLogger), diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts index ce39eabb0c..a69fcb24cd 100644 --- a/src/lib/features/export-import-toggles/createExportImportService.ts +++ b/src/lib/features/export-import-toggles/createExportImportService.ts @@ -132,7 +132,11 @@ export const createExportImportTogglesService = ( ); const featureTagStore = new FeatureTagStore(db, eventBus, getLogger); const strategyStore = new StrategyStore(db, getLogger); - const contextFieldStore = new ContextFieldStore(db, getLogger); + const contextFieldStore = new ContextFieldStore( + db, + getLogger, + flagResolver, + ); const featureStrategiesStore = new FeatureStrategiesStore( db, eventBus, diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 98b2eb6060..77cad99bd7 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -74,7 +74,11 @@ export const createFeatureToggleService = ( getLogger, flagResolver, ); - const contextFieldStore = new ContextFieldStore(db, getLogger); + const contextFieldStore = new ContextFieldStore( + db, + getLogger, + flagResolver, + ); const groupStore = new GroupStore(db); const accountStore = new AccountStore(db, getLogger); const accessStore = new AccessStore(db, eventBus, getLogger); diff --git a/src/lib/types/stores/context-field-store.ts b/src/lib/types/stores/context-field-store.ts index 546cc934a2..10865fb151 100644 --- a/src/lib/types/stores/context-field-store.ts +++ b/src/lib/types/stores/context-field-store.ts @@ -5,6 +5,8 @@ export interface IContextFieldDto { description?: string; stickiness?: boolean; sortOrder?: number; + usedInProjects?: number; + usedInFeatures?: number; legalValues?: ILegalValue[]; } diff --git a/src/test/e2e/api/admin/context.e2e.test.ts b/src/test/e2e/api/admin/context.e2e.test.ts index b8bd9d316f..04417287b2 100644 --- a/src/test/e2e/api/admin/context.e2e.test.ts +++ b/src/test/e2e/api/admin/context.e2e.test.ts @@ -1,9 +1,9 @@ import dbInit from '../../helpers/database-init'; -import { setupApp } from '../../helpers/test-helper'; +import { IUnleashTest, setupApp } from '../../helpers/test-helper'; import getLogger from '../../../fixtures/no-logger'; let db; -let app; +let app: IUnleashTest; beforeAll(async () => { db = await dbInit('context_api_serial', getLogger);