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