From d11aedc12f8611fdadaac31b37b18e78c6efb29a Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:33:11 +0100 Subject: [PATCH] Project Overview with react-table v8 (#5571) --- .../Table/PaginatedTable/PaginatedTable.tsx | 8 +- .../common/Table/cells/DateCell/DateCell.tsx | 7 +- .../cells/FeatureNameCell/FeatureNameCell.tsx | 16 +- .../FeatureEnvironmentSeenCell.tsx | 7 +- .../cells/FeatureTypeCell/FeatureTypeCell.tsx | 18 +- .../ExperimentalProjectFeatures.tsx | 4 +- .../ExperimentalProjectTable.tsx | 428 ++++++++++++++++++ .../FeatureToggleCell/FeatureToggleCell.tsx | 86 ++++ .../ProjectFeatureTogglesHeader.tsx | 175 +++++++ .../RowSelectCell/RowSelectCell.tsx | 10 +- frontend/src/types/react-table-v8.d.ts | 3 +- 11 files changed, 739 insertions(+), 23 deletions(-) create mode 100644 frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/ExperimentalProjectTable.tsx create mode 100644 frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/FeatureToggleCell/FeatureToggleCell.tsx create mode 100644 frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx diff --git a/frontend/src/component/common/Table/PaginatedTable/PaginatedTable.tsx b/frontend/src/component/common/Table/PaginatedTable/PaginatedTable.tsx index 85d5a8a1b1..9d639bfea8 100644 --- a/frontend/src/component/common/Table/PaginatedTable/PaginatedTable.tsx +++ b/frontend/src/component/common/Table/PaginatedTable/PaginatedTable.tsx @@ -14,6 +14,7 @@ const HeaderCell = (header: Header) => { const column = header.column; const isDesc = column.getIsSorted() === 'desc'; const align = column.columnDef.meta?.align || undefined; + const width = column.columnDef.meta?.width || undefined; return ( (header: Header) => { isDescending={isDesc} align={align} onClick={() => column.toggleSorting()} - styles={{ borderRadius: '0px' }} + styles={{ + borderRadius: '0px', + paddingTop: 0, + paddingBottom: 0, + width, + }} > {header.isPlaceholder ? null diff --git a/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx index 4bb99582c5..a1845c2d40 100644 --- a/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx +++ b/frontend/src/component/common/Table/cells/DateCell/DateCell.tsx @@ -5,11 +5,14 @@ import { getLocalizedDateString } from '../../../util'; interface IDateCellProps { value?: Date | string | null; + getValue?: () => Date | string | null | undefined; } -export const DateCell: VFC = ({ value }) => { +// `getValue is for new @tanstack/react-table (v8), `value` is for legacy react-table (v7) +export const DateCell: VFC = ({ value, getValue }) => { + const input = value || getValue?.() || null; const { locationSettings } = useLocationSettings(); - const date = getLocalizedDateString(value, locationSettings.locale); + const date = getLocalizedDateString(input, locationSettings.locale); return {date}; }; diff --git a/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx b/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx index 13f0128a17..88a84c2d8e 100644 --- a/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx +++ b/frontend/src/component/common/Table/cells/FeatureNameCell/FeatureNameCell.tsx @@ -4,17 +4,21 @@ import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; interface IFeatureNameCellProps { row: { original: { - name: string; - description: string; - project: string; + name?: string | null; + description?: string | null; + project?: string | null; }; }; } export const FeatureNameCell: VFC = ({ row }) => ( ); diff --git a/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell.tsx b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell.tsx index 089c8130d4..1450e12fbc 100644 --- a/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell.tsx +++ b/frontend/src/component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell.tsx @@ -1,9 +1,12 @@ import React, { VFC } from 'react'; import { FeatureEnvironmentSeen } from 'component/feature/FeatureView/FeatureEnvironmentSeen/FeatureEnvironmentSeen'; -import { FeatureSchema } from 'openapi'; +import { FeatureEnvironmentSchema } from 'openapi'; interface IFeatureSeenCellProps { - feature: FeatureSchema; + feature: { + environments?: FeatureEnvironmentSchema[]; + lastSeenAt?: string | null; + }; } export const FeatureEnvironmentSeenCell: VFC = ({ diff --git a/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx index b55fb65f03..18f391a057 100644 --- a/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx +++ b/frontend/src/component/common/Table/cells/FeatureTypeCell/FeatureTypeCell.tsx @@ -5,6 +5,7 @@ import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes'; interface IFeatureTypeProps { value?: string; + getValue?: () => string | undefined | null; } const StyledContainer = styled('div')(({ theme }) => ({ @@ -15,15 +16,20 @@ const StyledContainer = styled('div')(({ theme }) => ({ color: theme.palette.text.disabled, })); -export const FeatureTypeCell: VFC = ({ value }) => { +// `getValue is for new @tanstack/react-table (v8), `value` is for legacy react-table (v7) +export const FeatureTypeCell: VFC = ({ + value, + getValue, +}) => { + const type = value || getValue?.() || undefined; const { featureTypes } = useFeatureTypes(); - const IconComponent = getFeatureTypeIcons(value); + const IconComponent = getFeatureTypeIcons(type); - const typeName = featureTypes - .filter((type) => type.id === value) - .map((type) => type.name); + const typeName = featureTypes.find( + (featureType) => featureType.id === type, + )?.name; - const title = `This is a "${typeName || value}" toggle`; + const title = `This is a "${typeName || type}" toggle`; return ( diff --git a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx index aa048678d1..00f817c5b3 100644 --- a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx +++ b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectFeatures.tsx @@ -9,7 +9,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { useUiFlag } from 'hooks/useUiFlag'; -import { PaginatedProjectFeatureToggles } from '../ProjectFeatureToggles/PaginatedProjectFeatureToggles'; +import { ExperimentalProjectFeatureToggles } from './ExperimentalProjectTable/ExperimentalProjectTable'; const refreshInterval = 15 * 1000; @@ -44,7 +44,7 @@ const PaginatedProjectOverview = () => { - (); + +export const ExperimentalProjectFeatureToggles = ({ + environments, + style, + refreshInterval = 15 * 1000, + storageKey = 'project-feature-toggles', +}: IExperimentalProjectFeatureTogglesProps) => { + const projectId = useRequiredPathParam('projectId'); + const [tableState, setTableState] = usePersistentTableState( + `${storageKey}-${projectId}`, + { + offset: withDefault(NumberParam, 0), + limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), + query: StringParam, + favoritesFirst: withDefault(BooleansStringParam, true), + sortBy: withDefault(StringParam, 'createdAt'), + sortOrder: withDefault(StringParam, 'desc'), + columns: ArrayParam, + }, + ); + + const { features, total, refetch, loading, initialLoad } = useFeatureSearch( + mapValues({ ...tableState, projectId }, (value) => + value ? `${value}` : undefined, + ), + { + refreshInterval, + }, + ); + + const { favorite, unfavorite } = useFavoriteFeaturesApi(); + const onFavorite = useCallback( + async (feature: FeatureSearchResponseSchema) => { + if (feature?.favorite) { + await unfavorite(projectId, feature.name); + } else { + await favorite(projectId, feature.name); + } + refetch(); + }, + [projectId, refetch], + ); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { onToggle: onFeatureToggle, modals: featureToggleModals } = + useFeatureToggleSwitch(projectId); + const bodyLoadingRef = useLoading(loading); + const columns = useMemo( + () => [ + columnHelper.display({ + id: 'Select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor('favorite', { + header: () => ( + + setTableState({ + favoritesFirst: !tableState.favoritesFirst, + }) + } + /> + ), + cell: ({ row: { original: feature } }) => ( + onFavorite(feature)} + /> + ), + enableSorting: false, + meta: { + align: 'center', + // hideInMenu: true, + }, + }), + columnHelper.accessor('lastSeenAt', { + header: 'Last seen', + cell: ({ row: { original } }) => ( + + ), + size: 50, + meta: { + align: 'center', + }, + }), + columnHelper.accessor('type', { + header: 'Type', + cell: FeatureTypeCell, + meta: { + align: 'center', + }, + }), + columnHelper.accessor('name', { + header: 'Name', + cell: FeatureNameCell, + meta: { + width: '50%', + }, + }), + columnHelper.accessor('createdAt', { + header: 'Created', + cell: DateCell, + }), + ...environments.map( + (projectEnvironment: ProjectEnvironmentType) => { + const name = projectEnvironment.environment; + const isChangeRequestEnabled = + isChangeRequestConfigured(name); + + return columnHelper.accessor( + (row) => ({ + featureId: row.name, + environment: row.environments?.find( + (featureEnvironment) => + featureEnvironment.name === name, + ), + someEnabledEnvironmentHasVariants: + row.environments?.some( + (featureEnvironment) => + featureEnvironment.variantCount && + featureEnvironment.variantCount > 0 && + featureEnvironment.enabled, + ) || false, + }), + { + id: `environment:${name}`, + header: loading ? '' : name, + meta: { + align: 'center', + }, + cell: ({ getValue }) => { + const { + featureId, + environment, + someEnabledEnvironmentHasVariants, + } = getValue(); + + return ( + + ); + }, + }, + ); + }, + ), + ], + [projectId, environments, loading, tableState.favoritesFirst, refetch], + ); + + const placeholderData = useMemo( + () => + Array(tableState.limit) + .fill(null) + .map((_, index) => ({ + id: index, + type: '-', + name: `Feature name ${index}`, + createdAt: new Date().toISOString(), + environments: [ + { + name: 'production', + enabled: false, + }, + { + name: 'production', + enabled: false, + }, + ], + })), + [tableState.limit], + ); + + const data = useMemo(() => { + if (initialLoad || (loading && total)) { + return placeholderData; + } + return features; + }, [loading, features]); + + const table = useReactTable( + withTableState(tableState, setTableState, { + columns, + data, + enableRowSelection: true, + }), + ); + + return ( + <> + { + setTableState({ query }); + }} + dataToExport={data} + environmentsToExport={environments.map( + ({ environment }) => environment, + )} + /> + } + > +
+ + + + {/* + 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/ExperimentalProjectFeatures/ExperimentalProjectTable/FeatureToggleCell/FeatureToggleCell.tsx b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/FeatureToggleCell/FeatureToggleCell.tsx new file mode 100644 index 0000000000..73592e688c --- /dev/null +++ b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/FeatureToggleCell/FeatureToggleCell.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { styled } from '@mui/material'; +import { flexRow } from 'themes/themeStyles'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning'; +import { FeatureToggleSwitch } from '../../../ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch'; +import type { UseFeatureToggleSwitchType } from '../../../ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.types'; +import { type FeatureEnvironmentSchema } from 'openapi'; + +const StyledSwitchContainer = styled('div', { + shouldForwardProp: (prop) => prop !== 'hasWarning', +})<{ hasWarning?: boolean }>(({ theme, hasWarning }) => ({ + flexGrow: 0, + ...flexRow, + justifyContent: 'center', + ...(hasWarning && { + '::before': { + content: '""', + display: 'block', + width: theme.spacing(2), + }, + }), +})); + +interface IFeatureToggleCellProps { + projectId: string; + environmentName: string; + isChangeRequestEnabled: boolean; + refetch: () => void; + onFeatureToggleSwitch: ReturnType['onToggle']; + value: boolean; + featureId: string; + environment?: FeatureEnvironmentSchema; + someEnabledEnvironmentHasVariants?: boolean; +} + +const FeatureToggleCellComponent = ({ + value, + featureId, + projectId, + environment, + isChangeRequestEnabled, + someEnabledEnvironmentHasVariants, + refetch, + onFeatureToggleSwitch, +}: IFeatureToggleCellProps) => { + const hasWarning = useMemo( + () => + someEnabledEnvironmentHasVariants && + environment?.variantCount === 0 && + environment?.enabled, + [someEnabledEnvironmentHasVariants, environment], + ); + + const onToggle = (newState: boolean, onRollback: () => void) => { + onFeatureToggleSwitch(newState, { + projectId, + featureId, + environmentName: environment?.name || '', + environmentType: environment?.type, + hasStrategies: environment?.hasStrategies, + hasEnabledStrategies: environment?.hasEnabledStrategies, + isChangeRequestEnabled, + onRollback, + onSuccess: refetch, + }); + }; + + return ( + + + } + /> + + ); +}; + +export const FeatureToggleCell = React.memo(FeatureToggleCellComponent); diff --git a/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx new file mode 100644 index 0000000000..03d3343863 --- /dev/null +++ b/frontend/src/component/project/Project/ExperimentalProjectFeatures/ExperimentalProjectTable/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 + + + } + > + + } + /> + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell.tsx index d9c9689c7f..d2df48fbad 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell.tsx @@ -4,23 +4,27 @@ import { FC } from 'react'; import { BATCH_SELECT } from 'utils/testIds'; interface IRowSelectCellProps { - onChange: () => void; + onChange: (_?: unknown) => void; checked: boolean; title: string; + noPadding?: boolean; } const StyledBoxCell = styled(Box)(({ theme }) => ({ display: 'flex', justifyContent: 'center', - paddingLeft: theme.spacing(2), })); export const RowSelectCell: FC = ({ onChange, checked, title, + noPadding, }) => ( - + ({ paddingLeft: noPadding ? 0 : theme.spacing(2) })} + > { - align: 'left' | 'center' | 'right'; + align?: 'left' | 'center' | 'right'; + width?: number | string; } }