diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx new file mode 100644 index 0000000000..0faf8935fb --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.test.tsx @@ -0,0 +1,123 @@ +import { ArchiveTable } from 'component/archive/ArchiveTable/ArchiveTable'; +import { render } from 'utils/testRenderer'; +import { useState } from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import type { FeatureSearchResponseSchema } from 'openapi'; + +const mockedFeatures = [ + { + name: 'someFeature', + description: '', + type: 'release', + project: 'default', + stale: false, + createdAt: '2023-08-10T09:28:58.928Z', + lastSeenAt: null, + impressionData: false, + archivedAt: '2023-08-11T10:18:03.429Z', + }, + { + name: 'someOtherFeature', + description: '', + type: 'release', + project: 'default', + stale: false, + createdAt: '2023-08-10T09:28:58.928Z', + lastSeenAt: null, + impressionData: false, + archivedAt: '2023-08-11T10:18:03.429Z', + }, +] as FeatureSearchResponseSchema[]; + +const Component = () => { + const [storedParams, setStoredParams] = useState({}); + return ( + Promise.resolve({})} + loading={false} + setStoredParams={setStoredParams as any} + storedParams={storedParams as any} + /> + ); +}; + +const server = testServerSetup(); + +const setupApi = () => { + testServerRoute( + server, + '/api/admin/projects/default/revive', + {}, + 'post', + 200, + ); + + testServerRoute(server, '/api/admin/projects/default/overview', { + environment: 'Open Source', + }); + testServerRoute(server, '/api/admin/ui-config', { + archivedAt: null, + }); +}; + +test('should load the table', async () => { + render(, { permissions: [{ permission: UPDATE_FEATURE }] }); + expect(screen.getByRole('table')).toBeInTheDocument(); + + await screen.findByText('someFeature'); +}); + +test('should show confirm dialog when reviving flag', async () => { + setupApi(); + render( + <> + + + , + { permissions: [{ permission: UPDATE_FEATURE }] }, + ); + await screen.findByText('someFeature'); + + const reviveButton = screen.getAllByTestId( + 'revive-feature-flag-button', + )?.[0]; + fireEvent.click(reviveButton); + + await screen.findByText('Revive feature flag?'); + const reviveFlagsButton = screen.getByRole('button', { + name: /Revive feature flag/i, + }); + await waitFor(async () => { + expect(reviveFlagsButton).toBeEnabled(); + }); + fireEvent.click(reviveFlagsButton); + + await screen.findByText('Feature flags revived'); +}); + +test('should show info box when disableAllEnvsOnRevive flag is on', async () => { + setupApi(); + render( + <> + + + , + { permissions: [{ permission: UPDATE_FEATURE }] }, + ); + await screen.findByText('someFeature'); + + const reviveButton = screen.getAllByTestId( + 'revive-feature-flag-button', + )?.[0]; + fireEvent.click(reviveButton); + + await screen.findByText('Revive feature flag?'); + await screen.findByText( + 'Revived feature flags will be automatically disabled in all environments', + ); +}); diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx new file mode 100644 index 0000000000..d55c3d24b7 --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx @@ -0,0 +1,302 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { + type SortingRule, + useFlexLayout, + useRowSelect, + useSortBy, + useTable, +} from 'react-table'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useMediaQuery } from '@mui/material'; +import { sortTypes } from 'utils/sortTypes'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Search } from 'component/common/Search/Search'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell'; +import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; +import theme from 'themes/theme'; +import type { FeatureSearchResponseSchema } from 'openapi'; +import { useSearch } from 'hooks/useSearch'; +import { FeatureArchivedCell } from 'component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell'; +import { useSearchParams } from 'react-router-dom'; +import { ArchivedFeatureDeleteConfirm } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm'; +import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; +import { ArchivedFeatureReviveConfirm } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm'; + +export interface IFeaturesArchiveTableProps { + archivedFeatures: FeatureSearchResponseSchema[]; + title: string; + refetch: () => void; + loading: boolean; + storedParams: SortingRule; + setStoredParams: ( + newValue: + | SortingRule + | ((prev: SortingRule) => SortingRule), + ) => SortingRule; +} + +export const ArchiveTable = ({ + archivedFeatures = [], + loading, + refetch, + storedParams, + setStoredParams, + title, +}: IFeaturesArchiveTableProps) => { + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deletedFeature, setDeletedFeature] = useState(); + + const [reviveModalOpen, setReviveModalOpen] = useState(false); + const [revivedFeature, setRevivedFeature] = useState(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '', + ); + + const columns = useMemo( + () => [ + { + Header: 'Seen', + accessor: 'lastSeenAt', + Cell: ({ row: { original: feature } }: any) => { + return ; + }, + align: 'center', + maxWidth: 80, + }, + { + Header: 'Type', + accessor: 'type', + width: 85, + canSort: true, + Cell: FeatureTypeCell, + align: 'center', + }, + { + Header: 'Name', + accessor: 'name', + searchable: true, + minWidth: 100, + Cell: ({ value, row: { original } }: any) => ( + + ), + sortType: 'alphanumeric', + }, + { + Header: 'Created', + accessor: 'createdAt', + width: 150, + Cell: DateCell, + }, + { + Header: 'Archived', + accessor: 'archivedAt', + width: 150, + Cell: FeatureArchivedCell, + }, + { + Header: 'Project ID', + accessor: 'project', + sortType: 'alphanumeric', + filterName: 'project', + searchable: true, + maxWidth: 170, + Cell: ({ value }: any) => ( + + ), + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + maxWidth: 120, + canSort: false, + Cell: ({ row: { original: feature } }: any) => ( + { + setRevivedFeature(feature); + setReviveModalOpen(true); + }} + onDelete={() => { + setDeletedFeature(feature); + setDeleteModalOpen(true); + }} + /> + ), + }, + // Always hidden -- for search + { + accessor: 'description', + header: 'Description', + searchable: true, + }, + ], + [], + ); + + const { + data: searchedData, + getSearchText, + getSearchContext, + } = useSearch(columns, searchValue, archivedFeatures); + + const data = useMemo( + () => (loading ? featuresPlaceholder : searchedData), + [searchedData, loading], + ); + + const [initialState] = useState(() => ({ + sortBy: [ + { + id: searchParams.get('sort') || storedParams.id, + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns: ['description'], + selectedRowIds: {}, + })); + + const getRowId = useCallback((row: any) => row.name, []); + + const { + headerGroups, + rows, + state: { sortBy }, + prepareRow, + setHiddenColumns, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSelectedRows: false, + disableSortRemove: true, + autoResetSortBy: false, + getRowId, + }, + useFlexLayout, + useSortBy, + useRowSelect, + ); + + useConditionallyHiddenColumns( + [ + { + condition: isSmallScreen, + columns: ['type', 'createdAt'], + }, + { + condition: isMediumScreen, + columns: ['lastSeenAt', 'stale'], + }, + ], + setHiddenColumns, + columns, + ); + + useEffect(() => { + if (loading) { + return; + } + const tableState: Record = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); + }, [loading, sortBy, searchValue]); + + return ( + <> + + } + /> + } + > + + + + ( + 0} + show={ + + No feature flags found matching “ + {searchValue}” + + } + elseShow={ + + None of the feature flags were archived yet. + + } + /> + )} + /> + + + + + ); +}; diff --git a/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx new file mode 100644 index 0000000000..16f97c3e27 --- /dev/null +++ b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx @@ -0,0 +1,45 @@ +import type { FC } from 'react'; +import { TimeAgo } from 'component/common/TimeAgo/TimeAgo'; +import { Tooltip, Typography, useTheme } from '@mui/material'; +import { formatDateYMD } from 'utils/formatDate'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { useLocationSettings } from 'hooks/useLocationSettings'; + +interface IFeatureArchivedCellProps { + value?: string | Date | null; +} + +export const FeatureArchivedCell: FC = ({ + value: archivedAt, +}) => { + const { locationSettings } = useLocationSettings(); + const theme = useTheme(); + + if (!archivedAt) + return ( + + + not available + + + ); + + return ( + + + + + + + + ); +}; diff --git a/frontend/src/component/archive/FeaturesArchiveTable.tsx b/frontend/src/component/archive/FeaturesArchiveTable.tsx new file mode 100644 index 0000000000..a4a4cfcbb6 --- /dev/null +++ b/frontend/src/component/archive/FeaturesArchiveTable.tsx @@ -0,0 +1,34 @@ +import { ArchiveTable } from 'component/archive/ArchiveTable/ArchiveTable'; +import type { SortingRule } from 'react-table'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; + +const defaultSort: SortingRule = { id: 'createdAt' }; +const { value, setValue } = createLocalStorage( + 'FeaturesArchiveTable:v1', + defaultSort, +); + +export const FeaturesArchiveTable = () => { + usePageTitle('Archive'); + + const { + features: archivedFeatures, + loading, + refetch, + } = useFeatureSearch({ + archived: 'IS:true', + }); + + return ( + + ); +}; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index c538c2267b..61093db32c 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -432,6 +432,13 @@ exports[`returns all baseRoutes 1`] = ` "title": "Login history", "type": "protected", }, + { + "component": [Function], + "menu": {}, + "path": "/archive", + "title": "Archived flags", + "type": "protected", + }, { "component": { "$$typeof": Symbol(react.lazy), diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 42fa885e82..08ee8ab298 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -30,6 +30,7 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment'; import type { INavigationMenuItem, IRoute } from 'interfaces/route'; import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable'; import { SegmentTable } from '../segments/SegmentTable/SegmentTable.jsx'; +import { FeaturesArchiveTable } from 'component/archive/FeaturesArchiveTable'; import { LazyPlayground } from 'component/playground/Playground/LazyPlayground'; import { Profile } from 'component/user/Profile/Profile'; import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView'; @@ -446,6 +447,15 @@ export const routes: IRoute[] = [ menu: { adminSettings: true }, }, + // Archive + { + path: '/archive', + title: 'Archived flags', + component: FeaturesArchiveTable, + type: 'protected', + menu: {}, + }, + // Admin { path: '/admin/*',