mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-23 13:46:45 +02:00
feat: connect search and filter with server api (#5297)
This commit is contained in:
parent
fa85e10eac
commit
24f9fa3058
@ -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<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
||||||
|
|
||||||
|
const defaultSort: SortingRule<string> & {
|
||||||
|
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) => (
|
||||||
|
<Checkbox {...getToggleAllRowsSelectedProps()} />
|
||||||
|
),
|
||||||
|
Cell: ({ row }: any) => (
|
||||||
|
<MemoizedRowSelectCell
|
||||||
|
{...row?.getToggleRowSelectedProps?.()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
maxWidth: 50,
|
||||||
|
disableSortBy: true,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'favorite',
|
||||||
|
Header: (
|
||||||
|
<FavoriteIconHeader
|
||||||
|
isActive={isFavoritesPinned}
|
||||||
|
onClick={onChangeIsFavoritePinned}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
accessor: 'favorite',
|
||||||
|
Cell: ({ row: { original: feature } }: any) => (
|
||||||
|
<FavoriteIconCell
|
||||||
|
value={feature?.favorite}
|
||||||
|
onClick={() => onFavorite(feature)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
maxWidth: 50,
|
||||||
|
disableSortBy: true,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Seen',
|
||||||
|
accessor: 'lastSeenAt',
|
||||||
|
Cell: ({ value, row: { original: feature } }: any) => {
|
||||||
|
return showEnvironmentLastSeen ? (
|
||||||
|
<MemoizedFeatureEnvironmentSeenCell feature={feature} />
|
||||||
|
) : (
|
||||||
|
<FeatureSeenCell value={value} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
|
maxWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: FeatureTypeCell,
|
||||||
|
align: 'center',
|
||||||
|
filterName: 'type',
|
||||||
|
maxWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: ({ value }: { value: string }) => (
|
||||||
|
<Tooltip title={value} arrow describeChild>
|
||||||
|
<span>
|
||||||
|
<LinkCell
|
||||||
|
title={value}
|
||||||
|
to={`/projects/${projectId}/features/${value}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
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 } }) => (
|
||||||
|
<ActionsCell
|
||||||
|
projectId={projectId}
|
||||||
|
onOpenArchiveDialog={setFeatureArchiveState}
|
||||||
|
onOpenStaleDialog={setFeatureStaleDialogState}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
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<string, string> = {};
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
className={styles.container}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
titleElement={
|
||||||
|
showTitle
|
||||||
|
? `Feature toggles (${total || rows.length})`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
placeholder='Search and Filter'
|
||||||
|
expandable
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
onFocus={() => setShowTitle(false)}
|
||||||
|
onBlur={() => setShowTitle(true)}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
id='projectFeatureToggles'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ColumnsMenu
|
||||||
|
allColumns={allColumns}
|
||||||
|
staticColumns={staticColumns}
|
||||||
|
dividerAfter={['createdAt']}
|
||||||
|
dividerBefore={['Actions']}
|
||||||
|
isCustomized={Boolean(storedParams.columns)}
|
||||||
|
setHiddenColumns={setHiddenColumns}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider sx={{ marginLeft: 0 }} />
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(
|
||||||
|
uiConfig?.flags?.featuresExportImport,
|
||||||
|
)}
|
||||||
|
show={
|
||||||
|
<Tooltip
|
||||||
|
title='Export toggles visible in the table below'
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setShowExportDialog(true)
|
||||||
|
}
|
||||||
|
sx={(theme) => ({
|
||||||
|
marginRight:
|
||||||
|
theme.spacing(2),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FileDownload />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
id='projectFeatureToggles'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No feature toggles found matching “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No feature toggles available. Get started by
|
||||||
|
adding a new feature toggle.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<EnvironmentStrategyDialog
|
||||||
|
onClose={() =>
|
||||||
|
setStrategiesDialogState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
open: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
projectId={projectId}
|
||||||
|
{...strategiesDialogState}
|
||||||
|
/>
|
||||||
|
<FeatureStaleDialog
|
||||||
|
isStale={featureStaleDialogState.stale === true}
|
||||||
|
isOpen={Boolean(featureStaleDialogState.featureId)}
|
||||||
|
onClose={() => {
|
||||||
|
setFeatureStaleDialogState({});
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
featureId={featureStaleDialogState.featureId || ''}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
<FeatureArchiveDialog
|
||||||
|
isOpen={Boolean(featureArchiveState)}
|
||||||
|
onConfirm={onChange}
|
||||||
|
onClose={() => {
|
||||||
|
setFeatureArchiveState(undefined);
|
||||||
|
}}
|
||||||
|
featureIds={[featureArchiveState || '']}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={
|
||||||
|
Boolean(uiConfig?.flags?.featuresExportImport) &&
|
||||||
|
!loading
|
||||||
|
}
|
||||||
|
show={
|
||||||
|
<ExportDialog
|
||||||
|
showExportDialog={showExportDialog}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setShowExportDialog(false)}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{featureToggleModals}
|
||||||
|
</PageContent>
|
||||||
|
<BatchSelectionActionsBar
|
||||||
|
count={Object.keys(selectedRowIds).length}
|
||||||
|
>
|
||||||
|
<ProjectFeaturesBatchActions
|
||||||
|
selectedIds={Object.keys(selectedRowIds)}
|
||||||
|
data={features}
|
||||||
|
projectId={projectId}
|
||||||
|
onResetSelection={() => toggleAllRowsSelected(false)}
|
||||||
|
/>
|
||||||
|
</BatchSelectionActionsBar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -83,6 +83,9 @@ const defaultSort: SortingRule<string> & {
|
|||||||
columns?: string[];
|
columns?: string[];
|
||||||
} = { id: 'createdAt' };
|
} = { id: 'createdAt' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated remove when flag `featureSearchFrontend` is removed
|
||||||
|
*/
|
||||||
export const ProjectFeatureToggles = ({
|
export const ProjectFeatureToggles = ({
|
||||||
features,
|
features,
|
||||||
loading,
|
loading,
|
||||||
|
@ -13,6 +13,8 @@ import { ProjectStats } from './ProjectStats/ProjectStats';
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||||
|
import { PaginatedProjectFeatureToggles } from './ProjectFeatureToggles/PaginatedProjectFeatureToggles';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
const refreshInterval = 15 * 1000;
|
const refreshInterval = 15 * 1000;
|
||||||
|
|
||||||
@ -39,16 +41,22 @@ const PAGE_LIMIT = 25;
|
|||||||
|
|
||||||
const PaginatedProjectOverview = () => {
|
const PaginatedProjectOverview = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { project, loading: projectLoading } = useProject(projectId, {
|
const { project, loading: projectLoading } = useProject(projectId, {
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
});
|
});
|
||||||
const [currentOffset, setCurrentOffset] = useState(0);
|
const [currentOffset, setCurrentOffset] = useState(0);
|
||||||
|
|
||||||
|
const [searchValue, setSearchValue] = useState(
|
||||||
|
searchParams.get('search') || '',
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
features: searchFeatures,
|
features: searchFeatures,
|
||||||
total,
|
total,
|
||||||
refetch,
|
refetch,
|
||||||
loading,
|
loading,
|
||||||
} = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, {
|
} = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, searchValue, {
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +87,7 @@ const PaginatedProjectOverview = () => {
|
|||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<ProjectStats stats={project.stats} />
|
<ProjectStats stats={project.stats} />
|
||||||
<StyledProjectToggles>
|
<StyledProjectToggles>
|
||||||
<ProjectFeatureToggles
|
<PaginatedProjectFeatureToggles
|
||||||
key={
|
key={
|
||||||
loading && searchFeatures.length === 0
|
loading && searchFeatures.length === 0
|
||||||
? 'loading'
|
? 'loading'
|
||||||
@ -90,6 +98,8 @@ const PaginatedProjectOverview = () => {
|
|||||||
loading={loading && searchFeatures.length === 0}
|
loading={loading && searchFeatures.length === 0}
|
||||||
onChange={refetch}
|
onChange={refetch}
|
||||||
total={total}
|
total={total}
|
||||||
|
searchValue={searchValue}
|
||||||
|
setSearchValue={setSearchValue}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasPreviousPage}
|
condition={hasPreviousPage}
|
||||||
@ -105,6 +115,9 @@ const PaginatedProjectOverview = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated remove when flag `featureSearchFrontend` is removed
|
||||||
|
*/
|
||||||
const ProjectOverview = () => {
|
const ProjectOverview = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const projectName = useProjectNameOrId(projectId);
|
const projectName = useProjectNameOrId(projectId);
|
||||||
|
@ -36,10 +36,11 @@ describe('translateToQueryParams', () => {
|
|||||||
'development:enabled,disabled',
|
'development:enabled,disabled',
|
||||||
'status[]=development:enabled&status[]=development:disabled',
|
'status[]=development:enabled&status[]=development:disabled',
|
||||||
],
|
],
|
||||||
['tag:simple:web', 'tag[]=simple:web'],
|
['tags:simple:web', 'tag[]=simple:web'],
|
||||||
['tag:enabled:enabled', 'tag[]=enabled:enabled'],
|
['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',
|
'tag[]=simple:web&tag[]=complex:native',
|
||||||
],
|
],
|
||||||
])('when input is "%s"', (input, expected) => {
|
])('when input is "%s"', (input, expected) => {
|
||||||
|
@ -45,7 +45,7 @@ const handleFilter = (
|
|||||||
|
|
||||||
if (isStatusFilter(key, values)) {
|
if (isStatusFilter(key, values)) {
|
||||||
return addStatusFilters(key, values, filterParams);
|
return addStatusFilters(key, values, filterParams);
|
||||||
} else if (key === 'tag') {
|
} else if (key === 'tags') {
|
||||||
return addTagFilters(values, filterParams);
|
return addTagFilters(values, filterParams);
|
||||||
} else {
|
} else {
|
||||||
return addRegularFilters(key, values, filterParams);
|
return addRegularFilters(key, values, filterParams);
|
||||||
|
@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
|||||||
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
|
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { translateToQueryParams } from './searchToQueryParams';
|
||||||
|
|
||||||
type IFeatureSearchResponse = {
|
type IFeatureSearchResponse = {
|
||||||
features: IFeatureToggleListItem[];
|
features: IFeatureToggleListItem[];
|
||||||
@ -29,9 +30,15 @@ export const useFeatureSearch = (
|
|||||||
offset: number,
|
offset: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
projectId = '',
|
projectId = '',
|
||||||
|
searchValue = '',
|
||||||
options: SWRConfiguration = {},
|
options: SWRConfiguration = {},
|
||||||
): IUseFeatureSearchOutput => {
|
): IUseFeatureSearchOutput => {
|
||||||
const { KEY, fetcher } = getFeatureSearchFetcher(projectId, offset, limit);
|
const { KEY, fetcher } = getFeatureSearchFetcher(
|
||||||
|
projectId,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
searchValue,
|
||||||
|
);
|
||||||
const { data, error, mutate } = useSWR<IFeatureSearchResponse>(
|
const { data, error, mutate } = useSWR<IFeatureSearchResponse>(
|
||||||
KEY,
|
KEY,
|
||||||
fetcher,
|
fetcher,
|
||||||
@ -45,7 +52,7 @@ export const useFeatureSearch = (
|
|||||||
const returnData = data || fallbackData;
|
const returnData = data || fallbackData;
|
||||||
return {
|
return {
|
||||||
...returnData,
|
...returnData,
|
||||||
loading: !error && !data,
|
loading: false,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
};
|
};
|
||||||
@ -55,9 +62,10 @@ const getFeatureSearchFetcher = (
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
offset: number,
|
offset: number,
|
||||||
limit: 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 fetcher = () => {
|
||||||
const path = formatApiPath(KEY);
|
const path = formatApiPath(KEY);
|
||||||
return fetch(path, {
|
return fetch(path, {
|
||||||
|
@ -21,7 +21,6 @@ export class FeatureSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(params: IFeatureSearchParams) {
|
async search(params: IFeatureSearchParams) {
|
||||||
// fetch one more item than needed to get a cursor of the next item
|
|
||||||
const { features, total } =
|
const { features, total } =
|
||||||
await this.featureStrategiesStore.searchFeatures({
|
await this.featureStrategiesStore.searchFeatures({
|
||||||
...params,
|
...params,
|
||||||
|
Loading…
Reference in New Issue
Block a user