1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-08 01:15:49 +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 { useNavigate } from 'react-router';
@ -37,7 +38,7 @@ const StyledHomeButton = styled(Button)(({ theme }) => ({
top: 45,
}));
const NotFound = () => {
const NotFound: FC = ({ children }) => {
const navigate = useNavigate();
const onClickHome = () => {
@ -70,6 +71,7 @@ const NotFound = () => {
</StyledHomeButton>
</StyledButtonContainer>
</div>
{children}
</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 { 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 NotFound from 'component/common/NotFound/NotFound';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
@ -10,9 +12,13 @@ import Input from 'component/common/Input/Input';
import { FeatureTypeSchema } from 'openapi';
import { trim } from 'component/common/util';
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 = {
featureTypes: FeatureTypeSchema[];
featureType?: FeatureTypeSchema;
loading: boolean;
};
@ -31,24 +37,22 @@ const StyledForm = styled(Box)(() => ({
}));
export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
featureTypes,
featureType,
loading,
}) => {
const { featureTypeId } = useParams();
const navigate = useNavigate();
const featureType = featureTypes.find(
featureType => featureType.id === featureTypeId
);
const { uiConfig } = useUiConfig();
const { refetch } = useFeatureTypes();
const { updateFeatureTypeLifetime, loading: actionLoading } =
useFeatureTypeApi();
const [lifetime, setLifetime] = useState<number>(
featureType?.lifetimeDays || 0
);
const [doesntExpire, setDoesntExpire] = useState<boolean>(
!featureType?.lifetimeDays
);
if (!loading && !featureType) {
return <NotFound />;
}
const { setToastData, setToastApiError } = useToast();
const tracker = usePlausibleTracker();
const onChangeLifetime = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(trim(e.target.value), 10);
@ -68,14 +72,45 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
const isIncorrect =
!doesntExpire && (Number.isNaN(lifetime) || lifetime < 0);
const onSubmit: FormEventHandler = e => {
const onSubmit: FormEventHandler = async e => {
e.preventDefault();
if (isIncorrect) return;
try {
if (!featureType?.id)
throw new Error('No feature toggle type loaded');
const value = doesntExpire ? 0 : lifetime;
console.log('FIXME: onSubmit', value);
const value = doesntExpire ? 0 : lifetime;
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 (
<FormTemplate
modal
@ -87,7 +122,7 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
description={featureType?.description || ''}
documentationLink="https://docs.getunleash.io/reference/feature-toggle-types"
documentationLinkLabel="Feature toggle types documentation"
formatApiCode={() => 'FIXME: formatApiCode'}
formatApiCode={formatApiCode}
>
<StyledForm component="form" onSubmit={onSubmit}>
<Typography
@ -135,12 +170,13 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
checked={doesntExpire || lifetime === 0}
id="feature-toggle-expire"
onChange={onChangeDoesntExpire}
disabled={loading}
/>
<Box>doesn't expire</Box>
</Box>
<Input
autoFocus
disabled={doesntExpire}
disabled={doesntExpire || loading}
type="number"
label="Lifetime in days"
id="feature-toggle-lifetime"
@ -154,7 +190,7 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
variant="contained"
color="primary"
type="submit"
disabled={loading || isIncorrect}
disabled={loading || actionLoading}
>
Save feature toggle type
</PermissionButton>
@ -162,7 +198,6 @@ export const FeatureTypeForm: VFC<FeatureTypeFormProps> = ({
type="button"
color="primary"
onClick={() => navigate(GO_BACK)}
disabled={loading}
>
Cancel
</Button>

View File

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

View File

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