From 82672d40fd5843ac86a22081e56bd206c322e537 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Fri, 20 Jun 2025 11:58:14 +0300 Subject: [PATCH] [Feature]: Property filter on Fleets, Models, Volumes pages #2817 --- frontend/src/libs/filters.ts | 52 ++++++++ frontend/src/locale/en.json | 3 + frontend/src/pages/Fleets/List/hooks.tsx | 106 ++++++++++++++--- frontend/src/pages/Fleets/List/index.tsx | 55 ++++----- .../src/pages/Fleets/List/styles.module.scss | 12 +- frontend/src/pages/Models/List/hooks.tsx | 111 ++++++++++++------ frontend/src/pages/Models/List/index.tsx | 56 ++++----- .../src/pages/Models/List/styles.module.scss | 13 +- .../src/pages/Runs/List/hooks/useFilters.ts | 64 ++-------- frontend/src/pages/Volumes/List/hooks.tsx | 106 ++++++++++++++--- frontend/src/pages/Volumes/List/index.tsx | 55 ++++----- .../src/pages/Volumes/List/styles.module.scss | 14 +-- 12 files changed, 416 insertions(+), 231 deletions(-) create mode 100644 frontend/src/libs/filters.ts diff --git a/frontend/src/libs/filters.ts b/frontend/src/libs/filters.ts new file mode 100644 index 0000000000..03ae946930 --- /dev/null +++ b/frontend/src/libs/filters.ts @@ -0,0 +1,52 @@ +import type { PropertyFilterProps } from 'components'; + +export const tokensToRequestParams = ( + tokens: PropertyFilterProps.Query['tokens'], + onlyActive?: boolean, +) => { + const params: Record = tokens.reduce((acc, token) => { + if (token.propertyKey) { + acc[token.propertyKey as RequestParamsKeys] = token.value; + } + + return acc; + }, {} as Record); + + if (onlyActive) { + params['only_active'] = 'true'; + } + + return params; +}; + +export const EMPTY_QUERY: PropertyFilterProps.Query = { + tokens: [], + operation: 'and', +}; + +export const requestParamsToTokens = ({ + searchParams, + filterKeys, +}: { + searchParams: URLSearchParams; + filterKeys: Record; +}): PropertyFilterProps.Query => { + const tokens = []; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + for (const [paramKey, paramValue] of searchParams.entries()) { + if (Object.values(filterKeys).includes(paramKey)) { + tokens.push({ propertyKey: paramKey, operator: '=', value: paramValue }); + } + } + + if (!tokens.length) { + return EMPTY_QUERY; + } + + return { + ...EMPTY_QUERY, + tokens, + }; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 8cda89caef..7c8f8e6e05 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -448,6 +448,7 @@ "nomatch_message_text": "We can't find a match.", "nomatch_message_button_label": "Clear filter", "active_only": "Active fleets", + "filter_property_placeholder": "Filter fleets by properties", "statuses": { "active": "Active", "submitted": "Submitted", @@ -457,6 +458,7 @@ }, "instances": { "active_only": "Active instances", + "filter_property_placeholder": "Filter instances by properties", "title": "Instances", "empty_message_title": "No instances", "empty_message_text": "No instances to display.", @@ -494,6 +496,7 @@ "delete_volumes_confirm_title": "Delete volumes", "delete_volumes_confirm_message": "Are you sure you want to delete these volumes?", "active_only": "Active volumes", + "filter_property_placeholder": "Filter volumes by properties", "name": "Name", "project": "Project name", diff --git a/frontend/src/pages/Fleets/List/hooks.tsx b/frontend/src/pages/Fleets/List/hooks.tsx index d684e77133..4a1a0e3835 100644 --- a/frontend/src/pages/Fleets/List/hooks.tsx +++ b/frontend/src/pages/Fleets/List/hooks.tsx @@ -1,20 +1,23 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { format } from 'date-fns'; +import { ToggleProps } from '@cloudscape-design/components'; +import type { PropertyFilterProps } from 'components'; import { Button, ListEmptyMessage, NavigateLink, StatusIndicator, TableProps } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; -import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { useProjectFilter } from 'hooks/useProjectFilter'; +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams } from 'libs/filters'; import { getFleetInstancesLinkText, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; export const useEmptyMessages = ({ - clearFilters, + clearFilter, isDisabledClearFilter, }: { - clearFilters?: () => void; + clearFilter?: () => void; isDisabledClearFilter?: boolean; }) => { const { t } = useTranslation(); @@ -22,22 +25,22 @@ export const useEmptyMessages = ({ const renderEmptyMessage = useCallback<() => React.ReactNode>(() => { return ( - ); - }, [clearFilters, isDisabledClearFilter]); + }, [clearFilter, isDisabledClearFilter]); const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { return ( - ); - }, [clearFilters, isDisabledClearFilter]); + }, [clearFilter, isDisabledClearFilter]); return { renderEmptyMessage, renderNoMatchMessage } as const; }; @@ -99,24 +102,91 @@ export const useColumnsDefinitions = () => { return { columns } as const; }; +type RequestParamsKeys = keyof Pick; + +const filterKeys: Record = { + PROJECT_NAME: 'project_name', +}; + export const useFilters = (localStorePrefix = 'fleet-list-page') => { - const [onlyActive, setOnlyActive] = useLocalStorageState(`${localStorePrefix}-is-active`, true); - const { selectedProject, setSelectedProject, projectOptions } = useProjectFilter({ localStorePrefix }); + const [searchParams, setSearchParams] = useSearchParams(); + const [onlyActive, setOnlyActive] = useState(() => searchParams.get('only_active') === 'true'); + const { projectOptions } = useProjectFilter({ localStorePrefix }); - const clearFilters = () => { + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); + + const clearFilter = () => { + setSearchParams({}); setOnlyActive(false); - setSelectedProject(null); + setPropertyFilterQuery(EMPTY_QUERY); }; - const isDisabledClearFilter = !selectedProject && !onlyActive; + const filteringOptions = useMemo(() => { + const options: PropertyFilterProps.FilteringOption[] = []; + + projectOptions.forEach(({ value }) => { + if (value) + options.push({ + propertyKey: filterKeys.PROJECT_NAME, + value, + }); + }); + + return options; + }, [projectOptions]); + + const filteringProperties = [ + { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', + }, + ]; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const { tokens, operation } = detail; + + const filteredTokens = tokens.filter((token, tokenIndex) => { + return !tokens.some((item, index) => token.propertyKey === item.propertyKey && index > tokenIndex); + }); + + setSearchParams(tokensToRequestParams(filteredTokens, onlyActive)); + + setPropertyFilterQuery({ + operation, + tokens: filteredTokens, + }); + }; + + const onChangeOnlyActive: ToggleProps['onChange'] = ({ detail }) => { + setOnlyActive(detail.checked); + + setSearchParams(tokensToRequestParams(propertyFilterQuery.tokens, detail.checked)); + }; + + const filteringRequestParams = useMemo(() => { + const params = tokensToRequestParams(propertyFilterQuery.tokens); + + return { + ...params, + only_active: onlyActive, + }; + }, [propertyFilterQuery, onlyActive]); + + const isDisabledClearFilter = !propertyFilterQuery.tokens.length && !onlyActive; return { - projectOptions, - selectedProject, - setSelectedProject, + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, onlyActive, - setOnlyActive, - clearFilters, + onChangeOnlyActive, isDisabledClearFilter, } as const; }; diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 4aa71ef84f..3ac92310fb 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, FormField, Header, Loader, SelectCSD, SpaceBetween, Table, Toggle } from 'components'; +import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; @@ -24,18 +24,20 @@ export const FleetList: React.FC = () => { ]); const { + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringRequestParams, onlyActive, - setOnlyActive, + onChangeOnlyActive, isDisabledClearFilter, - clearFilters, - projectOptions, - selectedProject, - setSelectedProject, } = useFilters(); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetFleetsQuery, - args: { project_name: selectedProject?.value, only_active: onlyActive, limit: DEFAULT_TABLE_PAGE_SIZE }, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, getPaginationParams: (lastFleet) => ({ prev_created_at: lastFleet.created_at, @@ -45,7 +47,7 @@ export const FleetList: React.FC = () => { const { columns } = useColumnsDefinitions(); const { deleteFleets, isDeleting } = useDeleteFleet(); - const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilters, isDisabledClearFilter }); + const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, isDisabledClearFilter }); const { items, collectionProps } = useCollection(data, { filtering: { @@ -98,33 +100,28 @@ export const FleetList: React.FC = () => { } filter={
-
- - { - setSelectedProject(event.detail.selectedOption); - }} - placeholder={t('projects.run.project_placeholder')} - expandToViewport={true} - filteringType="auto" - /> - +
+
- setOnlyActive(detail.checked)} checked={onlyActive}> + {t('fleets.active_only')}
- -
- -
} footer={} diff --git a/frontend/src/pages/Fleets/List/styles.module.scss b/frontend/src/pages/Fleets/List/styles.module.scss index a6917fc622..022678e83e 100644 --- a/frontend/src/pages/Fleets/List/styles.module.scss +++ b/frontend/src/pages/Fleets/List/styles.module.scss @@ -1,20 +1,20 @@ .filters { - --select-width: calc((688px - 3 * 20px) / 2); display: flex; flex-wrap: wrap; gap: 0 20px; - .select { - width: var(--select-width, 30%); + .propertyFilter { + max-width: 640px; + flex-grow: 1; + min-width: 0; } .activeOnly { display: flex; - align-items: center; - padding-top: 26px; + padding-top: 7px; } .clear { - padding-top: 26px; + } } diff --git a/frontend/src/pages/Models/List/hooks.tsx b/frontend/src/pages/Models/List/hooks.tsx index 9cc0075ddb..8188ec7514 100644 --- a/frontend/src/pages/Models/List/hooks.tsx +++ b/frontend/src/pages/Models/List/hooks.tsx @@ -1,13 +1,16 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { format } from 'date-fns'; -import { Button, ListEmptyMessage, NavigateLink, SelectCSDProps, TableProps } from 'components'; +import type { PropertyFilterProps } from 'components'; +import { Button, ListEmptyMessage, NavigateLink, TableProps } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; import { useProjectFilter } from 'hooks/useProjectFilter'; +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams } from 'libs/filters'; import { ROUTES } from 'routes'; +import { useGetUserListQuery } from 'services/user'; import { getModelGateway } from '../helpers'; @@ -116,49 +119,91 @@ export const useEmptyMessages = ({ return { renderEmptyMessage, renderNoMatchMessage } as const; }; -type Args = { - projectSearchKey?: string; - localStorePrefix?: string; +type RequestParamsKeys = keyof Pick; + +const filterKeys: Record = { + PROJECT_NAME: 'project_name', + USER_NAME: 'username', }; -export const useFilters = ({ projectSearchKey, localStorePrefix = 'models-list-page' }: Args) => { - const [searchParams] = useSearchParams(); - const { selectedProject, setSelectedProject, projectOptions } = useProjectFilter({ localStorePrefix }); +export const useFilters = (localStorePrefix = 'models-list-page') => { + const [searchParams, setSearchParams] = useSearchParams(); + const { projectOptions } = useProjectFilter({ localStorePrefix }); + const { data: usersData } = useGetUserListQuery(); - const setSelectedOptionFromParams = ( - searchKey: string, - options: SelectCSDProps.Options | null, - set: (option: SelectCSDProps.Option) => void, - ) => { - const searchValue = searchParams.get(searchKey); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); - if (!searchValue || !options?.length) return; + const clearFilter = () => { + setSearchParams({}); + setPropertyFilterQuery(EMPTY_QUERY); + }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const selectedOption = options.find((option) => option?.value === searchValue); + const filteringOptions = useMemo(() => { + const options: PropertyFilterProps.FilteringOption[] = []; - if (selectedOption) set(selectedOption); - }; + projectOptions.forEach(({ value }) => { + if (value) + options.push({ + propertyKey: filterKeys.PROJECT_NAME, + value, + }); + }); - useEffect(() => { - if (!projectSearchKey) return; + usersData?.forEach(({ username }) => { + options.push({ + propertyKey: filterKeys.USER_NAME, + value: username, + }); + }); - setSelectedOptionFromParams(projectSearchKey, projectOptions, setSelectedProject); - }, [searchParams, projectSearchKey, projectOptions]); + return options; + }, [projectOptions, usersData]); - const clearSelected = () => { - setSelectedProject(null); - }; + const filteringProperties = [ + { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', + }, + { + key: filterKeys.USER_NAME, + operators: ['='], + propertyLabel: 'User', + }, + ]; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const { tokens, operation } = detail; + + const filteredTokens = tokens.filter((token, tokenIndex) => { + return !tokens.some((item, index) => token.propertyKey === item.propertyKey && index > tokenIndex); + }); - const setSelectedProjectHandle = (project: SelectCSDProps.Option | null) => { - setSelectedProject(project); + setSearchParams(tokensToRequestParams(filteredTokens)); + + setPropertyFilterQuery({ + operation, + tokens: filteredTokens, + }); }; + const filteringRequestParams = useMemo(() => { + const { only_active, ...params } = tokensToRequestParams(propertyFilterQuery.tokens); + + return { + ...params, + }; + }, [propertyFilterQuery]); + return { - projectOptions, - selectedProject, - setSelectedProject: setSelectedProjectHandle, - clearSelected, + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, } as const; }; diff --git a/frontend/src/pages/Models/List/index.tsx b/frontend/src/pages/Models/List/index.tsx index 3a38a64f8a..f0dffa4cf7 100644 --- a/frontend/src/pages/Models/List/index.tsx +++ b/frontend/src/pages/Models/List/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, FormField, Header, Loader, SelectCSD, Table } from 'components'; +import { Button, Header, Loader, PropertyFilter, Table } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; @@ -19,9 +19,14 @@ import styles from './styles.module.scss'; export const List: React.FC = () => { const { t } = useTranslation(); - const { projectOptions, selectedProject, setSelectedProject, clearSelected } = useFilters({ - projectSearchKey: 'project', - }); + const { + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringRequestParams, + } = useFilters(); useBreadcrumbs([ { @@ -35,16 +40,12 @@ export const List: React.FC = () => { const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetModelsQuery, - args: { project_name: selectedProject?.value, limit: DEFAULT_TABLE_PAGE_SIZE }, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, getPaginationParams: (lastModel) => ({ prev_submitted_at: lastModel.submitted_at }), }); - const clearFilter = () => { - clearSelected(); - }; - - const isDisabledClearFilter = !selectedProject; + const isDisabledClearFilter = !propertyFilterQuery.tokens.length; const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, @@ -86,26 +87,21 @@ export const List: React.FC = () => { } filter={
-
- - { - setSelectedProject(event.detail.selectedOption); - }} - placeholder={t('projects.run.project_placeholder')} - expandToViewport={true} - filteringType="auto" - /> - -
- -
- +
+
} diff --git a/frontend/src/pages/Models/List/styles.module.scss b/frontend/src/pages/Models/List/styles.module.scss index 8be2cab624..9f5eb3b217 100644 --- a/frontend/src/pages/Models/List/styles.module.scss +++ b/frontend/src/pages/Models/List/styles.module.scss @@ -4,17 +4,14 @@ flex-wrap: wrap; gap: 0 20px; - .select { - width: var(--select-width, 30%); + .propertyFilter { + max-width: 640px; + flex-grow: 1; + min-width: 0; } .activeOnly { display: flex; - align-items: center; - padding-top: 26px; - } - - .clear { - padding-top: 26px; + padding-top: 7px; } } diff --git a/frontend/src/pages/Runs/List/hooks/useFilters.ts b/frontend/src/pages/Runs/List/hooks/useFilters.ts index 951ceb35a5..18db5e332b 100644 --- a/frontend/src/pages/Runs/List/hooks/useFilters.ts +++ b/frontend/src/pages/Runs/List/hooks/useFilters.ts @@ -5,8 +5,8 @@ import { ToggleProps } from '@cloudscape-design/components'; import type { PropertyFilterProps } from 'components'; import { useProjectFilter } from 'hooks/useProjectFilter'; - -import { useGetUserListQuery } from '../../../../services/user'; +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams } from 'libs/filters'; +import { useGetUserListQuery } from 'services/user'; type Args = { localStorePrefix: string; @@ -14,58 +14,20 @@ type Args = { type RequestParamsKeys = keyof Pick; -const FilterKeys: Record = { +const filterKeys: Record = { PROJECT_NAME: 'project_name', USER_NAME: 'username', }; -const EMPTY_QUERY: PropertyFilterProps.Query = { - tokens: [], - operation: 'and', -}; - -const tokensToRequestParams = (tokens: PropertyFilterProps.Query['tokens'], onlyActive?: boolean) => { - const params = tokens.reduce((acc, token) => { - if (token.propertyKey) { - acc[token.propertyKey as RequestParamsKeys] = token.value; - } - - return acc; - }, {} as Record); - - if (onlyActive) { - params['only_active'] = 'true'; - } - - return params; -}; - export const useFilters = ({ localStorePrefix }: Args) => { const [searchParams, setSearchParams] = useSearchParams(); const [onlyActive, setOnlyActive] = useState(() => searchParams.get('only_active') === 'true'); const { projectOptions } = useProjectFilter({ localStorePrefix }); const { data: usersData } = useGetUserListQuery(); - const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { - const tokens = []; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - for (const [paramKey, paramValue] of searchParams.entries()) { - if (Object.values(FilterKeys).includes(paramKey)) { - tokens.push({ propertyKey: paramKey, operator: '=', value: paramValue }); - } - } - - if (!tokens.length) { - return EMPTY_QUERY; - } - - return { - ...EMPTY_QUERY, - tokens, - }; - }); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); const clearFilter = () => { setSearchParams({}); @@ -79,14 +41,14 @@ export const useFilters = ({ localStorePrefix }: Args) => { projectOptions.forEach(({ value }) => { if (value) options.push({ - propertyKey: FilterKeys.PROJECT_NAME, + propertyKey: filterKeys.PROJECT_NAME, value, }); }); usersData?.forEach(({ username }) => { options.push({ - propertyKey: FilterKeys.USER_NAME, + propertyKey: filterKeys.USER_NAME, value: username, }); }); @@ -96,13 +58,13 @@ export const useFilters = ({ localStorePrefix }: Args) => { const filteringProperties = [ { - key: FilterKeys.PROJECT_NAME, + key: filterKeys.PROJECT_NAME, operators: ['='], propertyLabel: 'Project', groupValuesLabel: 'Project values', }, { - key: FilterKeys.USER_NAME, + key: filterKeys.USER_NAME, operators: ['='], propertyLabel: 'User', }, @@ -115,7 +77,7 @@ export const useFilters = ({ localStorePrefix }: Args) => { return !tokens.some((item, index) => token.propertyKey === item.propertyKey && index > tokenIndex); }); - setSearchParams(tokensToRequestParams(filteredTokens, onlyActive)); + setSearchParams(tokensToRequestParams(filteredTokens, onlyActive)); setPropertyFilterQuery({ operation, @@ -126,11 +88,11 @@ export const useFilters = ({ localStorePrefix }: Args) => { const onChangeOnlyActive: ToggleProps['onChange'] = ({ detail }) => { setOnlyActive(detail.checked); - setSearchParams(tokensToRequestParams(propertyFilterQuery.tokens, detail.checked)); + setSearchParams(tokensToRequestParams(propertyFilterQuery.tokens, detail.checked)); }; const filteringRequestParams = useMemo(() => { - const params = tokensToRequestParams(propertyFilterQuery.tokens); + const params = tokensToRequestParams(propertyFilterQuery.tokens); return { ...params, diff --git a/frontend/src/pages/Volumes/List/hooks.tsx b/frontend/src/pages/Volumes/List/hooks.tsx index 372da3b7e9..8fe5be9817 100644 --- a/frontend/src/pages/Volumes/List/hooks.tsx +++ b/frontend/src/pages/Volumes/List/hooks.tsx @@ -1,23 +1,26 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { format } from 'date-fns'; +import { ToggleProps } from '@cloudscape-design/components'; +import type { PropertyFilterProps } from 'components'; import { Button, ListEmptyMessage, NavigateLink, StatusIndicator } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; import { useNotifications } from 'hooks'; -import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { useProjectFilter } from 'hooks/useProjectFilter'; import { getServerError } from 'libs'; +import { EMPTY_QUERY, requestParamsToTokens, tokensToRequestParams } from 'libs/filters'; import { getStatusIconType } from 'libs/volumes'; import { ROUTES } from 'routes'; import { useDeleteVolumesMutation } from 'services/volume'; export const useVolumesTableEmptyMessages = ({ - clearFilters, + clearFilter, isDisabledClearFilter, }: { - clearFilters?: () => void; + clearFilter?: () => void; isDisabledClearFilter?: boolean; }) => { const { t } = useTranslation(); @@ -25,7 +28,7 @@ export const useVolumesTableEmptyMessages = ({ const renderEmptyMessage = (): React.ReactNode => { return ( - @@ -35,7 +38,7 @@ export const useVolumesTableEmptyMessages = ({ const renderNoMatchMessage = (): React.ReactNode => { return ( - @@ -92,7 +95,7 @@ export const useColumnsDefinitions = () => { { id: 'finished', header: t('volume.finished'), - cell: (item: IVolume) => getVolumeFinised(item), + cell: (item: IVolume) => getVolumeFinished(item), }, { id: 'price', @@ -113,24 +116,91 @@ export const useColumnsDefinitions = () => { return { columns } as const; }; +type RequestParamsKeys = keyof Pick; + +const filterKeys: Record = { + PROJECT_NAME: 'project_name', +}; + export const useFilters = (localStorePrefix = 'volume-list-page') => { - const [onlyActive, setOnlyActive] = useLocalStorageState(`${localStorePrefix}-is-active`, true); - const { selectedProject, setSelectedProject, projectOptions } = useProjectFilter({ localStorePrefix }); + const [searchParams, setSearchParams] = useSearchParams(); + const [onlyActive, setOnlyActive] = useState(() => searchParams.get('only_active') === 'true'); + const { projectOptions } = useProjectFilter({ localStorePrefix }); + + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); - const clearFilters = () => { + const clearFilter = () => { + setSearchParams({}); setOnlyActive(false); - setSelectedProject(null); + setPropertyFilterQuery(EMPTY_QUERY); }; - const isDisabledClearFilter = !selectedProject && !onlyActive; + const isDisabledClearFilter = !propertyFilterQuery.tokens.length && !onlyActive; + + const filteringOptions = useMemo(() => { + const options: PropertyFilterProps.FilteringOption[] = []; + + projectOptions.forEach(({ value }) => { + if (value) + options.push({ + propertyKey: filterKeys.PROJECT_NAME, + value, + }); + }); + + return options; + }, [projectOptions]); + + const filteringProperties = [ + { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', + }, + ]; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const { tokens, operation } = detail; + + const filteredTokens = tokens.filter((token, tokenIndex) => { + return !tokens.some((item, index) => token.propertyKey === item.propertyKey && index > tokenIndex); + }); + + setSearchParams(tokensToRequestParams(filteredTokens, onlyActive)); + + setPropertyFilterQuery({ + operation, + tokens: filteredTokens, + }); + }; + + const onChangeOnlyActive: ToggleProps['onChange'] = ({ detail }) => { + setOnlyActive(detail.checked); + + setSearchParams(tokensToRequestParams(propertyFilterQuery.tokens, detail.checked)); + }; + + const filteringRequestParams = useMemo(() => { + const params = tokensToRequestParams(propertyFilterQuery.tokens); + + return { + ...params, + only_active: onlyActive, + }; + }, [propertyFilterQuery, onlyActive]); return { - projectOptions, - selectedProject, - setSelectedProject, + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, onlyActive, - setOnlyActive, - clearFilters, + onChangeOnlyActive, isDisabledClearFilter, } as const; }; @@ -180,7 +250,7 @@ export const useVolumesDelete = () => { return { isDeleting, deleteVolumes }; }; -const getVolumeFinised = (volume: IVolume): string => { +const getVolumeFinished = (volume: IVolume): string => { if (!volume.deleted_at && volume.status != 'failed') { return '-'; } diff --git a/frontend/src/pages/Volumes/List/index.tsx b/frontend/src/pages/Volumes/List/index.tsx index a677dd0a46..15229d772e 100644 --- a/frontend/src/pages/Volumes/List/index.tsx +++ b/frontend/src/pages/Volumes/List/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, ButtonWithConfirmation, FormField, Header, Loader, SelectCSD, SpaceBetween, Table, Toggle } from 'components'; +import { Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; @@ -15,25 +15,27 @@ import styles from './styles.module.scss'; export const VolumeList: React.FC = () => { const { t } = useTranslation(); const { + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringRequestParams, onlyActive, - setOnlyActive, + onChangeOnlyActive, isDisabledClearFilter, - clearFilters, - projectOptions, - selectedProject, - setSelectedProject, } = useFilters(); const { isDeleting, deleteVolumes } = useVolumesDelete(); const { renderEmptyMessage, renderNoMatchMessage } = useVolumesTableEmptyMessages({ - clearFilters, + clearFilter, isDisabledClearFilter, }); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllVolumesQuery, - args: { project_name: selectedProject?.value ?? undefined, only_active: onlyActive, limit: DEFAULT_TABLE_PAGE_SIZE }, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, getPaginationParams: (lastFleet) => ({ prev_created_at: lastFleet.created_at, @@ -112,33 +114,28 @@ export const VolumeList: React.FC = () => { selectionType="multi" filter={
-
- - { - setSelectedProject(event.detail.selectedOption); - }} - placeholder={t('projects.run.project_placeholder')} - expandToViewport={true} - filteringType="auto" - /> - +
+
- setOnlyActive(detail.checked)} checked={onlyActive}> + {t('volume.active_only')}
- -
- -
} footer={} diff --git a/frontend/src/pages/Volumes/List/styles.module.scss b/frontend/src/pages/Volumes/List/styles.module.scss index a6917fc622..48705817d4 100644 --- a/frontend/src/pages/Volumes/List/styles.module.scss +++ b/frontend/src/pages/Volumes/List/styles.module.scss @@ -1,20 +1,16 @@ .filters { - --select-width: calc((688px - 3 * 20px) / 2); display: flex; flex-wrap: wrap; gap: 0 20px; - .select { - width: var(--select-width, 30%); + .propertyFilter { + max-width: 640px; + flex-grow: 1; + min-width: 0; } .activeOnly { display: flex; - align-items: center; - padding-top: 26px; - } - - .clear { - padding-top: 26px; + padding-top: 7px; } }