1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-13 13:48:59 +02:00

spike how to handle tables without react-table

This commit is contained in:
Tymoteusz Czech 2023-12-06 15:24:51 +01:00
parent 87ebbb0fa2
commit e1cddfec1d
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
5 changed files with 555 additions and 5 deletions

View File

@ -15,7 +15,7 @@ interface ILinkCellProps {
title?: string;
to?: string;
onClick?: () => void;
subtitle?: string;
subtitle?: string | null;
}
export const LinkCell: FC<ILinkCellProps> = ({
@ -45,7 +45,7 @@ export const LinkCell: FC<ILinkCellProps> = ({
<>
<StyledDescription data-loading>
<Highlighter search={searchQuery}>
{subtitle}
{subtitle || ''}
</Highlighter>
</StyledDescription>
</>

View File

@ -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 { ExperimentalPaginatedFeatureToggles } from '../ProjectFeatureToggles/ExperimentalPaginatedFeatureToggles';
const refreshInterval = 15 * 1000;
@ -44,7 +44,7 @@ const PaginatedProjectOverview = () => {
<StyledContainer>
<StyledContentContainer>
<StyledProjectToggles>
<PaginatedProjectFeatureToggles
<ExperimentalPaginatedFeatureToggles
style={{ width: '100%', margin: 0 }}
environments={environments}
storageKey='project-features'

View File

@ -0,0 +1,375 @@
import React, {
type CSSProperties,
useCallback,
useEffect,
useMemo,
useState,
FC,
ReactNode,
VFC,
} from 'react';
import {
Checkbox,
IconButton,
styled,
Tooltip,
useMediaQuery,
Box,
useTheme,
} from '@mui/material';
import { Add } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import {
useFlexLayout,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
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 {
PaginatedTable,
TableCell,
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';
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 } from './hooks/useEnvironmentsRef';
import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
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 {
MemoizedFeatureToggleCell,
createFeatureToggleCell,
} from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
import useLoading from 'hooks/useLoading';
import {
DEFAULT_PAGE_LIMIT,
useFeatureSearch,
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import mapValues from 'lodash.mapvalues';
import { usePersistentTableState } from 'hooks/usePersistentTableState';
import { BooleansStringParam } from 'utils/serializeQueryParams';
import {
NumberParam,
StringParam,
ArrayParam,
withDefault,
} from 'use-query-params';
import { createColumnHelper, useReactTable } from '@tanstack/react-table';
import { withTableState } from 'utils/withTableState';
import { FeatureSchema, FeatureSearchResponseSchema } from 'openapi';
import { ProjectFeatureTogglesHeader } from './ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader';
import { TableBody, TableRow, TableHead } from '@mui/material';
import { Table } from 'component/common/Table/Table/Table';
import {
Header,
type Table as TableType,
flexRender,
} from '@tanstack/react-table';
import { StickyPaginationBar } from 'component/common/Table/StickyPaginationBar/StickyPaginationBar';
import { CellSortable } from 'component/common/Table/SortableTableHeader/CellSortable/CellSortable';
interface IExperimentalPaginatedFeatureTogglesProps {
environments: IProject['environments'];
style?: CSSProperties;
refreshInterval?: number;
storageKey?: string;
}
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
type ColumnProps = {
id: string;
header: ReactNode;
isSortable?: boolean;
align?: 'left' | 'right' | 'center';
};
type ColumnType<T> = ColumnProps & {
cell: (row: T) => JSX.Element;
};
const HeaderCell: VFC<{
column: ColumnProps;
tableState: {
sortBy: string;
sortOrder: string;
};
setTableState: (state: {
sortBy: string;
sortOrder: string;
}) => void;
}> = ({ column, tableState, setTableState }) => (
<CellSortable
key={column.id}
isSortable={column.isSortable}
isSorted={tableState.sortBy === column.id}
isDescending={tableState.sortOrder === 'desc'}
align={column.align}
onClick={() => {
if (column.isSortable) {
setTableState({
sortBy: column.id,
sortOrder: tableState.sortOrder === 'desc' ? 'asc' : 'desc',
});
}
}}
styles={{ borderRadius: '0px' }}
>
{column.header}
</CellSortable>
);
export const ExperimentalPaginatedFeatureToggles = ({
environments,
style,
refreshInterval = 15 * 1000,
storageKey = 'project-feature-toggles',
}: IExperimentalPaginatedFeatureTogglesProps) => {
const projectId = useRequiredPathParam('projectId');
const { isChangeRequestConfigured } = useChangeRequestsEnabled(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 bodyLoadingRef = useLoading(loading);
const data = useMemo(
() =>
features.map((feature) => ({
...feature,
archivedAt: feature.archivedAt || undefined,
createdAt: feature.createdAt || '',
lastSeenAt: feature.lastSeenAt || undefined,
type: feature.type || '',
environments: Object.fromEntries(
environments.map((env) => {
const thisEnv = feature?.environments?.find(
(featureEnvironment) =>
featureEnvironment?.name === env.environment,
);
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 &&
featureEnvironment.variantCount > 0 &&
featureEnvironment.enabled,
) || false,
})),
[features, environments],
);
type DataItem = (typeof data)[number];
const columns: ColumnType<DataItem>[] = [
{
id: 'name',
header: 'Name',
cell: (row) => (
<LinkCell
title={row.name}
subtitle={row.description}
to={`/projects/${row.project}/features/${row.name}`}
/>
),
isSortable: true,
},
{
id: 'type',
header: 'Type',
cell: (row) => <FeatureTypeCell value={row.type} />,
align: 'center',
isSortable: true,
},
...environments.map(
(environment) =>
({
id: environment.environment,
header: environment.environment,
cell: (row: DataItem) => (
<MemoizedFeatureToggleCell
value={
row.environments?.[environment.environment]
?.enabled || false
}
feature={row}
projectId={projectId}
environmentName={environment.environment}
isChangeRequestEnabled={isChangeRequestConfigured(
environment.environment,
)}
refetch={refetch}
// onFeatureToggleSwitch={onFeatureToggleSwitch}
onFeatureToggleSwitch={() => {}}
/>
),
align: 'center',
isSortable: true,
}) as const,
),
];
return (
<>
<PageContent
disableLoading
disablePadding
header={
<ProjectFeatureTogglesHeader
totalItems={total}
searchQuery={tableState.query || ''}
onChangeSearchQuery={(query) =>
setTableState({ query })
}
isLoading={initialLoad}
dataToExport={features} // FIXME: selected columns?
environmentsToExport={environments.map(
({ environment }) => environment, // FIXME: visible env columns?
)}
/>
}
>
<div
ref={bodyLoadingRef}
aria-busy={loading}
aria-live='polite'
>
<SearchHighlightProvider value={tableState.query || ''}>
<Table>
<TableHead>
<TableRow>
{columns.map((column) => (
<HeaderCell
column={column}
tableState={tableState}
setTableState={setTableState}
key={column.id}
/>
))}
</TableRow>
</TableHead>
<TableBody
role='rowgroup'
sx={{
'& tr': {
'&:hover': {
'.show-row-hover': {
opacity: 1,
},
},
},
}}
>
{data.map((feature) => (
<TableRow key={feature.name}>
{columns.map((column) => (
<TableCell key={column.id}>
{column.cell(feature)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<ConditionallyRender
condition={data.length > 0}
show={
<StickyPaginationBar
totalItems={total}
pageIndex={
tableState.offset / tableState.limit
}
pageSize={tableState.limit}
fetchNextPage={() =>
setTableState({
offset:
tableState.offset +
tableState.limit,
})
}
fetchPrevPage={() =>
setTableState({
offset:
tableState.offset -
tableState.limit,
})
}
setPageLimit={(pageSize) =>
setTableState({
offset: 0,
limit: pageSize,
})
}
/>
}
/>
</SearchHighlightProvider>
</div>
</PageContent>
</>
);
};

View File

@ -82,7 +82,7 @@ const FeatureToggleCellComponent = ({
);
};
const MemoizedFeatureToggleCell = React.memo(FeatureToggleCellComponent);
export const MemoizedFeatureToggleCell = React.memo(FeatureToggleCellComponent);
export const createFeatureToggleCell =
(

View File

@ -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<FeatureSchema, 'name'>[];
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 (
<Box
ref={headerLoadingRef}
aria-busy={isLoading}
aria-live='polite'
sx={(theme) => ({
padding: `${theme.spacing(2.5)} ${theme.spacing(3.125)}`,
})}
>
<PageHeader
titleElement={
showTitle
? `Feature toggles ${
totalItems !== undefined ? `(${totalItems})` : ''
}`
: null
}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
data-loading
placeholder='Search and Filter'
expandable
initialValue={searchQuery || ''}
onChange={handleSearch}
onFocus={() => setShowTitle(false)}
onBlur={() => setShowTitle(true)}
hasFilters
id='projectFeatureToggles'
/>
}
/>
{/* FIXME: columns menu */}
{/* <ColumnsMenu
allColumns={allColumns}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={isCustomColumns}
setHiddenColumns={setHiddenColumns}
onCustomize={() => setIsCustomColumns(true)}
/> */}
<PageHeader.Divider sx={{ marginLeft: 0 }} />
<ConditionallyRender
condition={featuresExportImportFlag}
show={
<>
<Tooltip
title='Export toggles visible in the table below'
arrow
>
<IconButton
data-loading
onClick={() =>
setShowExportDialog(true)
}
sx={(theme) => ({
marginRight: theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
<ConditionallyRender
condition={!isLoading}
show={
<ExportDialog
showExportDialog={
showExportDialog
}
data={dataToExport || []}
onClose={() =>
setShowExportDialog(false)
}
environments={
environmentsToExport || []
}
/>
}
/>
</>
}
/>
<StyledResponsiveButton
onClick={() =>
navigate(getCreateTogglePath(projectId))
}
maxWidth='960px'
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
data-testid='NAVIGATE_TO_CREATE_FEATURE'
>
New feature toggle
</StyledResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchQuery || ''}
onChange={handleSearch}
hasFilters
id='projectFeatureToggles'
/>
}
/>
</PageHeader>
</Box>
);
};