From ba97acd1aaae7fd935cb9010bfcd1d99dd020531 Mon Sep 17 00:00:00 2001 From: Timofei Ponomarev Date: Tue, 28 Jan 2025 10:33:12 +0100 Subject: [PATCH] first version of filters and sorting --- packages/ui/locales/en/views.json | 4 +- packages/ui/locales/es/views.json | 8 +- packages/ui/locales/fr/views.json | 8 +- .../constants/filter-options.ts | 85 ++++++++ .../src/views/user-management/hooks/index.ts | 3 + .../user-management/hooks/use-filters.tsx | 204 ++++++++++++++++++ .../user-management/user-management-page.tsx | 18 +- 7 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/views/user-management/constants/filter-options.ts create mode 100644 packages/ui/src/views/user-management/hooks/index.ts create mode 100644 packages/ui/src/views/user-management/hooks/use-filters.tsx diff --git a/packages/ui/locales/en/views.json b/packages/ui/locales/en/views.json index 9b40d6d42..385aaeced 100644 --- a/packages/ui/locales/en/views.json +++ b/packages/ui/locales/en/views.json @@ -405,9 +405,9 @@ "active": "Active users", "inactive": "Pending users" }, + "usersHeader": "Users", "searchPlaceholder": "Search", - "newUserButton": "New user", - "usersHeader": "Users" + "newUserButton": "New user" }, "labelData": { "create": "Create labels" diff --git a/packages/ui/locales/es/views.json b/packages/ui/locales/es/views.json index d2ac2848a..313777679 100644 --- a/packages/ui/locales/es/views.json +++ b/packages/ui/locales/es/views.json @@ -389,9 +389,13 @@ "delete": "Delete webhook" }, "userManagement": { + "tabs": { + "active": "Active users", + "inactive": "Pending users" + }, + "usersHeader": "Users", "searchPlaceholder": "Search", - "newUserButton": "New user", - "usersHeader": "Users" + "newUserButton": "New user" }, "labelData": { "create": "Create labels" diff --git a/packages/ui/locales/fr/views.json b/packages/ui/locales/fr/views.json index 2656d2646..5755a34b2 100644 --- a/packages/ui/locales/fr/views.json +++ b/packages/ui/locales/fr/views.json @@ -395,9 +395,13 @@ "delete": "Delete webhook" }, "userManagement": { + "tabs": { + "active": "Active users", + "inactive": "Pending users" + }, + "usersHeader": "Users", "searchPlaceholder": "Search", - "newUserButton": "New user", - "usersHeader": "Users" + "newUserButton": "New user" }, "labelData": { "create": "Create labels" diff --git a/packages/ui/src/views/user-management/constants/filter-options.ts b/packages/ui/src/views/user-management/constants/filter-options.ts new file mode 100644 index 000000000..f09a4f4a7 --- /dev/null +++ b/packages/ui/src/views/user-management/constants/filter-options.ts @@ -0,0 +1,85 @@ +import { type FilterCondition, type FilterOption, type SortDirection, type SortOption } from '@components/filters' +import { TFunction } from 'i18next' + +export const getBasicConditions = (t: TFunction): FilterCondition[] => [ + { label: t('component:filter.is', 'is'), value: 'is' }, + { label: t('component:filter.isNot', 'is not'), value: 'is_not' }, + { label: t('component:filter.isEmpty', 'is empty'), value: 'is_empty' }, + { label: t('component:filter.isNotEmpty', 'is not empty'), value: 'is_not_empty' } +] + +export const getRangeConditions = (t: TFunction): FilterCondition[] => [ + { label: t('component:filter.is', 'is'), value: 'is' }, + { label: t('component:filter.isBefore', 'is before'), value: 'is_before' }, + { label: t('component:filter.isAfter', 'is after'), value: 'is_after' }, + { label: t('component:filter.isBetween', 'is between'), value: 'is_between' }, + { label: t('component:filter.isEmpty', 'is empty'), value: 'is_empty' }, + { label: t('component:filter.isNotEmpty', 'is not empty'), value: 'is_not_empty' } +] + +export const getTextConditions = (t: TFunction): FilterCondition[] => [ + { label: t('component:filter.is', 'is'), value: 'is' }, + { label: t('component:filter.isNot', 'is not'), value: 'is_not' }, + { label: t('component:filter.contains', 'contains'), value: 'contains' }, + { label: t('component:filter.doesNotContain', 'does not contain'), value: 'does_not_contain' }, + { label: t('component:filter.startsWith', 'starts with'), value: 'starts_with' }, + { label: t('component:filter.endsWith', 'ends with'), value: 'ends_with' }, + { label: t('component:filter.isEmpty', 'is empty'), value: 'is_empty' }, + { label: t('component:filter.isNotEmpty', 'is not empty'), value: 'is_not_empty' } +] + +export const getNumberConditions = (t: TFunction): FilterCondition[] => [ + { label: '=', value: 'equals' }, + { label: '≠', value: 'not_equals' }, + { label: '>', value: 'greater' }, + { label: '<', value: 'less' }, + { label: '≥', value: 'greater_equals' }, + { label: '≤', value: 'less_equals' }, + { label: t('component:filter.isEmpty', 'is empty'), value: 'is_empty' }, + { label: t('component:filter.isNotEmpty', 'is not empty'), value: 'is_not_empty' } +] + +export const getFilterOptions = (t: TFunction): FilterOption[] => [ + { + label: t('component:filter.type', 'Type'), + value: 'type', + type: 'checkbox', + conditions: getBasicConditions(t), + options: [ + { label: t('component:filter.public', 'Public'), value: 'public' }, + { label: t('component:filter.private', 'Private'), value: 'private' }, + { label: t('component:filter.fork', 'Fork'), value: 'fork' } + ] + }, + { + label: t('component:filter.createdTime', 'Created Time'), + value: 'created_time', + type: 'calendar', + conditions: getRangeConditions(t) + }, + { + label: t('component:filter.name', 'Name'), + value: 'name', + type: 'text', + conditions: getTextConditions(t) + }, + { + label: t('component:filter.stars', 'Stars'), + value: 'stars', + type: 'number', + conditions: getNumberConditions(t) + } +] + +export const getSortOptions = (t: TFunction): SortOption[] => [ + { label: t('component:sort.lastUpdated', 'Last updated'), value: 'updated' }, + { label: t('component:sort.stars', 'Stars'), value: 'stars' }, + { label: t('component:sort.forks', 'Forks'), value: 'forks' }, + { label: t('component:sort.pullRequests', 'Pull Requests'), value: 'pulls' }, + { label: t('component:sort.title', 'Title'), value: 'title' } +] + +export const getSortDirections = (t: TFunction): SortDirection[] => [ + { label: t('component:filter.ascending', 'Ascending'), value: 'asc' }, + { label: t('component:filter.descending', 'Descending'), value: 'desc' } +] diff --git a/packages/ui/src/views/user-management/hooks/index.ts b/packages/ui/src/views/user-management/hooks/index.ts new file mode 100644 index 000000000..15acc92c4 --- /dev/null +++ b/packages/ui/src/views/user-management/hooks/index.ts @@ -0,0 +1,3 @@ +import { useFilters } from './use-filters' + +export { useFilters } diff --git a/packages/ui/src/views/user-management/hooks/use-filters.tsx b/packages/ui/src/views/user-management/hooks/use-filters.tsx new file mode 100644 index 000000000..68216ef6f --- /dev/null +++ b/packages/ui/src/views/user-management/hooks/use-filters.tsx @@ -0,0 +1,204 @@ +import { useCallback, useState } from 'react' + +import { FilterAction, FilterHandlers, FilterSearchQueries, FilterValue, SortValue } from '@/components/filters/types' + +export const useFilters = (): FilterHandlers => { + // Initialize state with initialState if provided + const [activeFilters, setActiveFilters] = useState([]) + const [activeSorts, setActiveSorts] = useState([]) + const [searchQueries, setSearchQueries] = useState({ + filters: {}, + menu: {} + }) + const [filterToOpen, setFilterToOpen] = useState(null) + + // Load saved views on mount + + // FILTERS + /** + * Handles adding a new filter to the active filters list + * @param newFilter - Filter object containing: + * - type: string - The type of filter (e.g., 'type', 'language') + * - condition: string - The condition for filtering (will be overridden with default 'is') + * - selectedValues: string[] - Array of selected values (will be initialized as empty) + * + * Only adds the filter if one with the same type doesn't already exist + */ + const handleFilterChange = ( + newFilter: Omit, + defaultCondition = 'is' + ) => { + setActiveFilters(prevFilters => { + // Indicate which filter should be opened + setFilterToOpen({ type: newFilter.type, kind: 'filter' }) + + if (!prevFilters.find(f => f.type === newFilter.type)) { + return [ + ...prevFilters, + { + ...newFilter, + condition: defaultCondition, + selectedValues: [] + } + ] + } + return prevFilters + }) + } + + /** + * Updates the selected values for a specific filter type + * @param type - The type of filter to update (e.g., 'type', 'language') + * @param selectedValues - New array of selected values for the filter + * + * Maps through all filters and updates only the matching one + * while preserving other filters unchanged + */ + const handleUpdateFilter = (type: string, selectedValues: string[]) => { + setActiveFilters(activeFilters.map(filter => (filter.type === type ? { ...filter, selectedValues } : filter))) + } + + /** + * Updates the condition for a specific filter type + * @param type - The type of filter to update (e.g., 'type', 'language') + * @param condition - New condition value (e.g., 'is', 'is_not', 'is_empty') + * + * Special handling for 'is_empty' condition: + * - When 'is_empty' is selected, clears all selected values + * - For other conditions, preserves existing selected values + */ + const handleUpdateCondition = (type: string, condition: string) => { + setActiveFilters(prevFilters => + prevFilters.map(filter => { + if (filter.type === type) { + return { + ...filter, + condition, + selectedValues: condition === 'is_empty' ? [] : filter.selectedValues + } + } + return filter + }) + ) + } + + /** + * Removes a filter from the active filters list + * @param type - The type of filter to remove (e.g., 'type', 'language') + * + * Filters out the specified filter type while keeping all others + */ + const handleRemoveFilter = (type: string) => { + setActiveFilters(prevFilters => prevFilters.filter(filter => filter.type !== type)) + } + + const handleResetFilters = useCallback(() => { + setActiveFilters([]) + }, []) + + // SORTS + /** + * Handles adding a new sort to the active sorts list + * @param newSort - Sort object containing: + * - type: string - The type of sort (e.g., 'updated', 'stars') + * - direction: string - The direction of sort (will be initialized as 'asc') + * + * Only adds the sort if one with the same type doesn't already exist + */ + const handleSortChange = (newSort: SortValue) => { + // Indicate which filter should be opened + setFilterToOpen({ type: newSort.type, kind: 'sort' }) + + if (!activeSorts.find(sort => sort.type === newSort.type)) { + setActiveSorts([...activeSorts, newSort]) + } + } + + /** + * Updates a specific sort in the active sorts list + * @param index - The index of the sort to update + * @param updatedSort - The new sort object to replace the existing one + * + * Maps through all sorts and updates only the matching one + * while preserving other sorts unchanged + */ + const handleUpdateSort = (index: number, updatedSort: SortValue) => { + setActiveSorts(activeSorts.map((sort, i) => (i === index ? updatedSort : sort))) + } + + /** + * Removes a sort from the active sorts list + * @param index - The index of the sort to remove + * + * Filters out the specified sort index while keeping all others + */ + const handleRemoveSort = (index: number) => { + setActiveSorts(activeSorts.filter((_, i) => i !== index)) + } + + const handleReorderSorts = (newSorts: SortValue[]) => { + setActiveSorts(newSorts) + } + + const handleResetSorts = () => { + setActiveSorts([]) + } + + const handleResetAll = useCallback(() => { + setActiveFilters([]) + setActiveSorts([]) + setSearchQueries({ filters: {}, menu: {} }) + }, []) + + // SEARCH + const handleSearchChange = (type: string, query: string, searchType: keyof FilterSearchQueries) => { + setSearchQueries(prev => ({ + ...prev, + [searchType]: { + ...prev[searchType], + [type]: query + } + })) + } + + const clearSearchQuery = (type: string, searchType: keyof FilterSearchQueries) => { + setSearchQueries(prev => ({ + ...prev, + [searchType]: { + ...prev[searchType], + [type]: '' + } + })) + } + + // Clear the filter to open + const clearFilterToOpen = () => { + setFilterToOpen(null) + } + + return { + // Filters + activeFilters, + activeSorts, + setActiveFilters, + setActiveSorts, + searchQueries, + filterToOpen, + handleFilterChange, + handleUpdateFilter, + handleUpdateCondition, + handleRemoveFilter, + handleResetFilters, + clearFilterToOpen, + // Sorts + handleSortChange, + handleUpdateSort, + handleRemoveSort, + handleReorderSorts, + handleResetSorts, + handleResetAll, + // Search + handleSearchChange, + clearSearchQuery + } +} diff --git a/packages/ui/src/views/user-management/user-management-page.tsx b/packages/ui/src/views/user-management/user-management-page.tsx index 686908874..a28f1afb9 100644 --- a/packages/ui/src/views/user-management/user-management-page.tsx +++ b/packages/ui/src/views/user-management/user-management-page.tsx @@ -1,7 +1,9 @@ import { useMemo } from 'react' -import { Button, ListActions, PaginationComponent, SearchBox, Spacer, Text } from '@/components' +import { Button, Filters, FiltersBar, ListActions, PaginationComponent, SearchBox, Spacer, Text } from '@/components' import { SandboxLayout } from '@/views' +import { getFilterOptions, getSortDirections, getSortOptions } from '@views/repo/constants/filter-options' +import { useFilters } from '@views/repo/hooks' import { UserManagementTabs } from './components/tabs' import { UsersList } from './components/users-list' @@ -30,6 +32,12 @@ export const UserManagementPage: React.FC = ({ } = useAdminListUsersStore() const { t } = useTranslationStore() + const FILTER_OPTIONS = getFilterOptions(t) + const SORT_OPTIONS = getSortOptions(t) + const SORT_DIRECTIONS = getSortDirections(t) + + const filterHandlers = useFilters() + const handleSearch = (event: React.ChangeEvent) => { setSearchQuery(event.target.value) } @@ -68,11 +76,19 @@ export const UserManagementPage: React.FC = ({ /> + + {renderUserListContent()}