1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-21 13:47:39 +02:00

feat: Feature type lifetime API integration (#4295)

## About the changes
API integration and tests.
This commit is contained in:
Tymoteusz Czech 2023-07-21 11:51:09 +02:00 committed by GitHub
parent c99b6b3abc
commit 464297d4be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 265 additions and 72 deletions

View File

@ -1,3 +1,4 @@
import { FC } from 'react';
import { Button, styled, Typography } from '@mui/material'; import { Button, styled, Typography } from '@mui/material';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@ -37,7 +38,7 @@ const StyledHomeButton = styled(Button)(({ theme }) => ({
top: 45, top: 45,
})); }));
const NotFound = () => { const NotFound: FC = ({ children }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const onClickHome = () => { const onClickHome = () => {
@ -70,6 +71,7 @@ const NotFound = () => {
</StyledHomeButton> </StyledHomeButton>
</StyledButtonContainer> </StyledButtonContainer>
</div> </div>
{children}
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -0,0 +1,21 @@
import { VFC } from 'react';
import { useParams } from 'react-router-dom';
import { FeatureTypeSchema } from 'openapi';
import { FeatureTypeForm } from './FeatureTypeForm/FeatureTypeForm';
type FeatureTypeEditProps = {
featureTypes: FeatureTypeSchema[];
loading: boolean;
};
export const FeatureTypeEdit: VFC<FeatureTypeEditProps> = ({
featureTypes,
loading,
}) => {
const { featureTypeId } = useParams();
const featureType = featureTypes.find(
featureType => featureType.id === featureTypeId
);
return <FeatureTypeForm featureType={featureType} loading={loading} />;
};

View File

@ -0,0 +1,92 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { FeatureTypeForm } from './FeatureTypeForm';
const mockFeatureType = {
id: '1',
name: 'Test',
description: 'Test',
lifetimeDays: 1,
};
describe('FeatureTypeForm', () => {
it('should render component', () => {
render(
<FeatureTypeForm featureType={mockFeatureType} loading={false} />
);
expect(screen.getByText('Edit toggle type: Test')).toBeInTheDocument();
expect(screen.getByText('Expected lifetime')).toBeInTheDocument();
});
it('should render 404 if feature type is not found', () => {
render(<FeatureTypeForm featureType={undefined} loading={false} />);
expect(screen.getByTestId('404_NOT_FOUND')).toBeInTheDocument();
});
it('should not enable inputs and submit button when loading', () => {
render(
<FeatureTypeForm featureType={mockFeatureType} loading={true} />
);
expect(screen.getByLabelText('Expected lifetime')).toBeDisabled();
expect(screen.getByLabelText("doesn't expire")).toBeDisabled();
expect(screen.getByText('Save feature toggle type')).toBeDisabled();
});
it('should check "doesn\'t expire" when lifetime is 0', () => {
render(
<FeatureTypeForm
featureType={{
...mockFeatureType,
lifetimeDays: 0,
}}
loading={false}
/>
);
const doesntExpire = screen.getByLabelText("doesn't expire");
expect(doesntExpire).toBeChecked();
expect(screen.getByLabelText('Expected lifetime')).toBeDisabled();
});
it('should disable lifetime input when "doesn\'t expire" is checked', () => {
render(
<FeatureTypeForm featureType={mockFeatureType} loading={false} />
);
const doesntExpire = screen.getByLabelText("doesn't expire");
const lifetime = screen.getByLabelText('Expected lifetime');
expect(lifetime).toBeEnabled();
doesntExpire.click();
expect(lifetime).toBeDisabled();
});
it('restores lifetime input when "doesn\'t expire" is unchecked', () => {
render(
<FeatureTypeForm
featureType={{
...mockFeatureType,
lifetimeDays: 7,
}}
loading={false}
/>
);
const doesntExpire = screen.getByLabelText("doesn't expire");
const lifetime = screen.getByLabelText('Expected lifetime');
doesntExpire.click();
expect(lifetime).toBeDisabled();
doesntExpire.click();
expect(lifetime).toBeEnabled();
expect(lifetime).toHaveValue(7);
});
it('should disable submit button when form is invalid', () => {
render(
<FeatureTypeForm
featureType={{
...mockFeatureType,
lifetimeDays: -6,
}}
loading={false}
/>
);
expect(screen.getByText('Save feature toggle type')).toBeDisabled();
});
});

View File

@ -1,6 +1,8 @@
import { type FormEventHandler, type VFC, useState } from 'react'; import { type FormEventHandler, type VFC, useState, useCallback } from 'react';
import { Box, Button, Typography, Checkbox, styled } from '@mui/material'; import { Box, Button, Typography, Checkbox, styled } from '@mui/material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useFeatureTypeApi } from 'hooks/api/actions/useFeatureTypeApi/useFeatureTypeApi';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import NotFound from 'component/common/NotFound/NotFound'; import NotFound from 'component/common/NotFound/NotFound';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
@ -10,9 +12,13 @@ import Input from 'component/common/Input/Input';
import { FeatureTypeSchema } from 'openapi'; import { FeatureTypeSchema } from 'openapi';
import { trim } from 'component/common/util'; import { trim } from 'component/common/util';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
type FeatureTypeFormProps = { type FeatureTypeFormProps = {
featureTypes: FeatureTypeSchema[]; featureType?: FeatureTypeSchema;
loading: boolean; loading: boolean;
}; };
@ -31,24 +37,22 @@ const StyledForm = styled(Box)(() => ({
})); }));
export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
featureTypes, featureType,
loading, loading,
}) => { }) => {
const { featureTypeId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const featureType = featureTypes.find( const { uiConfig } = useUiConfig();
featureType => featureType.id === featureTypeId const { refetch } = useFeatureTypes();
); const { updateFeatureTypeLifetime, loading: actionLoading } =
useFeatureTypeApi();
const [lifetime, setLifetime] = useState<number>( const [lifetime, setLifetime] = useState<number>(
featureType?.lifetimeDays || 0 featureType?.lifetimeDays || 0
); );
const [doesntExpire, setDoesntExpire] = useState<boolean>( const [doesntExpire, setDoesntExpire] = useState<boolean>(
!featureType?.lifetimeDays !featureType?.lifetimeDays
); );
const { setToastData, setToastApiError } = useToast();
if (!loading && !featureType) { const tracker = usePlausibleTracker();
return <NotFound />;
}
const onChangeLifetime = (e: React.ChangeEvent<HTMLInputElement>) => { const onChangeLifetime = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(trim(e.target.value), 10); const value = parseInt(trim(e.target.value), 10);
@ -68,14 +72,45 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
const isIncorrect = const isIncorrect =
!doesntExpire && (Number.isNaN(lifetime) || lifetime < 0); !doesntExpire && (Number.isNaN(lifetime) || lifetime < 0);
const onSubmit: FormEventHandler = e => { const onSubmit: FormEventHandler = async e => {
e.preventDefault(); e.preventDefault();
if (isIncorrect) return; try {
if (!featureType?.id)
throw new Error('No feature toggle type loaded');
const value = doesntExpire ? 0 : lifetime; const value = doesntExpire ? 0 : lifetime;
console.log('FIXME: onSubmit', value); await updateFeatureTypeLifetime(featureType?.id, value);
refetch();
setToastData({
title: 'Feature type updated',
type: 'success',
});
navigate('/feature-toggle-type');
tracker.trackEvent('feature-type-edit');
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}; };
const formatApiCode = useCallback(
() =>
[
`curl --location --request PUT '${uiConfig.unleashUrl}/api/admin/feature-types/${featureType?.id}/lifetime`,
"--header 'Authorization: INSERT_API_KEY'",
"--header 'Content-Type: application/json'",
'--data-raw \'{\n "lifetimeDays": 7\n}\'',
].join(' \\\n'),
[uiConfig, featureType?.id]
);
if (!loading && !featureType) {
return (
<NotFound>
<div data-testid="404_NOT_FOUND" />
</NotFound>
);
}
return ( return (
<FormTemplate <FormTemplate
modal modal
@ -87,7 +122,7 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
description={featureType?.description || ''} description={featureType?.description || ''}
documentationLink="https://docs.getunleash.io/reference/feature-toggle-types" documentationLink="https://docs.getunleash.io/reference/feature-toggle-types"
documentationLinkLabel="Feature toggle types documentation" documentationLinkLabel="Feature toggle types documentation"
formatApiCode={() => 'FIXME: formatApiCode'} formatApiCode={formatApiCode}
> >
<StyledForm component="form" onSubmit={onSubmit}> <StyledForm component="form" onSubmit={onSubmit}>
<Typography <Typography
@ -135,12 +170,13 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
checked={doesntExpire || lifetime === 0} checked={doesntExpire || lifetime === 0}
id="feature-toggle-expire" id="feature-toggle-expire"
onChange={onChangeDoesntExpire} onChange={onChangeDoesntExpire}
disabled={loading}
/> />
<Box>doesn't expire</Box> <Box>doesn't expire</Box>
</Box> </Box>
<Input <Input
autoFocus autoFocus
disabled={doesntExpire} disabled={doesntExpire || loading}
type="number" type="number"
label="Lifetime in days" label="Lifetime in days"
id="feature-toggle-lifetime" id="feature-toggle-lifetime"
@ -154,7 +190,7 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
variant="contained" variant="contained"
color="primary" color="primary"
type="submit" type="submit"
disabled={loading || isIncorrect} disabled={loading || actionLoading}
> >
Save feature toggle type Save feature toggle type
</PermissionButton> </PermissionButton>
@ -162,7 +198,6 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
type="button" type="button"
color="primary" color="primary"
onClick={() => navigate(GO_BACK)} onClick={() => navigate(GO_BACK)}
disabled={loading}
> >
Cancel Cancel
</Button> </Button>

View File

@ -5,7 +5,7 @@ import { sortTypes } from 'utils/sortTypes';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes'; import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Box, Typography, useMediaQuery, useTheme } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { import {
Table, Table,
TableBody, TableBody,
@ -20,16 +20,14 @@ import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { Edit } from '@mui/icons-material'; import { Edit } from '@mui/icons-material';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { FeatureTypeForm } from './FeatureTypeForm/FeatureTypeForm'; import { FeatureTypeEdit } from './FeatureTypeEdit/FeatureTypeEdit';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
const basePath = '/feature-toggle-type'; const basePath = '/feature-toggle-type';
export const FeatureTypesList = () => { export const FeatureTypesList = () => {
const { featureTypes, loading } = useFeatureTypes(); const { featureTypes, loading } = useFeatureTypes();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const columns = useMemo( const columns = useMemo(
@ -55,24 +53,21 @@ export const FeatureTypesList = () => {
{ {
Header: 'Name', Header: 'Name',
accessor: 'name', accessor: 'name',
minWidth: 125, width: '90%',
Cell: TextCell, Cell: ({
row: {
original: { name, description },
}, },
{ }: any) => {
Header: 'Description', return (
accessor: 'description', <LinkCell
width: '80%', data-loading
Cell: ({ value }: { value: string }) => ( title={name}
<Typography subtitle={description}
component="div" />
variant="body2" );
color="text.secondary" },
lineHeight={2} sortType: 'alphanumeric',
>
<TextCell lineClamp={1}>{value}</TextCell>
</Typography>
),
disableSortBy: true,
}, },
{ {
Header: 'Lifetime', Header: 'Lifetime',
@ -88,8 +83,8 @@ export const FeatureTypesList = () => {
return <TextCell>doesn't expire</TextCell>; return <TextCell>doesn't expire</TextCell>;
}, },
sortInverted: true,
minWidth: 150, minWidth: 150,
sortType: 'numericZeroLast',
}, },
{ {
Header: 'Actions', Header: 'Actions',
@ -133,33 +128,23 @@ export const FeatureTypesList = () => {
[loading, featureTypes] [loading, featureTypes]
); );
const { const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
getTableProps, useTable(
getTableBodyProps,
headerGroups,
rows,
prepareRow,
setHiddenColumns,
} = useTable(
{ {
columns: columns as any[], columns: columns as any[],
data, data,
sortTypes, sortTypes,
autoResetSortBy: false, autoResetSortBy: false,
disableSortRemove: true, disableSortRemove: true,
}, initialState: {
useSortBy sortBy: [
);
useConditionallyHiddenColumns(
[
{ {
condition: isSmallScreen, id: 'lifetimeDays',
columns: ['description'],
}, },
], ],
setHiddenColumns, },
columns },
useSortBy
); );
return ( return (
@ -204,7 +189,7 @@ export const FeatureTypesList = () => {
onClose={() => navigate(basePath)} onClose={() => navigate(basePath)}
open open
> >
<FeatureTypeForm <FeatureTypeEdit
featureTypes={featureTypes} featureTypes={featureTypes}
loading={loading} loading={loading}
/> />

View File

@ -0,0 +1,34 @@
import { FeatureTypeSchema, UpdateFeatureTypeLifetimeSchema } from 'openapi';
import useAPI from '../useApi/useApi';
export const useFeatureTypeApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const updateFeatureTypeLifetime = async (
featureTypeId: FeatureTypeSchema['id'],
lifetimeDays: UpdateFeatureTypeLifetimeSchema['lifetimeDays']
) => {
const payload: UpdateFeatureTypeLifetimeSchema = {
lifetimeDays,
};
const path = `api/admin/feature-types/${featureTypeId}/lifetime`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
await makeRequest(req.caller, req.id);
} catch (e) {
throw e;
}
};
return {
updateFeatureTypeLifetime,
errors,
loading,
};
};

View File

@ -40,7 +40,8 @@ export type CustomEvents =
| 'context-usage' | 'context-usage'
| 'segment-usage' | 'segment-usage'
| 'strategy-add' | 'strategy-add'
| 'playground'; | 'playground'
| 'feature-type-edit';
export const usePlausibleTracker = () => { export const usePlausibleTracker = () => {
const plausible = useContext(PlausibleContext); const plausible = useContext(PlausibleContext);

View File

@ -6,26 +6,31 @@ const data = [
id: 1, id: 1,
age: 42, age: 42,
bool: true, bool: true,
value: 0,
}, },
{ {
id: 2, id: 2,
age: 35, age: 35,
bool: false, bool: false,
value: 9999999,
}, },
{ {
id: 3, id: 3,
age: 25, age: 25,
bool: true, bool: true,
value: 3456,
}, },
{ {
id: 4, id: 4,
age: 32, age: 32,
bool: false, bool: false,
value: 3455,
}, },
{ {
id: 5, id: 5,
age: 18, age: 18,
bool: true, bool: true,
value: '49585',
}, },
].map(d => ({ values: d })) as unknown as Row<{ ].map(d => ({ values: d })) as unknown as Row<{
id: number; id: number;
@ -45,4 +50,10 @@ test('sortTypes', () => {
.sort((a, b) => sortTypes.alphanumeric(a, b, 'age')) .sort((a, b) => sortTypes.alphanumeric(a, b, 'age'))
.map(({ values: { age } }) => age) .map(({ values: { age } }) => age)
).toEqual([18, 25, 32, 35, 42]); ).toEqual([18, 25, 32, 35, 42]);
expect(
data
.sort((a, b) => sortTypes.numericZeroLast(a, b, 'value'))
.map(({ values: { value } }) => value)
).toEqual([3455, 3456, '49585', 9999999, 0]);
}); });

View File

@ -51,4 +51,16 @@ export const sortTypes = {
if (b === 'false') return 1; if (b === 'false') return 1;
return 0; return 0;
}, },
numericZeroLast: <D extends object>(
a: Row<D>,
b: Row<D>,
id: IdType<D>,
_desc?: boolean
) => {
let aVal =
parseInt(`${a?.values?.[id] || 0}`, 10) || Number.MAX_SAFE_INTEGER;
let bVal =
parseInt(`${b?.values?.[id] || 0}`, 10) || Number.MAX_SAFE_INTEGER;
return aVal - bVal;
},
}; };