1
0
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:
Jaanus Sellin 2023-06-06 13:59:41 +03:00 committed by GitHub
parent 6af72325c1
commit 0efaa346c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 190 additions and 41 deletions

View File

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

View File

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

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

View File

@ -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';

View File

@ -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} />
),
},
]

View File

@ -4,6 +4,8 @@ export interface IUnleashContextDefinition {
createdAt: string;
sortOrder: number;
stickiness: boolean;
usedInProjects?: number;
usedInFeatures?: number;
legalValues?: ILegalValue[];
}

View File

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

View File

@ -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),

View File

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

View File

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

View File

@ -5,6 +5,8 @@ export interface IContextFieldDto {
description?: string;
stickiness?: boolean;
sortOrder?: number;
usedInProjects?: number;
usedInFeatures?: number;
legalValues?: ILegalValue[];
}

View File

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