mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: usage on context fields in list (#3906)
This commit is contained in:
parent
6af72325c1
commit
0efaa346c4
@ -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(
|
||||
<UIProviderContainer>
|
||||
<ContextList />
|
||||
</UIProviderContainer>
|
||||
);
|
||||
|
||||
await screen.findByText('2 feature toggles');
|
||||
await screen.findByText('3 projects');
|
||||
});
|
@ -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<string>();
|
||||
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) => (
|
||||
<UsedInCell original={original} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
@ -104,7 +128,7 @@ const ContextList: VFC = () => {
|
||||
sortType: 'number',
|
||||
},
|
||||
],
|
||||
[]
|
||||
[uiConfig.flags.segmentContextFieldUsage]
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
32
frontend/src/component/context/ContextList/UsedInCell.tsx
Normal file
32
frontend/src/component/context/ContextList/UsedInCell.tsx
Normal file
@ -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<IUsedInCellProps> = ({ original }) => {
|
||||
const projectText = original.usedInProjects === 1 ? 'project' : 'projects';
|
||||
const togglesText = original.usedInFeatures === 1 ? 'toggle' : 'toggles';
|
||||
return (
|
||||
<TextCell
|
||||
sx={{
|
||||
color:
|
||||
original.usedInProjects === 0 &&
|
||||
original.usedInFeatures === 0
|
||||
? theme.palette.text.disabled
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{original.usedInProjects} {projectText}
|
||||
</Box>
|
||||
<Box>
|
||||
{original.usedInFeatures} feature {togglesText}
|
||||
</Box>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
@ -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) => (
|
||||
<TextCell
|
||||
sx={{
|
||||
color:
|
||||
original.usedInProjects === 0 &&
|
||||
original.usedInFeatures === 0
|
||||
? theme.palette.text.disabled
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Box>{original.usedInProjects} projects</Box>
|
||||
<Box>{original.usedInFeatures} feature toggles</Box>
|
||||
</TextCell>
|
||||
<UsedInCell original={original} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
@ -4,6 +4,8 @@ export interface IUnleashContextDefinition {
|
||||
createdAt: string;
|
||||
sortOrder: number;
|
||||
stickiness: boolean;
|
||||
usedInProjects?: number;
|
||||
usedInFeatures?: number;
|
||||
legalValues?: ILegalValue[];
|
||||
}
|
||||
|
||||
|
@ -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<ICreateContextField, 'updated_at'> {
|
||||
@ -68,18 +83,48 @@ class ContextFieldStore implements IContextFieldStore {
|
||||
}
|
||||
|
||||
async getAll(): Promise<IContextField[]> {
|
||||
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<IContextField> {
|
||||
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<void> {
|
||||
await this.db(TABLE).del();
|
||||
await this.db(T.contextFields).del();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
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<IContextField> {
|
||||
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<IContextField> {
|
||||
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<void> {
|
||||
return this.db(TABLE).where({ name }).del();
|
||||
return this.db(T.contextFields).where({ name }).del();
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.db(TABLE)
|
||||
return this.db(T.contextFields)
|
||||
.count('*')
|
||||
.then((res) => Number(res[0].count));
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -5,6 +5,8 @@ export interface IContextFieldDto {
|
||||
description?: string;
|
||||
stickiness?: boolean;
|
||||
sortOrder?: number;
|
||||
usedInProjects?: number;
|
||||
usedInFeatures?: number;
|
||||
legalValues?: ILegalValue[];
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user