diff --git a/frontend/src/component/common/NotFound/NotFound.tsx b/frontend/src/component/common/NotFound/NotFound.tsx index 663099594d..6661d7d3e5 100644 --- a/frontend/src/component/common/NotFound/NotFound.tsx +++ b/frontend/src/component/common/NotFound/NotFound.tsx @@ -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 = () => { + {children} ); }; diff --git a/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeEdit.tsx b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeEdit.tsx new file mode 100644 index 0000000000..2518b8c360 --- /dev/null +++ b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeEdit.tsx @@ -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 = ({ + featureTypes, + loading, +}) => { + const { featureTypeId } = useParams(); + const featureType = featureTypes.find( + featureType => featureType.id === featureTypeId + ); + + return ; +}; diff --git a/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.test.tsx b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.test.tsx new file mode 100644 index 0000000000..a41aec277f --- /dev/null +++ b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.test.tsx @@ -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( + + ); + 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(); + expect(screen.getByTestId('404_NOT_FOUND')).toBeInTheDocument(); + }); + + it('should not enable inputs and submit button when loading', () => { + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + expect(screen.getByText('Save feature toggle type')).toBeDisabled(); + }); +}); diff --git a/frontend/src/component/featureTypes/FeatureTypeForm/FeatureTypeForm.tsx b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.tsx similarity index 71% rename from frontend/src/component/featureTypes/FeatureTypeForm/FeatureTypeForm.tsx rename to frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.tsx index c10a707c5c..07e57ac1e1 100644 --- a/frontend/src/component/featureTypes/FeatureTypeForm/FeatureTypeForm.tsx +++ b/frontend/src/component/featureTypes/FeatureTypeEdit/FeatureTypeForm/FeatureTypeForm.tsx @@ -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 = ({ - 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( featureType?.lifetimeDays || 0 ); const [doesntExpire, setDoesntExpire] = useState( !featureType?.lifetimeDays ); - - if (!loading && !featureType) { - return ; - } + const { setToastData, setToastApiError } = useToast(); + const tracker = usePlausibleTracker(); const onChangeLifetime = (e: React.ChangeEvent) => { const value = parseInt(trim(e.target.value), 10); @@ -68,14 +72,45 @@ export const FeatureTypeForm: VFC = ({ 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 ( + +
+ + ); + } + return ( = ({ description={featureType?.description || ''} documentationLink="https://docs.getunleash.io/reference/feature-toggle-types" documentationLinkLabel="Feature toggle types documentation" - formatApiCode={() => 'FIXME: formatApiCode'} + formatApiCode={formatApiCode} > = ({ checked={doesntExpire || lifetime === 0} id="feature-toggle-expire" onChange={onChangeDoesntExpire} + disabled={loading} /> doesn't expire = ({ variant="contained" color="primary" type="submit" - disabled={loading || isIncorrect} + disabled={loading || actionLoading} > Save feature toggle type @@ -162,7 +198,6 @@ export const FeatureTypeForm: VFC = ({ type="button" color="primary" onClick={() => navigate(GO_BACK)} - disabled={loading} > Cancel diff --git a/frontend/src/component/featureTypes/FeatureTypesList.tsx b/frontend/src/component/featureTypes/FeatureTypesList.tsx index c6b7c5c812..6f6d0bb5b7 100644 --- a/frontend/src/component/featureTypes/FeatureTypesList.tsx +++ b/frontend/src/component/featureTypes/FeatureTypesList.tsx @@ -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 }) => ( - - {value} - - ), - disableSortBy: true, + width: '90%', + Cell: ({ + row: { + original: { name, description }, + }, + }: any) => { + return ( + + ); + }, + sortType: 'alphanumeric', }, { Header: 'Lifetime', @@ -88,8 +83,8 @@ export const FeatureTypesList = () => { return doesn't expire; }, - 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 ( { onClose={() => navigate(basePath)} open > - diff --git a/frontend/src/hooks/api/actions/useFeatureTypeApi/useFeatureTypeApi.ts b/frontend/src/hooks/api/actions/useFeatureTypeApi/useFeatureTypeApi.ts new file mode 100644 index 0000000000..ad0d4cd4ef --- /dev/null +++ b/frontend/src/hooks/api/actions/useFeatureTypeApi/useFeatureTypeApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/usePlausibleTracker.ts b/frontend/src/hooks/usePlausibleTracker.ts index 58742c7a2c..63095aff5a 100644 --- a/frontend/src/hooks/usePlausibleTracker.ts +++ b/frontend/src/hooks/usePlausibleTracker.ts @@ -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); diff --git a/frontend/src/utils/sortTypes.test.ts b/frontend/src/utils/sortTypes.test.ts index 461b501f2c..4819b2c538 100644 --- a/frontend/src/utils/sortTypes.test.ts +++ b/frontend/src/utils/sortTypes.test.ts @@ -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]); }); diff --git a/frontend/src/utils/sortTypes.ts b/frontend/src/utils/sortTypes.ts index 7e5fbfe9de..b91cba1468 100644 --- a/frontend/src/utils/sortTypes.ts +++ b/frontend/src/utils/sortTypes.ts @@ -51,4 +51,16 @@ export const sortTypes = { if (b === 'false') return 1; return 0; }, + numericZeroLast: ( + a: Row, + b: Row, + id: IdType, + _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; + }, };