Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions frontend/src/libs/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { PropertyFilterProps } from 'components';

export const tokensToRequestParams = <RequestParamsKeys extends string>(
tokens: PropertyFilterProps.Query['tokens'],
onlyActive?: boolean,
) => {
const params: Record<RequestParamsKeys | 'only_active', string> = tokens.reduce((acc, token) => {
if (token.propertyKey) {
acc[token.propertyKey as RequestParamsKeys] = token.value;
}

return acc;
}, {} as Record<RequestParamsKeys | 'only_active', string>);

if (onlyActive) {
params['only_active'] = 'true';
}

return params;
};

export const EMPTY_QUERY: PropertyFilterProps.Query = {
tokens: [],
operation: 'and',
};

export const requestParamsToTokens = <RequestParamsKeys extends string>({
searchParams,
filterKeys,
}: {
searchParams: URLSearchParams;
filterKeys: Record<string, RequestParamsKeys>;
}): 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,
};
};
3 changes: 3 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
106 changes: 88 additions & 18 deletions frontend/src/pages/Fleets/List/hooks.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,46 @@
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();

const renderEmptyMessage = useCallback<() => React.ReactNode>(() => {
return (
<ListEmptyMessage title={t('fleets.empty_message_title')} message={t('fleets.empty_message_text')}>
<Button disabled={isDisabledClearFilter} onClick={clearFilters}>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
</Button>
</ListEmptyMessage>
);
}, [clearFilters, isDisabledClearFilter]);
}, [clearFilter, isDisabledClearFilter]);

const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => {
return (
<ListEmptyMessage title={t('fleets.nomatch_message_title')} message={t('fleets.nomatch_message_text')}>
<Button disabled={isDisabledClearFilter} onClick={clearFilters}>
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
{t('common.clearFilter')}
</Button>
</ListEmptyMessage>
);
}, [clearFilters, isDisabledClearFilter]);
}, [clearFilter, isDisabledClearFilter]);

return { renderEmptyMessage, renderNoMatchMessage } as const;
};
Expand Down Expand Up @@ -99,24 +102,91 @@ export const useColumnsDefinitions = () => {
return { columns } as const;
};

type RequestParamsKeys = keyof Pick<TFleetListRequestParams, 'only_active' | 'project_name'>;

const filterKeys: Record<string, RequestParamsKeys> = {
PROJECT_NAME: 'project_name',
};

export const useFilters = (localStorePrefix = 'fleet-list-page') => {
const [onlyActive, setOnlyActive] = useLocalStorageState<boolean>(`${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<PropertyFilterProps.Query>(() =>
requestParamsToTokens<RequestParamsKeys>({ 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<RequestParamsKeys>(filteredTokens, onlyActive));

setPropertyFilterQuery({
operation,
tokens: filteredTokens,
});
};

const onChangeOnlyActive: ToggleProps['onChange'] = ({ detail }) => {
setOnlyActive(detail.checked);

setSearchParams(tokensToRequestParams<RequestParamsKeys>(propertyFilterQuery.tokens, detail.checked));
};

const filteringRequestParams = useMemo(() => {
const params = tokensToRequestParams<RequestParamsKeys>(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;
};
55 changes: 26 additions & 29 deletions frontend/src/pages/Fleets/List/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<IFleet, TFleetListRequestParams>({
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,
Expand All @@ -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<IFleet>(data, {
filtering: {
Expand Down Expand Up @@ -98,33 +100,28 @@ export const FleetList: React.FC = () => {
}
filter={
<div className={styles.filters}>
<div className={styles.select}>
<FormField label={t('projects.run.project')}>
<SelectCSD
disabled={!projectOptions?.length}
options={projectOptions}
selectedOption={selectedProject}
onChange={(event) => {
setSelectedProject(event.detail.selectedOption);
}}
placeholder={t('projects.run.project_placeholder')}
expandToViewport={true}
filteringType="auto"
/>
</FormField>
<div className={styles.propertyFilter}>
<PropertyFilter
query={propertyFilterQuery}
onChange={onChangePropertyFilter}
expandToViewport
hideOperations
i18nStrings={{
clearFiltersText: t('common.clearFilter'),
filteringAriaLabel: t('fleets.filter_property_placeholder'),
filteringPlaceholder: t('fleets.filter_property_placeholder'),
operationAndText: 'and',
}}
filteringOptions={filteringOptions}
filteringProperties={filteringProperties}
/>
</div>

<div className={styles.activeOnly}>
<Toggle onChange={({ detail }) => setOnlyActive(detail.checked)} checked={onlyActive}>
<Toggle onChange={onChangeOnlyActive} checked={onlyActive}>
{t('fleets.active_only')}
</Toggle>
</div>

<div className={styles.clear}>
<Button formAction="none" onClick={clearFilters} disabled={isDisabledClearFilter}>
{t('common.clearFilter')}
</Button>
</div>
</div>
}
footer={<Loader show={isLoadingMore} padding={{ vertical: 'm' }} />}
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/pages/Fleets/List/styles.module.scss
Original file line number Diff line number Diff line change
@@ -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;

}
}
Loading
Loading