diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx index 5160a80bcc..a2a66ebb81 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/PaginatedProjectFeatureToggles.tsx @@ -35,7 +35,11 @@ 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 { + PaginatedTable, + TablePlaceholder, + VirtualizedTable, +} from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; @@ -76,6 +80,10 @@ import { ArrayParam, withDefault, } from 'use-query-params'; +import { createColumnHelper, useReactTable } from '@tanstack/react-table'; +import { withTableState } from 'utils/withTableState'; +import { FeatureSchema } from 'openapi'; +import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader'; const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ whiteSpace: 'nowrap', @@ -89,6 +97,7 @@ interface IPaginatedProjectFeatureTogglesProps { } const staticColumns = ['Select', 'Actions', 'name', 'favorite']; +const columnHelper = createColumnHelper(); export const PaginatedProjectFeatureToggles = ({ environments, @@ -118,529 +127,52 @@ export const PaginatedProjectFeatureToggles = ({ refreshInterval, }, ); - const onChange = refetch; - - const { classes: styles } = useStyles(); const bodyLoadingRef = useLoading(loading); - const headerLoadingRef = useLoading(initialLoad); - const theme = useTheme(); - const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); - const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{ - featureId?: string; - stale?: boolean; - }>({}); - const [featureArchiveState, setFeatureArchiveState] = useState< - string | undefined - >(); - const [isCustomColumns, setIsCustomColumns] = useState( - Boolean(tableState.columns), - ); - const { onToggle: onFeatureToggle, modals: featureToggleModals } = - useFeatureToggleSwitch(projectId); - const navigate = useNavigate(); - const { favorite, unfavorite } = useFavoriteFeaturesApi(); - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - const [showExportDialog, setShowExportDialog] = useState(false); - const { uiConfig } = useUiConfig(); - - 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 data = useMemo(() => features, [features]); const columns = useMemo( () => [ - { - id: 'Select', - Header: ({ getToggleAllRowsSelectedProps }: any) => ( - - ), - Cell: ({ row }: any) => ( - , + cell: ({ row }) => ( + ), - maxWidth: 50, - disableSortBy: true, - hideInMenu: true, - styles: { - borderRadius: 0, - }, - }, - { - id: 'favorite', - Header: ( - - setTableState({ - favoritesFirst: !tableState.favoritesFirst, - }) - } - /> - ), - 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 ( - - ); - }, - 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, - minWidth: 120, - }, - ...environments.map( - (projectEnvironment: ProjectEnvironmentType | string) => { - const name = - typeof projectEnvironment === 'string' - ? projectEnvironment - : (projectEnvironment as ProjectEnvironmentType) - .environment; - const isChangeRequestEnabled = - isChangeRequestConfigured(name); - const FeatureToggleCell = createFeatureToggleCell( - projectId, - name, - isChangeRequestEnabled, - onChange, - onFeatureToggle, - ); - - return { - Header: loading ? () => '' : name, - maxWidth: 90, - id: `environment:${name}`, - accessor: (row: ListItemType) => { - return row.environments?.[name]?.enabled; - }, - align: 'center', - Cell: FeatureToggleCell, - sortType: 'boolean', - sortDescFirst: true, - filterName: name, - filterParsing: (value: boolean) => - value ? 'enabled' : 'disabled', - }; - }, - ), - { - id: 'Actions', - maxWidth: 56, - width: 56, - Cell: (props: { - row: { - original: ListItemType; - }; - }) => ( - - ), - disableSortBy: true, - hideInMenu: true, - styles: { - borderRadius: 0, - }, - }, + }), ], - [projectId, environments, loading, tableState.favoritesFirst, onChange], + [tableState.favoritesFirst], ); - 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.environment, - ); - return [ - typeof env === 'string' ? env : env.environment, - { - 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 && - featureEnvironment.variantCount > 0 && - featureEnvironment.enabled, - ) || false, - })), - [features, environments], - ); - - const { getSearchText, getSearchContext } = useSearch( - columns, - tableState.query || '', - featuresData, - ); - - const allColumnIds = columns - .map( - (column: any) => - (column?.id as string) || - (typeof column?.accessor === 'string' - ? (column?.accessor as string) - : ''), - ) - .filter(Boolean); - - const initialState = useMemo( - () => ({ - sortBy: [ - { - id: tableState.sortBy || 'createdAt', - desc: tableState.sortOrder === 'desc', - }, - ], - ...(tableState.columns - ? { - hiddenColumns: allColumnIds.filter( - (id) => - !tableState.columns?.includes(id) && - !staticColumns.includes(id), - ), - } - : {}), - pageSize: tableState.limit, - pageIndex: tableState.offset * tableState.limit, - selectedRowIds: {}, - }), - [initialLoad], - ); - - const data = useMemo(() => { - if (initialLoad || loading) { - const loadingData = Array(tableState.limit) - .fill(null) - .map((_, index) => ({ - id: index, // Assuming `id` is a required property - type: '-', - name: `Feature name ${index}`, - createdAt: new Date().toISOString(), - environments: [ - { - name: 'production', - enabled: false, - }, - ], - })); - // Coerce loading data to FeatureSchema[] - return loadingData as unknown as typeof featuresData; - } - return featuresData; - }, [loading, featuresData]); - - const pageCount = useMemo( - () => Math.ceil((total || 0) / tableState.limit), - [total, tableState.limit], - ); - const getRowId = useCallback((row: any) => row.name, []); - - const { - allColumns, - headerGroups, - rows, - state: { pageIndex, pageSize, hiddenColumns, selectedRowIds, sortBy }, - canNextPage, - canPreviousPage, - previousPage, - nextPage, - setPageSize, - prepareRow, - setHiddenColumns, - toggleAllRowsSelected, - } = useTable( - { - columns: columns as any[], // TODO: fix after `react-table` v8 update + const table = useReactTable( + withTableState(tableState, setTableState, { + columns, data, - initialState, - autoResetHiddenColumns: false, - autoResetSelectedRows: false, - disableSortRemove: true, - autoResetSortBy: false, - manualSortBy: true, - manualPagination: true, - pageCount, - getRowId, - }, - useFlexLayout, - useSortBy, - usePagination, - useRowSelect, + }), ); - // Refetching - https://github.com/TanStack/table/blob/v7/docs/src/pages/docs/faq.md#how-can-i-use-the-table-state-to-fetch-new-data - useEffect(() => { - setTableState({ - offset: pageIndex * pageSize, - limit: pageSize, - sortBy: sortBy[0]?.id || 'createdAt', - sortOrder: sortBy[0]?.desc ? 'desc' : 'asc', - }); - }, [pageIndex, pageSize, sortBy]); - - useEffect(() => { - // FIXME: refactor column visibility logic when switching to react-table v8 - if (!loading && isCustomColumns) { - setTableState({ - columns: - hiddenColumns !== undefined - ? allColumnIds.filter( - (id) => - !hiddenColumns.includes(id) && - !staticColumns.includes(id), - ) - : undefined, - }); - } - }, [loading, isCustomColumns, hiddenColumns]); - - const showPaginationBar = Boolean(total && total > pageSize); - const paginatedStyles = showPaginationBar - ? { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - } - : {}; - return ( <> ({ - padding: `${theme.spacing(2.5)} ${theme.spacing( - 3.125, - )}`, - })} - > - - { - setTableState({ - query: value, - }); - }} - onFocus={() => - setShowTitle(false) - } - onBlur={() => - setShowTitle(true) - } - hasFilters - getSearchContext={ - getSearchContext - } - id='projectFeatureToggles' - /> - } - /> - - setIsCustomColumns(true) - } - /> - - - - 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 - - - } - > - { - setTableState({ query: value }); - }} - hasFilters - getSearchContext={getSearchContext} - id='projectFeatureToggles' - /> - } - /> - - + + setTableState({ query }) + } + isLoading={initialLoad} + dataToExport={features} // FIXME: selected columns? + environmentsToExport={environments.map( + ({ environment }) => environment, // FIXME: visible env columns? + )} + /> } >
- - + - - 0} - show={ - - - No feature toggles found matching - “ - {tableState.query} - ” - - - } - elseShow={ - - - No feature toggles available. Get - started by adding a new feature - toggle. - - - } - /> - } - /> - { - setFeatureStaleDialogState({}); - onChange(); - }} - featureId={featureStaleDialogState.featureId || ''} - projectId={projectId} - /> - { - setFeatureArchiveState(undefined); - }} - featureIds={[featureArchiveState || '']} - projectId={projectId} - /> - setShowExportDialog(false)} - environments={environments.map( - ({ environment }) => environment, - )} - /> - } - /> - {featureToggleModals}
- - } - /> - - toggleAllRowsSelected(false)} - /> - ); }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx new file mode 100644 index 0000000000..03d3343863 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx @@ -0,0 +1,175 @@ +import { VFC, useState } from 'react'; +import { + Box, + IconButton, + Tooltip, + useMediaQuery, + useTheme, +} from '@mui/material'; +import useLoading from 'hooks/useLoading'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Search } from 'component/common/Search/Search'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { Add, FileDownload } from '@mui/icons-material'; +import { styled } from '@mui/material'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { useNavigate } from 'react-router-dom'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { getCreateTogglePath } from 'utils/routePathHelpers'; +import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; +import { FeatureSchema } from 'openapi'; + +interface IProjectFeatureTogglesHeaderProps { + isLoading?: boolean; + totalItems?: number; + searchQuery?: string; + onChangeSearchQuery?: (query: string) => void; + dataToExport?: Pick[]; + environmentsToExport?: string[]; +} + +const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ + whiteSpace: 'nowrap', +})); + +export const ProjectFeatureTogglesHeader: VFC< + IProjectFeatureTogglesHeaderProps +> = ({ + isLoading, + totalItems, + searchQuery, + onChangeSearchQuery, + dataToExport, + environmentsToExport, +}) => { + const projectId = useRequiredPathParam('projectId'); + const headerLoadingRef = useLoading(isLoading || false); + const [showTitle, setShowTitle] = useState(true); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + const featuresExportImportFlag = useUiFlag('featuresExportImport'); + const [showExportDialog, setShowExportDialog] = useState(false); + const navigate = useNavigate(); + const handleSearch = (query: string) => { + onChangeSearchQuery?.(query); + }; + + return ( + ({ + padding: `${theme.spacing(2.5)} ${theme.spacing(3.125)}`, + })} + > + + setShowTitle(false)} + onBlur={() => setShowTitle(true)} + hasFilters + id='projectFeatureToggles' + /> + } + /> + {/* FIXME: columns menu */} + {/* setIsCustomColumns(true)} + /> */} + + + + + setShowExportDialog(true) + } + sx={(theme) => ({ + marginRight: theme.spacing(2), + })} + > + + + + + + setShowExportDialog(false) + } + environments={ + environmentsToExport || [] + } + /> + } + /> + + } + /> + + navigate(getCreateTogglePath(projectId)) + } + maxWidth='960px' + Icon={Add} + projectId={projectId} + permission={CREATE_FEATURE} + data-testid='NAVIGATE_TO_CREATE_FEATURE' + > + New feature toggle + + + } + > + + } + /> + + + ); +};