mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: new search for feature toggle list table (#5454)
Filtering of feature toggles list with backend
This commit is contained in:
parent
bb03253681
commit
f690fe86da
@ -13,12 +13,17 @@ import { FilterItemChip } from './FilterItemChip/FilterItemChip';
|
||||
interface IFilterItemProps {
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const singularOperators = ['IS', 'IS_NOT'];
|
||||
const pluralOperators = ['IS_IN', 'IS_NOT_IN'];
|
||||
const pluralOperators = ['IS_ANY_OF', 'IS_NOT_ANY_OF'];
|
||||
|
||||
export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => {
|
||||
export const FilterItem: FC<IFilterItemProps> = ({
|
||||
label,
|
||||
options,
|
||||
onChange,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<typeof options>([]);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
|
||||
@ -35,8 +40,28 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleOnChange = (
|
||||
op: typeof operator,
|
||||
values: typeof selectedOptions,
|
||||
) => {
|
||||
const value = values.length
|
||||
? `${op}:${values?.map((option) => option.value).join(', ')}`
|
||||
: '';
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
const handleOperatorChange = (value: string) => {
|
||||
setOperator(value);
|
||||
handleOnChange(value, selectedOptions);
|
||||
};
|
||||
|
||||
const handleOptionsChange = (values: typeof selectedOptions) => {
|
||||
setSelectedOptions(values);
|
||||
handleOnChange(operator, values);
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
setSelectedOptions([]);
|
||||
handleOptionsChange([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -46,19 +71,19 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => {
|
||||
(selectedOption) => selectedOption.value === value,
|
||||
)
|
||||
) {
|
||||
setSelectedOptions(
|
||||
selectedOptions?.filter(
|
||||
(selectedOption) => selectedOption.value !== value,
|
||||
),
|
||||
const newOptions = selectedOptions?.filter(
|
||||
(selectedOption) => selectedOption.value !== value,
|
||||
);
|
||||
handleOptionsChange(newOptions);
|
||||
} else {
|
||||
setSelectedOptions([
|
||||
const newOptions = [
|
||||
...(selectedOptions ?? []),
|
||||
options.find((option) => option.value === value) ?? {
|
||||
label: '',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
];
|
||||
handleOptionsChange(newOptions);
|
||||
}
|
||||
};
|
||||
|
||||
@ -80,7 +105,7 @@ export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => {
|
||||
onClick={onClick}
|
||||
operator={operator}
|
||||
operatorOptions={currentOperators}
|
||||
onChangeOperator={setOperator}
|
||||
onChangeOperator={handleOperatorChange}
|
||||
/>
|
||||
</Box>
|
||||
<StyledPopover
|
||||
|
@ -176,7 +176,9 @@ export const Search = ({
|
||||
</StyledSearch>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={Boolean(hasFilters) && showSuggestions}
|
||||
condition={
|
||||
Boolean(hasFilters && getSearchContext) && showSuggestions
|
||||
}
|
||||
show={
|
||||
<SearchSuggestions
|
||||
onSuggestion={(suggestion) => {
|
||||
|
@ -0,0 +1,41 @@
|
||||
import { VFC } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { FilterItem } from 'component/common/FilterItem/FilterItem';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useTableState } from 'hooks/useTableState';
|
||||
|
||||
export type FeatureTogglesListFilters = {
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
interface IFeatureToggleFiltersProps {
|
||||
state: FeatureTogglesListFilters;
|
||||
onChange: (value: FeatureTogglesListFilters) => void;
|
||||
}
|
||||
|
||||
export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
|
||||
state,
|
||||
onChange,
|
||||
}) => {
|
||||
const { projects } = useProjects();
|
||||
const projectsOptions = (projects || []).map((project) => ({
|
||||
label: project.name,
|
||||
value: project.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box sx={(theme) => ({ marginBottom: theme.spacing(2) })}>
|
||||
<ConditionallyRender
|
||||
condition={projectsOptions.length > 1}
|
||||
show={() => (
|
||||
<FilterItem
|
||||
label='Project'
|
||||
options={projectsOptions}
|
||||
onChange={(value) => onChange({ projectId: value })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -1,37 +1,30 @@
|
||||
import { useCallback, useEffect, useMemo, useState, VFC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Link,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useFlexLayout, usePagination, useSortBy, useTable } from 'react-table';
|
||||
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
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 { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { FeatureSchema } from 'openapi';
|
||||
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
|
||||
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
||||
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
|
||||
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
||||
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
|
||||
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
|
||||
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
@ -40,8 +33,20 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { focusable } from 'themes/themeStyles';
|
||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { FilterItem } from 'component/common/FilterItem/FilterItem';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import {
|
||||
FeatureToggleFilters,
|
||||
FeatureTogglesListFilters,
|
||||
} from './FeatureToggleFilters/FeatureToggleFilters';
|
||||
import {
|
||||
DEFAULT_PAGE_LIMIT,
|
||||
useFeatureSearch,
|
||||
} from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
|
||||
import {
|
||||
defaultQueryKeys,
|
||||
defaultStoredKeys,
|
||||
useTableState,
|
||||
} from 'hooks/useTableState';
|
||||
|
||||
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||
name: 'Name of the feature',
|
||||
@ -55,12 +60,15 @@ export type PageQueryType = Partial<
|
||||
Record<'sort' | 'order' | 'search' | 'favorites', string>
|
||||
>;
|
||||
|
||||
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true };
|
||||
|
||||
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||
'FeatureToggleListTable:v1',
|
||||
defaultSort,
|
||||
);
|
||||
type FeatureToggleListState = {
|
||||
page: string;
|
||||
pageSize: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
projectId?: string;
|
||||
search?: string;
|
||||
favorites?: string;
|
||||
} & FeatureTogglesListFilters;
|
||||
|
||||
export const FeatureToggleListTable: VFC = () => {
|
||||
const theme = useTheme();
|
||||
@ -71,36 +79,54 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const { features = [], loading, refetchFeatures } = useFeatures();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
|
||||
const [tableState, setTableState] = useTableState<FeatureToggleListState>(
|
||||
{
|
||||
page: '1',
|
||||
pageSize: `${DEFAULT_PAGE_LIMIT}`,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
projectId: '',
|
||||
search: '',
|
||||
favorites: 'true',
|
||||
},
|
||||
'featureToggleList',
|
||||
[...defaultQueryKeys, 'projectId'],
|
||||
[...defaultStoredKeys, 'projectId'],
|
||||
);
|
||||
const offset = (Number(tableState.page) - 1) * Number(tableState?.pageSize);
|
||||
const {
|
||||
features = [],
|
||||
loading,
|
||||
refetch: refetchFeatures,
|
||||
} = useFeatureSearch(
|
||||
offset,
|
||||
Number(tableState.pageSize),
|
||||
{
|
||||
sortBy: tableState.sortBy || 'createdAt',
|
||||
sortOrder: tableState.sortOrder || 'desc',
|
||||
favoritesFirst: tableState.favorites === 'true',
|
||||
},
|
||||
tableState.projectId || undefined,
|
||||
tableState.search || '',
|
||||
);
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [
|
||||
{
|
||||
id: searchParams.get('sort') || storedParams.id,
|
||||
desc: searchParams.has('order')
|
||||
? searchParams.get('order') === 'desc'
|
||||
: storedParams.desc,
|
||||
id: tableState.sortBy || 'createdAt',
|
||||
desc: tableState.sortOrder === 'desc',
|
||||
},
|
||||
],
|
||||
hiddenColumns: ['description'],
|
||||
globalFilter: searchParams.get('search') || '',
|
||||
pageSize: Number(tableState.pageSize),
|
||||
pageIndex: Number(tableState.page) - 1,
|
||||
}));
|
||||
const { value: globalStore, setValue: setGlobalStore } =
|
||||
useGlobalLocalStorage();
|
||||
const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } =
|
||||
usePinnedFavorites(
|
||||
searchParams.has('favorites')
|
||||
? searchParams.get('favorites') === 'true'
|
||||
: globalStore.favorites,
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||
const onFavorite = useCallback(
|
||||
async (feature: any) => {
|
||||
// FIXME: projectId is missing
|
||||
try {
|
||||
if (feature?.favorite) {
|
||||
await unfavorite(feature.project, feature.name);
|
||||
@ -122,8 +148,15 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
{
|
||||
Header: (
|
||||
<FavoriteIconHeader
|
||||
isActive={isFavoritesPinned}
|
||||
onClick={onChangeIsFavoritePinned}
|
||||
isActive={tableState.favorites === 'true'}
|
||||
onClick={() =>
|
||||
setTableState({
|
||||
favorites:
|
||||
tableState.favorites === 'true'
|
||||
? 'false'
|
||||
: 'true',
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
accessor: 'favorite',
|
||||
@ -194,38 +227,22 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
Cell: FeatureStaleCell,
|
||||
sortType: 'boolean',
|
||||
maxWidth: 120,
|
||||
filterName: 'state',
|
||||
filterParsing: (value: any) => (value ? 'stale' : 'active'),
|
||||
},
|
||||
// Always hidden -- for search
|
||||
{
|
||||
accessor: 'description',
|
||||
Header: 'Description',
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
[isFavoritesPinned],
|
||||
[tableState.favorites],
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, features);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
searchedData?.length === 0 && loading
|
||||
? featuresPlaceholder
|
||||
: searchedData,
|
||||
[searchedData, loading],
|
||||
features?.length === 0 && loading ? featuresPlaceholder : features,
|
||||
[features, loading],
|
||||
);
|
||||
|
||||
const {
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
state: { sortBy },
|
||||
state: { pageIndex, pageSize, sortBy },
|
||||
setHiddenColumns,
|
||||
} = useTable(
|
||||
{
|
||||
@ -237,11 +254,23 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
manualSortBy: true,
|
||||
manualPagination: true,
|
||||
},
|
||||
useSortBy,
|
||||
useFlexLayout,
|
||||
usePagination,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState({
|
||||
page: `${pageIndex + 1}`,
|
||||
pageSize: `${pageSize}`,
|
||||
sortBy: sortBy[0]?.id || 'createdAt',
|
||||
sortOrder: sortBy[0]?.desc ? 'desc' : 'asc',
|
||||
});
|
||||
}, [pageIndex, pageSize, sortBy]);
|
||||
|
||||
useConditionallyHiddenColumns(
|
||||
[
|
||||
{
|
||||
@ -260,32 +289,7 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
setHiddenColumns,
|
||||
columns,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const tableState: PageQueryType = {};
|
||||
tableState.sort = sortBy[0].id;
|
||||
if (sortBy[0].desc) {
|
||||
tableState.order = 'desc';
|
||||
}
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
if (isFavoritesPinned) {
|
||||
tableState.favorites = 'true';
|
||||
}
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
setStoredParams({
|
||||
id: sortBy[0].id,
|
||||
desc: sortBy[0].desc || false,
|
||||
});
|
||||
setGlobalStore((params) => ({
|
||||
...params,
|
||||
favorites: Boolean(isFavoritesPinned),
|
||||
}));
|
||||
}, [sortBy, searchValue, setSearchParams, isFavoritesPinned]);
|
||||
const setSearchValue = (search = '') => setTableState({ search });
|
||||
|
||||
if (!(environments.length > 0)) {
|
||||
return null;
|
||||
@ -308,12 +312,10 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
placeholder='Search and Filter'
|
||||
placeholder='Search'
|
||||
expandable
|
||||
initialValue={searchValue}
|
||||
initialValue={tableState.search}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
@ -361,38 +363,16 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
initialValue={tableState.search}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
{featureSearchFrontend && (
|
||||
<Box sx={(theme) => ({ marginBottom: theme.spacing(2) })}>
|
||||
<FilterItem
|
||||
label='Project'
|
||||
options={[
|
||||
{
|
||||
label: 'Project 1',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
label: 'Test',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
label: 'Default',
|
||||
value: '3',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<FeatureToggleFilters state={tableState} onChange={setTableState} />
|
||||
<SearchHighlightProvider value={tableState.search || ''}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
@ -403,11 +383,11 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
condition={(tableState.search || '')?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No feature toggles found matching “
|
||||
{searchValue}
|
||||
{tableState.search}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
|
@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useMemo, useState, VFC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Link,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||
import { useFeatures } from 'hooks/api/getters/useFeatures/useFeatures';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
|
||||
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { FeatureSchema } from 'openapi';
|
||||
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
|
||||
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
||||
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
|
||||
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
||||
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
|
||||
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
|
||||
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
import { ExportDialog } from './ExportDialog';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { focusable } from 'themes/themeStyles';
|
||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||
import useToast from 'hooks/useToast';
|
||||
|
||||
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||
name: 'Name of the feature',
|
||||
description: 'Short description of the feature',
|
||||
type: '-',
|
||||
createdAt: new Date(2022, 1, 1),
|
||||
project: 'projectID',
|
||||
});
|
||||
|
||||
export type PageQueryType = Partial<
|
||||
Record<'sort' | 'order' | 'search' | 'favorites', string>
|
||||
>;
|
||||
|
||||
const defaultSort: SortingRule<string> = { id: 'createdAt', desc: true };
|
||||
|
||||
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||
'FeatureToggleListTable:v1',
|
||||
defaultSort,
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated remove with flag `featureSearchFrontend`
|
||||
*/
|
||||
export const FeatureToggleListTable: VFC = () => {
|
||||
const theme = useTheme();
|
||||
const { environments } = useEnvironments();
|
||||
const enabledEnvironments = environments
|
||||
.filter((env) => env.enabled)
|
||||
.map((env) => env.name);
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const { features = [], loading, refetchFeatures } = useFeatures();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [
|
||||
{
|
||||
id: searchParams.get('sort') || storedParams.id,
|
||||
desc: searchParams.has('order')
|
||||
? searchParams.get('order') === 'desc'
|
||||
: storedParams.desc,
|
||||
},
|
||||
],
|
||||
hiddenColumns: ['description'],
|
||||
globalFilter: searchParams.get('search') || '',
|
||||
}));
|
||||
const { value: globalStore, setValue: setGlobalStore } =
|
||||
useGlobalLocalStorage();
|
||||
const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } =
|
||||
usePinnedFavorites(
|
||||
searchParams.has('favorites')
|
||||
? searchParams.get('favorites') === 'true'
|
||||
: globalStore.favorites,
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||
const onFavorite = useCallback(
|
||||
async (feature: any) => {
|
||||
try {
|
||||
if (feature?.favorite) {
|
||||
await unfavorite(feature.project, feature.name);
|
||||
} else {
|
||||
await favorite(feature.project, feature.name);
|
||||
}
|
||||
refetchFeatures();
|
||||
} catch (error) {
|
||||
setToastApiError(
|
||||
'Something went wrong, could not update favorite',
|
||||
);
|
||||
}
|
||||
},
|
||||
[favorite, refetchFeatures, unfavorite, setToastApiError],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: (
|
||||
<FavoriteIconHeader
|
||||
isActive={isFavoritesPinned}
|
||||
onClick={onChangeIsFavoritePinned}
|
||||
/>
|
||||
),
|
||||
accessor: 'favorite',
|
||||
Cell: ({ row: { original: feature } }: any) => (
|
||||
<FavoriteIconCell
|
||||
value={feature?.favorite}
|
||||
onClick={() => onFavorite(feature)}
|
||||
/>
|
||||
),
|
||||
maxWidth: 50,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: 'Seen',
|
||||
accessor: 'lastSeenAt',
|
||||
Cell: ({ value, row: { original: feature } }: any) => {
|
||||
return <FeatureEnvironmentSeenCell feature={feature} />;
|
||||
},
|
||||
align: 'center',
|
||||
maxWidth: 80,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'type',
|
||||
Cell: FeatureTypeCell,
|
||||
align: 'center',
|
||||
maxWidth: 85,
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
minWidth: 150,
|
||||
Cell: FeatureNameCell,
|
||||
sortType: 'alphanumeric',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
Header: 'Tags',
|
||||
accessor: (row: FeatureSchema) =>
|
||||
row.tags
|
||||
?.map(({ type, value }) => `${type}:${value}`)
|
||||
.join('\n') || '',
|
||||
Cell: FeatureTagCell,
|
||||
width: 80,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
maxWidth: 150,
|
||||
},
|
||||
{
|
||||
Header: 'Project ID',
|
||||
accessor: 'project',
|
||||
Cell: ({ value }: { value: string }) => (
|
||||
<LinkCell title={value} to={`/projects/${value}`} />
|
||||
),
|
||||
sortType: 'alphanumeric',
|
||||
maxWidth: 150,
|
||||
filterName: 'project',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'State',
|
||||
accessor: 'stale',
|
||||
Cell: FeatureStaleCell,
|
||||
sortType: 'boolean',
|
||||
maxWidth: 120,
|
||||
filterName: 'state',
|
||||
filterParsing: (value: any) => (value ? 'stale' : 'active'),
|
||||
},
|
||||
// Always hidden -- for search
|
||||
{
|
||||
accessor: 'description',
|
||||
Header: 'Description',
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
[isFavoritesPinned],
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, features);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
searchedData?.length === 0 && loading
|
||||
? featuresPlaceholder
|
||||
: searchedData,
|
||||
[searchedData, loading],
|
||||
);
|
||||
|
||||
const {
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
state: { sortBy },
|
||||
setHiddenColumns,
|
||||
} = useTable(
|
||||
{
|
||||
columns: columns as any[],
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetHiddenColumns: false,
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
},
|
||||
useSortBy,
|
||||
useFlexLayout,
|
||||
);
|
||||
|
||||
useConditionallyHiddenColumns(
|
||||
[
|
||||
{
|
||||
condition: !features.some(({ tags }) => tags?.length),
|
||||
columns: ['tags'],
|
||||
},
|
||||
{
|
||||
condition: isSmallScreen,
|
||||
columns: ['type', 'createdAt', 'tags'],
|
||||
},
|
||||
{
|
||||
condition: isMediumScreen,
|
||||
columns: ['lastSeenAt', 'stale'],
|
||||
},
|
||||
],
|
||||
setHiddenColumns,
|
||||
columns,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const tableState: PageQueryType = {};
|
||||
tableState.sort = sortBy[0].id;
|
||||
if (sortBy[0].desc) {
|
||||
tableState.order = 'desc';
|
||||
}
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
if (isFavoritesPinned) {
|
||||
tableState.favorites = 'true';
|
||||
}
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
setStoredParams({
|
||||
id: sortBy[0].id,
|
||||
desc: sortBy[0].desc || false,
|
||||
});
|
||||
setGlobalStore((params) => ({
|
||||
...params,
|
||||
favorites: Boolean(isFavoritesPinned),
|
||||
}));
|
||||
}, [sortBy, searchValue, setSearchParams, isFavoritesPinned]);
|
||||
|
||||
if (!(environments.length > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Feature toggles (${
|
||||
rows.length < data.length
|
||||
? `${rows.length} of ${data.length}`
|
||||
: data.length
|
||||
})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
placeholder='Search and Filter'
|
||||
expandable
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to='/archive'
|
||||
underline='always'
|
||||
sx={{ marginRight: 2, ...focusable(theme) }}
|
||||
>
|
||||
View archive
|
||||
</Link>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(
|
||||
uiConfig?.flags?.featuresExportImport,
|
||||
)}
|
||||
show={
|
||||
<Tooltip
|
||||
title='Export current selection'
|
||||
arrow
|
||||
>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setShowExportDialog(true)
|
||||
}
|
||||
sx={(theme) => ({
|
||||
marginRight: theme.spacing(2),
|
||||
})}
|
||||
>
|
||||
<FileDownload />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateFeatureButton
|
||||
loading={false}
|
||||
filter={{ query: '', project: 'default' }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
|
||||
show={
|
||||
<ExportDialog
|
||||
showExportDialog={showExportDialog}
|
||||
data={data}
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
environments={enabledEnvironments}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -115,6 +115,7 @@ const StyledIconButton = styled(IconButton)<{
|
||||
}));
|
||||
|
||||
const Header: VFC = () => {
|
||||
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
|
||||
const { onSetThemeMode, themeMode } = useThemeMode();
|
||||
const theme = useTheme();
|
||||
const adminId = useId();
|
||||
@ -191,7 +192,15 @@ const Header: VFC = () => {
|
||||
<StyledNav>
|
||||
<StyledLinks>
|
||||
<StyledLink to='/projects'>Projects</StyledLink>
|
||||
<StyledLink to='/features'>Feature toggles</StyledLink>
|
||||
<StyledLink
|
||||
to={
|
||||
featureSearchFrontend
|
||||
? '/features-new'
|
||||
: '/features'
|
||||
}
|
||||
>
|
||||
Feature toggles
|
||||
</StyledLink>
|
||||
<StyledLink to='/playground'>Playground</StyledLink>
|
||||
<StyledAdvancedNavButton
|
||||
onClick={(e) => setConfigRef(e.currentTarget)}
|
||||
|
@ -123,6 +123,16 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Feature toggles",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"flag": "featureSearchFrontend",
|
||||
"menu": {
|
||||
"mobile": true,
|
||||
},
|
||||
"path": "/features-new",
|
||||
"title": "Feature toggles",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": {
|
||||
"$$typeof": Symbol(react.lazy),
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { FeatureToggleListTable } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
|
||||
import { FeatureToggleListTable as LegacyFeatureToggleListTable } from 'component/feature/FeatureToggleList/LegacyFeatureToggleListTable';
|
||||
import { StrategyView } from 'component/strategies/StrategyView/StrategyView';
|
||||
import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesList';
|
||||
import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList';
|
||||
@ -144,9 +145,17 @@ export const routes: IRoute[] = [
|
||||
{
|
||||
path: '/features',
|
||||
title: 'Feature toggles',
|
||||
component: LegacyFeatureToggleListTable,
|
||||
type: 'protected',
|
||||
menu: { mobile: true },
|
||||
},
|
||||
{
|
||||
path: '/features-new',
|
||||
title: 'Feature toggles',
|
||||
component: FeatureToggleListTable,
|
||||
type: 'protected',
|
||||
menu: { mobile: true },
|
||||
flag: 'featureSearchFrontend',
|
||||
},
|
||||
|
||||
// Playground
|
||||
|
@ -128,7 +128,6 @@ const getFeatureSearchFetcher = (
|
||||
const searchQueryParams = translateToQueryParams(searchValue);
|
||||
const sortQueryParams = translateToSortQueryParams(sortingRules);
|
||||
const project = projectId ? `projectId=${projectId}&` : '';
|
||||
|
||||
const KEY = `api/admin/search/features?${project}offset=${offset}&limit=${limit}&${searchQueryParams}&${sortQueryParams}`;
|
||||
const fetcher = () => {
|
||||
const path = formatApiPath(KEY);
|
||||
|
Loading…
Reference in New Issue
Block a user