From 24f9fa30584ae1632bf2ddeca7863433c24e90f5 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Wed, 8 Nov 2023 14:19:40 +0200 Subject: [PATCH] feat: connect search and filter with server api (#5297) --- .../PaginatedProjectFeatureToggles.tsx | 666 ++++++++++++++++++ .../ProjectFeatureToggles.tsx | 3 + .../project/Project/ProjectOverview.tsx | 17 +- .../searchToQueryParams.test.ts | 7 +- .../useFeatureSearch/searchToQueryParams.ts | 2 +- .../useFeatureSearch/useFeatureSearch.ts | 16 +- .../feature-search/feature-search-service.ts | 1 - 7 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx new file mode 100644 index 000000000000..ac554290a1e6 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx @@ -0,0 +1,666 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Checkbox, + IconButton, + styled, + Tooltip, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { Add } from '@mui/icons-material'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { + SortingRule, + useFlexLayout, + useRowSelect, + useSortBy, + useTable, +} from 'react-table'; +import type { FeatureSchema } from 'openapi'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { getCreateTogglePath } from 'utils/routePathHelpers'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; +import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell'; +import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; +import { IProject } from 'interfaces/project'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { createLocalStorage } from 'utils/createLocalStorage'; +import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; +import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; +import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; +import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch'; +import { Search } from 'component/common/Search/Search'; +import { IFeatureToggleListItem } from 'interfaces/featureToggle'; +import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; +import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; +import { + ProjectEnvironmentType, + useEnvironmentsRef, +} from './hooks/useEnvironmentsRef'; +import { ActionsCell } from './ActionsCell/ActionsCell'; +import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; +import { useStyles } from './ProjectFeatureToggles.styles'; +import { usePinnedFavorites } from 'hooks/usePinnedFavorites'; +import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; +import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell'; +import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage'; +import FileDownload from '@mui/icons-material/FileDownload'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; +import { MemoizedRowSelectCell } from './RowSelectCell/RowSelectCell'; +import { BatchSelectionActionsBar } from 'component/common/BatchSelectionActionsBar/BatchSelectionActionsBar'; +import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions'; +import { MemoizedFeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { ListItemType } from './ProjectFeatureToggles.types'; +import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell'; +import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch'; + +const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ + whiteSpace: 'nowrap', +})); + +interface IPaginatedProjectFeatureTogglesProps { + features: IProject['features']; + environments: IProject['environments']; + loading: boolean; + onChange: () => void; + total?: number; + searchValue: string; + setSearchValue: React.Dispatch>; +} + +const staticColumns = ['Select', 'Actions', 'name', 'favorite']; + +const defaultSort: SortingRule & { + columns?: string[]; +} = { id: 'createdAt' }; + +export const PaginatedProjectFeatureToggles = ({ + features, + loading, + environments: newEnvironments = [], + onChange, + total, + searchValue, + setSearchValue, +}: IPaginatedProjectFeatureTogglesProps) => { + const { classes: styles } = useStyles(); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const [strategiesDialogState, setStrategiesDialogState] = useState({ + open: false, + featureId: '', + environmentName: '', + }); + const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{ + featureId?: string; + stale?: boolean; + }>({}); + const [featureArchiveState, setFeatureArchiveState] = useState< + string | undefined + >(); + const projectId = useRequiredPathParam('projectId'); + const { onToggle: onFeatureToggle, modals: featureToggleModals } = + useFeatureToggleSwitch(projectId); + + const { value: storedParams, setValue: setStoredParams } = + createLocalStorage( + `${projectId}:FeatureToggleListTable:v1`, + defaultSort, + ); + const { value: globalStore, setValue: setGlobalStore } = + useGlobalLocalStorage(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const environments = useEnvironmentsRef( + loading + ? [{ environment: 'a' }, { environment: 'b' }, { environment: 'c' }] + : newEnvironments, + ); + const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = + usePinnedFavorites( + searchParams.has('favorites') + ? searchParams.get('favorites') === 'true' + : globalStore.favorites, + ); + const { favorite, unfavorite } = useFavoriteFeaturesApi(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const [showExportDialog, setShowExportDialog] = useState(false); + const { uiConfig } = useUiConfig(); + const showEnvironmentLastSeen = Boolean( + uiConfig.flags.lastSeenByEnvironment, + ); + + const onFavorite = useCallback( + async (feature: IFeatureToggleListItem) => { + if (feature?.favorite) { + await unfavorite(projectId, feature.name); + } else { + await favorite(projectId, feature.name); + } + onChange(); + }, + [projectId, onChange], + ); + + const showTagsColumn = useMemo( + () => features.some((feature) => feature?.tags?.length), + [features], + ); + + const columns = useMemo( + () => [ + { + id: 'Select', + Header: ({ getToggleAllRowsSelectedProps }: any) => ( + + ), + Cell: ({ row }: any) => ( + + ), + maxWidth: 50, + disableSortBy: true, + hideInMenu: true, + }, + { + id: 'favorite', + Header: ( + + ), + accessor: 'favorite', + Cell: ({ row: { original: feature } }: any) => ( + onFavorite(feature)} + /> + ), + maxWidth: 50, + disableSortBy: true, + hideInMenu: true, + }, + { + Header: 'Seen', + accessor: 'lastSeenAt', + Cell: ({ value, row: { original: feature } }: any) => { + return showEnvironmentLastSeen ? ( + + ) : ( + + ); + }, + align: 'center', + maxWidth: 80, + }, + { + Header: 'Type', + accessor: 'type', + Cell: FeatureTypeCell, + align: 'center', + filterName: 'type', + maxWidth: 80, + }, + { + Header: 'Name', + accessor: 'name', + Cell: ({ value }: { value: string }) => ( + + + + + + ), + minWidth: 100, + sortType: 'alphanumeric', + searchable: true, + }, + ...(showTagsColumn + ? [ + { + id: 'tags', + Header: 'Tags', + accessor: (row: IFeatureToggleListItem) => + row.tags + ?.map(({ type, value }) => `${type}:${value}`) + .join('\n') || '', + Cell: FeatureTagCell, + width: 80, + searchable: true, + filterName: 'tags', + filterBy( + row: IFeatureToggleListItem, + values: string[], + ) { + return includesFilter( + getColumnValues(this, row), + values, + ); + }, + }, + ] + : []), + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + minWidth: 120, + }, + ...environments.map((value: ProjectEnvironmentType | string) => { + const name = + typeof value === 'string' + ? value + : (value as ProjectEnvironmentType).environment; + const isChangeRequestEnabled = isChangeRequestConfigured(name); + const FeatureToggleCell = createFeatureToggleCell( + projectId, + name, + isChangeRequestEnabled, + onChange, + onFeatureToggle, + ); + + return { + Header: loading ? () => '' : name, + maxWidth: 90, + id: `environments.${name}`, + accessor: (row: ListItemType) => + row.environments[name]?.enabled, + align: 'center', + Cell: FeatureToggleCell, + sortType: 'boolean', + filterName: name, + filterParsing: (value: boolean) => + value ? 'enabled' : 'disabled', + }; + }), + + { + id: 'Actions', + maxWidth: 56, + width: 56, + Cell: (props: { row: { original: ListItemType } }) => ( + + ), + disableSortBy: true, + hideInMenu: true, + }, + ], + [projectId, environments, loading], + ); + + const [showTitle, setShowTitle] = useState(true); + + const featuresData = useMemo( + () => + features.map((feature) => ({ + ...feature, + environments: Object.fromEntries( + environments.map((env) => { + const thisEnv = feature?.environments.find( + (featureEnvironment) => + featureEnvironment?.name === env, + ); + return [ + env, + { + name: env, + enabled: thisEnv?.enabled || false, + variantCount: thisEnv?.variantCount || 0, + lastSeenAt: thisEnv?.lastSeenAt, + type: thisEnv?.type, + hasStrategies: thisEnv?.hasStrategies, + hasEnabledStrategies: + thisEnv?.hasEnabledStrategies, + }, + ]; + }), + ), + someEnabledEnvironmentHasVariants: + feature.environments?.some( + (featureEnvironment) => + featureEnvironment.variantCount > 0 && + featureEnvironment.enabled, + ) || false, + })), + [features, environments], + ); + + const { getSearchText, getSearchContext } = useSearch( + columns, + searchValue, + featuresData, + ); + + const data = useMemo(() => { + if (loading) { + return Array(6).fill({ + type: '-', + name: 'Feature name', + createdAt: new Date(), + environments: { + production: { name: 'production', enabled: false }, + }, + }) as FeatureSchema[]; + } + return featuresData; + }, [loading, featuresData]); + + const initialState = useMemo( + () => { + const allColumnIds = columns + .map( + (column: any) => + (column?.id as string) || + (typeof column?.accessor === 'string' + ? (column?.accessor as string) + : ''), + ) + .filter(Boolean); + let hiddenColumns = environments + .filter((_, index) => index >= 3) + .map((environment) => `environments.${environment}`); + + if (searchParams.has('columns')) { + const columnsInParams = + searchParams.get('columns')?.split(',') || []; + const visibleColumns = [...staticColumns, ...columnsInParams]; + hiddenColumns = allColumnIds.filter( + (columnId) => !visibleColumns.includes(columnId), + ); + } else if (storedParams.columns) { + const visibleColumns = [ + ...staticColumns, + ...storedParams.columns, + ]; + hiddenColumns = allColumnIds.filter( + (columnId) => !visibleColumns.includes(columnId), + ); + } + + return { + sortBy: [ + { + id: searchParams.get('sort') || 'createdAt', + desc: searchParams.has('order') + ? searchParams.get('order') === 'desc' + : storedParams.desc, + }, + ], + hiddenColumns, + selectedRowIds: {}, + }; + }, + [environments], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const getRowId = useCallback((row: any) => row.name, []); + const { + allColumns, + headerGroups, + rows, + state: { selectedRowIds, sortBy, hiddenColumns }, + prepareRow, + setHiddenColumns, + toggleAllRowsSelected, + } = 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, + ); + + useEffect(() => { + if (loading) { + return; + } + const tableState: Record = {}; + tableState.sort = sortBy[0].id; + if (sortBy[0].desc) { + tableState.order = 'desc'; + } + if (searchValue) { + tableState.search = searchValue; + } + if (isFavoritesPinned) { + tableState.favorites = 'true'; + } + tableState.columns = allColumns + .map(({ id }) => id) + .filter( + (id) => + !staticColumns.includes(id) && !hiddenColumns?.includes(id), + ) + .join(','); + + setSearchParams(tableState, { + replace: true, + }); + setStoredParams((params) => ({ + ...params, + id: sortBy[0].id, + desc: sortBy[0].desc || false, + columns: tableState.columns.split(','), + })); + setGlobalStore((params) => ({ + ...params, + favorites: Boolean(isFavoritesPinned), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + loading, + sortBy, + hiddenColumns, + searchValue, + setSearchParams, + isFavoritesPinned, + ]); + + return ( + <> + + setShowTitle(false)} + onBlur={() => setShowTitle(true)} + hasFilters + getSearchContext={getSearchContext} + id='projectFeatureToggles' + /> + } + /> + + + + + setShowExportDialog(true) + } + sx={(theme) => ({ + marginRight: + theme.spacing(2), + })} + > + + + + } + /> + + navigate(getCreateTogglePath(projectId)) + } + maxWidth='960px' + Icon={Add} + projectId={projectId} + permission={CREATE_FEATURE} + data-testid='NAVIGATE_TO_CREATE_FEATURE' + > + New feature toggle + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No feature toggles found matching “ + {searchValue} + ” + + } + elseShow={ + + No feature toggles available. Get started by + adding a new feature toggle. + + } + /> + } + /> + + setStrategiesDialogState((prev) => ({ + ...prev, + open: false, + })) + } + projectId={projectId} + {...strategiesDialogState} + /> + { + setFeatureStaleDialogState({}); + onChange(); + }} + featureId={featureStaleDialogState.featureId || ''} + projectId={projectId} + /> + { + setFeatureArchiveState(undefined); + }} + featureIds={[featureArchiveState || '']} + projectId={projectId} + /> + setShowExportDialog(false)} + environments={environments} + /> + } + /> + {featureToggleModals} + + + toggleAllRowsSelected(false)} + /> + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 103e97ab3994..ed0bb7fd205f 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -83,6 +83,9 @@ const defaultSort: SortingRule & { columns?: string[]; } = { id: 'createdAt' }; +/** + * @deprecated remove when flag `featureSearchFrontend` is removed + */ export const ProjectFeatureToggles = ({ features, loading, diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index 5a98434a88b4..44f2c2ed8f45 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -13,6 +13,8 @@ import { ProjectStats } from './ProjectStats/ProjectStats'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { useUiFlag } from 'hooks/useUiFlag'; import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; +import { PaginatedProjectFeatureToggles } from './ProjectFeatureToggles/PaginatedProjectFeatureToggles'; +import { useSearchParams } from 'react-router-dom'; const refreshInterval = 15 * 1000; @@ -39,16 +41,22 @@ const PAGE_LIMIT = 25; const PaginatedProjectOverview = () => { const projectId = useRequiredPathParam('projectId'); + const [searchParams, setSearchParams] = useSearchParams(); const { project, loading: projectLoading } = useProject(projectId, { refreshInterval, }); const [currentOffset, setCurrentOffset] = useState(0); + + const [searchValue, setSearchValue] = useState( + searchParams.get('search') || '', + ); + const { features: searchFeatures, total, refetch, loading, - } = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, { + } = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, searchValue, { refreshInterval, }); @@ -79,7 +87,7 @@ const PaginatedProjectOverview = () => { - { loading={loading && searchFeatures.length === 0} onChange={refetch} total={total} + searchValue={searchValue} + setSearchValue={setSearchValue} /> { ); }; +/** + * @deprecated remove when flag `featureSearchFrontend` is removed + */ const ProjectOverview = () => { const projectId = useRequiredPathParam('projectId'); const projectName = useProjectNameOrId(projectId); diff --git a/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.test.ts b/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.test.ts index dc91fece29d3..bcce8486e8c2 100644 --- a/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.test.ts +++ b/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.test.ts @@ -36,10 +36,11 @@ describe('translateToQueryParams', () => { 'development:enabled,disabled', 'status[]=development:enabled&status[]=development:disabled', ], - ['tag:simple:web', 'tag[]=simple:web'], - ['tag:enabled:enabled', 'tag[]=enabled:enabled'], + ['tags:simple:web', 'tag[]=simple:web'], + ['tags:enabled:enabled', 'tag[]=enabled:enabled'], + ['tags:simp', 'tag[]=simp'], [ - 'tag:simple:web,complex:native', + 'tags:simple:web,complex:native', 'tag[]=simple:web&tag[]=complex:native', ], ])('when input is "%s"', (input, expected) => { diff --git a/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.ts b/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.ts index 67a4572b112b..dfab08c2a74d 100644 --- a/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.ts +++ b/frontend/src/hooks/api/getters/useFeatureSearch/searchToQueryParams.ts @@ -45,7 +45,7 @@ const handleFilter = ( if (isStatusFilter(key, values)) { return addStatusFilters(key, values, filterParams); - } else if (key === 'tag') { + } else if (key === 'tags') { return addTagFilters(values, filterParams); } else { return addRegularFilters(key, values, filterParams); diff --git a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts index 116a8464b455..8fb6aa1cad6b 100644 --- a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts +++ b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; +import { translateToQueryParams } from './searchToQueryParams'; type IFeatureSearchResponse = { features: IFeatureToggleListItem[]; @@ -29,9 +30,15 @@ export const useFeatureSearch = ( offset: number, limit: number, projectId = '', + searchValue = '', options: SWRConfiguration = {}, ): IUseFeatureSearchOutput => { - const { KEY, fetcher } = getFeatureSearchFetcher(projectId, offset, limit); + const { KEY, fetcher } = getFeatureSearchFetcher( + projectId, + offset, + limit, + searchValue, + ); const { data, error, mutate } = useSWR( KEY, fetcher, @@ -45,7 +52,7 @@ export const useFeatureSearch = ( const returnData = data || fallbackData; return { ...returnData, - loading: !error && !data, + loading: false, error, refetch, }; @@ -55,9 +62,10 @@ const getFeatureSearchFetcher = ( projectId: string, offset: number, limit: number, + searchValue: string, ) => { - const KEY = `api/admin/search/features?projectId=${projectId}&offset=${offset}&limit=${limit}`; - + const searchQueryParams = translateToQueryParams(searchValue); + const KEY = `api/admin/search/features?projectId=${projectId}&offset=${offset}&limit=${limit}&${searchQueryParams}`; const fetcher = () => { const path = formatApiPath(KEY); return fetch(path, { diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 9fcb7411510a..5e14f8c16648 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -21,7 +21,6 @@ export class FeatureSearchService { } async search(params: IFeatureSearchParams) { - // fetch one more item than needed to get a cursor of the next item const { features, total } = await this.featureStrategiesStore.searchFeatures({ ...params,