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:
parent
c99b6b3abc
commit
464297d4be
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
@ -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}
|
||||
/>
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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);
|
||||
|
@ -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]);
|
||||
});
|
||||
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user