diff --git a/packages/manager-react-components/src/components/datagrid/datagrid-cursor.stories.tsx b/packages/manager-react-components/src/components/datagrid/datagrid-cursor.stories.tsx index 293f5a29d0eb..a520cdb2ce4c 100644 --- a/packages/manager-react-components/src/components/datagrid/datagrid-cursor.stories.tsx +++ b/packages/manager-react-components/src/components/datagrid/datagrid-cursor.stories.tsx @@ -1,9 +1,12 @@ import React, { useState } from 'react'; import { ColumnSort } from '@tanstack/react-table'; +import { OdsDivider } from '@ovhcloud/ods-components/react'; import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; import { withRouter } from 'storybook-addon-react-router-v6'; -import { Datagrid, DatagridProps } from './datagrid.component'; -import { DataGridTextCell } from './text-cell.component'; +import { useSearchParams } from 'react-router-dom'; +import { Datagrid } from './datagrid.component'; +import { useColumnFilters } from '../filters'; +import { columns, columsFilters } from './datagrid.mock'; import { ActionMenu } from '../navigation'; interface Item { @@ -12,30 +15,11 @@ interface Item { actions: React.ReactElement; } -const columns = [ - { - id: 'label', - cell: (item: Item) => { - return {item.label}; - }, - label: 'Label', - }, - { - id: 'price', - cell: (item: Item) => { - return {item.price} €; - }, - label: 'Price', - }, -]; - -const DatagridStory = ({ - items, - isSortable, - ...args -}: { isSortable?: boolean } & DatagridProps) => { +const DatagridStory = (args) => { const [sorting, setSorting] = useState(); - const [data, setData] = useState(items); + const [data, setData] = useState(args.items); + const [searchParams] = useSearchParams(); + const { filters, addFilter, removeFilter } = useColumnFilters(); const fetchNextPage = () => { const itemsIndex = data?.length; @@ -47,20 +31,29 @@ const DatagridStory = ({ }; return ( - 0 && data.length < 30} - onFetchNextPage={fetchNextPage} - totalItems={data?.length} - {...(isSortable - ? { - sorting, - onSortChange: setSorting, - manualSorting: false, - } - : {})} - /> + <> + {`${searchParams}` && ( + <> +
Search params: ?{`${searchParams}`}
+ + + )} + 0 && data.length < 30} + onFetchNextPage={fetchNextPage} + totalItems={data?.length} + filters={{ filters, add: addFilter, remove: removeFilter }} + {...(args.isSortable + ? { + sorting, + onSortChange: setSorting, + manualSorting: false, + } + : {})} + /> + ); }; @@ -143,6 +136,17 @@ WithActions.args = { isSortable: true, }; +export const Filters = DatagridStory.bind({}); + +Filters.args = { + items: [...Array(10).keys()].map((_, i) => ({ + label: `Item #${i}`, + price: Math.floor(1 + Math.random() * 100), + })), + isSortable: true, + columns: columsFilters, +}; + export default { title: 'Components/Datagrid Cursor', component: Datagrid, diff --git a/packages/manager-react-components/src/components/datagrid/datagrid.component.tsx b/packages/manager-react-components/src/components/datagrid/datagrid.component.tsx index a449be5ec79a..70646b90a844 100644 --- a/packages/manager-react-components/src/components/datagrid/datagrid.component.tsx +++ b/packages/manager-react-components/src/components/datagrid/datagrid.component.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import { useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { ColumnDef, ColumnSort as TanstackColumnSort, @@ -8,15 +9,27 @@ import { useReactTable, getSortedRowModel, } from '@tanstack/react-table'; -import { ODS_ICON_NAME, ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; import { + ODS_ICON_NAME, + ODS_BUTTON_VARIANT, + ODS_BUTTON_SIZE, +} from '@ovhcloud/ods-components'; +import { + OdsPopover, OdsButton, OdsIcon, OdsPagination, OdsSkeleton, OdsTable, } from '@ovhcloud/ods-components/react'; -import { useTranslation } from 'react-i18next'; +import { + FilterComparator, + FilterCategories, + FilterTypeCategories, +} from '@ovh-ux/manager-core-api'; +import { FilterAdd, FilterList } from '../filters'; +import { ColumnFilter } from '../filters/filter-add.component'; +import { FilterWithLabel } from '../filters/interface'; import { DataGridTextCell } from './text-cell.component'; import { defaultNumberOfLoadingRows } from './datagrid.contants'; import './translations'; @@ -37,6 +50,25 @@ export interface DatagridColumn { label: string; /** is the column sortable ? (defaults is true) */ isSortable?: boolean; + /** set column comparator for the filter */ + comparator?: FilterComparator[]; + /** Filters displayed for the column */ + type?: FilterTypeCategories; + /** Trigger the column filter */ + isFilterable?: boolean; +} + +type ColumnFilterProps = { + key: string; + value: string | string[]; + comparator: FilterComparator; + label: string; +}; + +export interface FilterProps { + filters: FilterWithLabel[]; + add: (filters: ColumnFilterProps) => void; + remove: (filter: FilterWithLabel) => void; } export interface DatagridProps { @@ -74,11 +106,14 @@ export interface DatagridProps { isLoading?: boolean; /** number of loading rows to show when table is in loading state, defaults to pagination.pageSize or 5 */ numberOfLoadingRows?: number; + /** List of filters and handlers to add, remove */ + filters?: FilterProps; } export const Datagrid = ({ columns, items, + filters, totalItems, pagination, sorting, @@ -95,6 +130,8 @@ export const Datagrid = ({ numberOfLoadingRows, }: DatagridProps) => { const { t } = useTranslation('datagrid'); + const { t: tfilters } = useTranslation('filters'); + const filterPopoverRef = useRef(null); const pageCount = pagination ? Math.ceil(totalItems / pagination.pageSize) : 1; @@ -139,8 +176,64 @@ export const Datagrid = ({ }), }); + const columnsFilters = useMemo( + () => + columns + .filter( + (item) => + ('comparator' in item || 'type' in item) && + 'isFilterable' in item && + item.isFilterable, + ) + .map((column) => ({ + id: column.id, + label: column.label, + ...(column?.type && { comparators: FilterCategories[column.type] }), + ...(column?.comparator && { comparators: column.comparator }), + })), + [columns], + ); + return (
+ {columnsFilters.length > 0 && ( +
+ + + { + filters.add({ + ...addedFilter, + label: column.label, + }); + filterPopoverRef.current?.hide(); + }} + /> + +
+ )} + {filters?.filters.length > 0 && ( +
+ +
+ )} +
diff --git a/packages/manager-react-components/src/components/datagrid/datagrid.mock.tsx b/packages/manager-react-components/src/components/datagrid/datagrid.mock.tsx new file mode 100644 index 000000000000..a8e6eb44605a --- /dev/null +++ b/packages/manager-react-components/src/components/datagrid/datagrid.mock.tsx @@ -0,0 +1,45 @@ +import { FilterCategories } from '@ovh-ux/manager-core-api'; +import { DataGridTextCell } from './text-cell.component'; + +export interface Item { + label: string; + price: number; +} + +export const columns = [ + { + id: 'label', + cell: (item: Item) => { + return {item.label}; + }, + label: 'Label', + }, + { + id: 'price', + cell: (item: Item) => { + return {item.price} €; + }, + label: 'Price', + }, +]; + +export const columsFilters = [ + { + id: 'label', + cell: (item: Item) => { + return {item.label}; + }, + label: 'Label', + isFilterable: true, + comparator: FilterCategories.String, + }, + { + id: 'price', + cell: (item: Item) => { + return {item.price} €; + }, + label: 'Price', + isFilterable: true, + comparator: FilterCategories.String, + }, +]; diff --git a/packages/manager-react-components/src/components/datagrid/datagrid.spec.tsx b/packages/manager-react-components/src/components/datagrid/datagrid.spec.tsx index f9d4e42c8e5c..c3840c1975f1 100644 --- a/packages/manager-react-components/src/components/datagrid/datagrid.spec.tsx +++ b/packages/manager-react-components/src/components/datagrid/datagrid.spec.tsx @@ -1,11 +1,12 @@ import { vitest } from 'vitest'; import React, { useState } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; +import { FilterCategories } from '@ovh-ux/manager-core-api'; import { ColumnSort, Datagrid, - DatagridColumn, PaginationState, + FilterProps, } from './datagrid.component'; import DataGridTextCell from './text-cell.component'; import { defaultNumberOfLoadingRows } from './datagrid.contants'; @@ -27,6 +28,25 @@ vitest.mock('react-i18next', async () => { }); const sampleColumns = [ + { + id: 'name', + cell: (name: string) => { + return {name}; + }, + label: 'Name', + comparator: FilterCategories.String, + isFilterable: true, + }, + { + id: 'another-column', + label: 'test', + cell: () => , + comparator: FilterCategories.String, + isFilterable: true, + }, +]; + +const cols = [ { id: 'name', cell: (name: string) => { @@ -42,17 +62,19 @@ const sampleColumns = [ ]; const DatagridTest = ({ - columns, + columns = cols, items, pageIndex, className, noResultLabel, + filters, }: { - columns: DatagridColumn[]; + columns: any; items: string[]; pageIndex: number; className?: string; noResultLabel?: string; + filters?: FilterProps; }) => { const [pagination, setPagination] = useState({ pageIndex, @@ -71,6 +93,7 @@ const DatagridTest = ({ onSortChange={() => {}} className={className || ''} noResultLabel={noResultLabel} + filters={filters} /> ); }; @@ -224,7 +247,7 @@ it('should disable overflow of table', async () => { it('should have the default number of loading row when isLoading is true and numberOfLoadingRows is not specified', async () => { const { queryAllByTestId } = render( - , + , ); expect(queryAllByTestId('loading-row').length).toBe( defaultNumberOfLoadingRows, @@ -235,7 +258,7 @@ it('should display the specified number of loading rows when isLoading is true', const numberOfLoadingRows = 2; const { queryAllByTestId } = render( { const { getByTestId } = render( - , + ); + expect(getByTestId('load-more-btn')).toHaveAttribute('is-loading', 'true'); +}); + +it('should disable overflow of table', async () => { + const { container } = render( + , ); - expect(getByTestId('load-more-btn')).toHaveAttribute('is-loading', 'true'); + expect(container.querySelectorAll('.overflow-hidden').length).toBe(1); +}); + +it('should display filter add and filter list', async () => { + const filters = { + filters: [ + { + key: 'customName', + comparator: 'includes', + value: 'coucou', + label: 'customName', + }, + ], + add: null, + remove: null, + } as FilterProps; + const { container } = render( + , + ); + expect( + container.querySelectorAll('#datagrid-filter-popover-trigger').length, + ).toBe(1); + expect(container.querySelectorAll('#datagrid-filter-list').length).toBe(1); }); diff --git a/packages/manager-react-components/src/components/datagrid/datagrid.stories.tsx b/packages/manager-react-components/src/components/datagrid/datagrid.stories.tsx index 4adfcbf35f27..cf2796e4e5e5 100644 --- a/packages/manager-react-components/src/components/datagrid/datagrid.stories.tsx +++ b/packages/manager-react-components/src/components/datagrid/datagrid.stories.tsx @@ -1,31 +1,11 @@ import React from 'react'; import { withRouter } from 'storybook-addon-react-router-v6'; import { useSearchParams } from 'react-router-dom'; +import { applyFilters } from '@ovh-ux/manager-core-api'; import { Datagrid } from './datagrid.component'; -import { DataGridTextCell } from './text-cell.component'; import { useDatagridSearchParams } from './useDatagridSearchParams'; - -interface Item { - label: string; - price: number; -} - -const columns = [ - { - id: 'label', - cell: (item: Item) => { - return {item.label}; - }, - label: 'Label', - }, - { - id: 'price', - cell: (item: Item) => { - return {item.price} €; - }, - label: 'Price', - }, -]; +import { useColumnFilters } from '../filters'; +import { columns as clm, columsFilters, Item } from './datagrid.mock'; function sortItems( itemList: Item[], @@ -44,10 +24,12 @@ const DatagridStory = ({ items, isPaginated, isSortable, + columns = clm, }: { items: Item[]; isPaginated: boolean; isSortable: boolean; + columns?: any; }) => { const [searchParams] = useSearchParams(); const { pagination, setPagination, sorting, setSorting } = @@ -65,6 +47,8 @@ const DatagridStory = ({ sorting, onSortChange: setSorting, }; + const { filters, addFilter, removeFilter } = useColumnFilters(); + return ( <> {`${searchParams}` && ( @@ -75,10 +59,14 @@ const DatagridStory = ({ )} ); @@ -87,7 +75,7 @@ const DatagridStory = ({ export const Basic = DatagridStory.bind({}); Basic.args = { - columns, + columns: clm, items: [...Array(50).keys()].map((_, i) => ({ label: `Item #${i}`, price: Math.floor(1 + Math.random() * 100), @@ -99,7 +87,7 @@ Basic.args = { export const Sortable = DatagridStory.bind({}); Sortable.args = { - columns, + columns: clm, items: [...Array(8).keys()].map((_, i) => ({ label: `Service #${i}`, price: Math.floor(1 + Math.random() * 100), @@ -107,6 +95,18 @@ Sortable.args = { isSortable: true, }; +export const Filters = DatagridStory.bind({}); + +Filters.args = { + items: [...Array(50).keys()].map((_, i) => ({ + label: `Item #${i}`, + price: Math.floor(1 + Math.random() * 100), + })), + isPaginated: true, + isSortable: true, + columns: columsFilters, +}; + export default { title: 'Components/Datagrid Paginated', component: Datagrid, diff --git a/packages/manager-react-components/src/components/datagrid/documentation.mdx b/packages/manager-react-components/src/components/datagrid/documentation.mdx index be7438d3fe4c..48575ddede56 100644 --- a/packages/manager-react-components/src/components/datagrid/documentation.mdx +++ b/packages/manager-react-components/src/components/datagrid/documentation.mdx @@ -1,4 +1,4 @@ -import { Meta } from '@storybook/blocks'; +import { Meta, Canvas, Source } from '@storybook/blocks'; @@ -34,18 +34,73 @@ Why Use TanStack Table? --- -#### API V6 +#### Filters -##### usage with Iceberg +##### How to use It -We provide a custom hook `useResourcesIcebergV6` +1 - In your columns definition, fill `type` or `comparator` attributes -##### usage without Iceberg +- `type`comparator is a `FilterTypeCategories` -We provide a custom hook `useResourcesV6` +- `comparator`is a `string` of `FilterCategories`, if you fill this attribute, it override the `type` attributes -#### API V2 + { + return {item.label}; + }, + label: 'Label', + type: FilterTypeCategories.String + }, + { + id: 'price', + cell: (item: Item) => { + return {item.price} €; + }, + label: 'Price', + comparator: FilterCategories.String, + }, + ]; + `} + language="javascript" +/> -##### usage **_ONLY_** with Iceberg +2 - In the datagrid component, pass `filters` object -We provide a custom hook `useResourcesIcebergV2` + + `} + +language="javascript" +/> + +If you are using a custom hook + + + `} + +language="javascript" +/> + +--- diff --git a/packages/manager-react-components/src/components/filters/filter-add.component.tsx b/packages/manager-react-components/src/components/filters/filter-add.component.tsx index e2865284a3e5..5d41f0b3fb29 100644 --- a/packages/manager-react-components/src/components/filters/filter-add.component.tsx +++ b/packages/manager-react-components/src/components/filters/filter-add.component.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Filter, FilterComparator } from '@ovh-ux/manager-core-api'; import { ODS_BUTTON_SIZE, ODS_INPUT_TYPE } from '@ovhcloud/ods-components'; @@ -11,7 +11,7 @@ import { import { useTranslation } from 'react-i18next'; import './translations'; -type ColumnFilter = { +export type ColumnFilter = { id: string; label: string; comparators: FilterComparator[]; @@ -48,6 +48,10 @@ export function FilterAdd({ columns, onAddFilter }: Readonly) { setValue(''); }; + useEffect(() => { + setSelectedComparator(selectedColumn?.comparators[0]); + }, [selectedColumn]); + return ( <>
@@ -78,19 +82,31 @@ export function FilterAdd({ columns, onAddFilter }: Readonly) { {t('common_criteria_adder_operator_label')}
- { - setSelectedComparator(event.detail.value as FilterComparator); - }} - > - {selectedColumn?.comparators?.map((comp) => ( - - ))} - + {selectedColumn && + columns.map((column) => { + return ( +
+ { + setSelectedComparator( + event.detail.value as FilterComparator, + ); + }} + > + {column?.comparators?.map((comp) => ( + + ))} + +
+ ); + })}
diff --git a/packages/manager-react-components/src/components/filters/filter-list.component.tsx b/packages/manager-react-components/src/components/filters/filter-list.component.tsx index 727c32460179..4f890a6f50ce 100644 --- a/packages/manager-react-components/src/components/filters/filter-list.component.tsx +++ b/packages/manager-react-components/src/components/filters/filter-list.component.tsx @@ -22,7 +22,7 @@ export function FilterList({ <> {filters?.map((filter, key) => ( { queryKey: string[]; diff --git a/packages/manager-react-components/src/hooks/datagrid/useIcebergV6.tsx b/packages/manager-react-components/src/hooks/datagrid/useIcebergV6.tsx index ed6425304a82..8d086a6edf4a 100644 --- a/packages/manager-react-components/src/hooks/datagrid/useIcebergV6.tsx +++ b/packages/manager-react-components/src/hooks/datagrid/useIcebergV6.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { IcebergFetchParamsV6, fetchIcebergV6 } from '@ovh-ux/manager-core-api'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { ColumnSort } from '../../components'; import { defaultPageSize } from './index'; +import { useColumnFilters, ColumnSort } from '../../components'; interface IcebergV6Hook { queryKey: string[]; @@ -25,6 +25,7 @@ export function useResourcesIcebergV6({ shouldFetchAll = false, }: IcebergFetchParamsV6 & IcebergV6Hook) { const [sorting, setSorting] = useState(defaultSorting); + const { filters, addFilter, removeFilter } = useColumnFilters(); const { data: dataSelected, @@ -33,7 +34,12 @@ export function useResourcesIcebergV6({ ...rest } = useInfiniteQuery({ initialPageParam: 1, - queryKey: [...queryKey, shouldFetchAll ? 'all' : pageSize, sorting], + queryKey: [ + ...queryKey, + shouldFetchAll ? 'all' : pageSize, + sorting, + filters, + ], staleTime: Infinity, retry: false, queryFn: ({ pageParam: pageIndex }) => @@ -43,6 +49,7 @@ export function useResourcesIcebergV6({ page: pageIndex, sortBy: sorting?.id || null, sortReverse: sorting?.desc, + filters, }), getNextPageParam: (lastPage, _allPages, lastPageIndex) => { if (lastPage.totalCount / pageSize > lastPageIndex) { @@ -76,5 +83,10 @@ export function useResourcesIcebergV6({ ...rest, sorting, setSorting, + filters: { + filters, + add: addFilter, + remove: removeFilter, + }, }; } diff --git a/packages/manager-react-components/src/hooks/datagrid/useResourcesV6.tsx b/packages/manager-react-components/src/hooks/datagrid/useResourcesV6.tsx index 30364230937b..19b1ba5253ef 100644 --- a/packages/manager-react-components/src/hooks/datagrid/useResourcesV6.tsx +++ b/packages/manager-react-components/src/hooks/datagrid/useResourcesV6.tsx @@ -1,6 +1,10 @@ import React, { useState, useEffect } from 'react'; import isDate from 'lodash.isdate'; -import { IcebergFetchParamsV6, fetchIcebergV6 } from '@ovh-ux/manager-core-api'; +import { + IcebergFetchParamsV6, + fetchIcebergV6, + FilterTypeCategories, +} from '@ovh-ux/manager-core-api'; import { useQuery } from '@tanstack/react-query'; import { ColumnSort } from '../../components'; @@ -17,8 +21,10 @@ export interface ResourcesV6Hook { } export function dataType(a: any) { - if (Number.isInteger(a)) return 'number'; - if (isDate(a)) return 'date'; + if (Number.isInteger(a)) return FilterTypeCategories.Numeric; + if (isDate(a)) return FilterTypeCategories.Date; + if (typeof a === 'string') return FilterTypeCategories.String; + if (typeof a === 'boolean') return FilterTypeCategories.Boolean; return typeof a; } diff --git a/packages/manager/core/api/src/filters.ts b/packages/manager/core/api/src/filters.ts index 8e2cae4dd811..f82bd1bfe3c5 100644 --- a/packages/manager/core/api/src/filters.ts +++ b/packages/manager/core/api/src/filters.ts @@ -17,6 +17,14 @@ export type Filter = { comparator: FilterComparator; }; +export enum FilterTypeCategories { + Numeric = 'Numeric', + String = 'String', + Date = 'Date', + Boolean = 'Boolean', + Options = 'Options', +} + export const FilterCategories = { Numeric: [ FilterComparator.IsEqual, @@ -37,6 +45,8 @@ export const FilterCategories = { FilterComparator.IsBefore, FilterComparator.IsAfter, ], + Boolean: [FilterComparator.IsEqual, FilterComparator.IsDifferent], + Options: [FilterComparator.IsEqual, FilterComparator.IsDifferent], }; export function applyFilters(items: T[] = [], filters: Filter[] = []) { diff --git a/packages/manager/core/generator/app/conditional-templates/listing/v2/index.tsx.hbs b/packages/manager/core/generator/app/conditional-templates/listing/v2/index.tsx.hbs index 1d0c30075408..966777f4d2fc 100644 --- a/packages/manager/core/generator/app/conditional-templates/listing/v2/index.tsx.hbs +++ b/packages/manager/core/generator/app/conditional-templates/listing/v2/index.tsx.hbs @@ -4,11 +4,11 @@ import { {{#if isPCI }}useParams, {{/if}} useNavigate, useLocation } from 'react import { OdsButton, OdsSpinner } from '@ovhcloud/ods-components/react'; import { ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; - import { BaseLayout, Breadcrumb, Datagrid, + dataType, DataGridTextCell, ErrorBanner, useResourcesIcebergV2, @@ -49,6 +49,7 @@ export default function Listing() { navigate(`${path}${label}`); }; + // Code to remove and declare definition columns in const variable useEffect(() => { if (status === 'success' && data?.pages[0].data.length === 0) { navigate(urls.onboarding); @@ -103,7 +104,15 @@ export default function Listing() { }; return ( - } header={header}> + + } + header={header} + > {columns && flattenData && ( { - if (columns && status === 'success' && flattenData?.length > 0) { + if (columns.length === 0 && status === 'success' && flattenData?.length > 0) { const newColumns = Object.keys(flattenData[0]) .filter((element) => element !== 'iam') .map((element) => ({ id: element, label: element, + isFilterable: true, // @ts-ignore type: dataType(flattenData[0][element]), + // @ts-ignore + ...(comparatorType[dataType(flattenData[0][element])] && { + // @ts-ignore + comparator: comparatorType[dataType(flattenData[0][element])], + }), cell: (props: any) => { const label = props[element] as string; if (typeof label === 'string' || typeof label === 'number') { @@ -108,7 +125,15 @@ export default function Listing() { }; return ( - } header={header}>} header={header}> + + } + header={header} + > {columns && flattenData && ( )} diff --git a/yarn.lock b/yarn.lock index 264dbcfd8e94..4d640b5ff0ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17646,7 +17646,7 @@ i18next@^23.11.5: i18next@^23.16.4: version "23.16.8" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.16.8.tgz#3ae1373d344c2393f465556f394aba5a9233b93a" + resolved "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz#3ae1373d344c2393f465556f394aba5a9233b93a" integrity sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg== dependencies: "@babel/runtime" "^7.23.2"