Jobs page: advanced filtration and implemented sorting (#4319)
Co-authored-by: Nikita Manovich <nikita.manovich@intel.com> Co-authored-by: Maya <maya17grd@gmail.com>main
parent
c07a93d1ad
commit
b5bac8c0a5
@ -0,0 +1,335 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import 'react-awesome-query-builder/lib/css/styles.css';
|
||||
import AntdConfig from 'react-awesome-query-builder/lib/config/antd';
|
||||
import {
|
||||
Builder, Config, ImmutableTree, Query, Utils as QbUtils,
|
||||
} from 'react-awesome-query-builder';
|
||||
import {
|
||||
DownOutlined, FilterFilled, FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Space from 'antd/lib/space';
|
||||
import Button from 'antd/lib/button';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox/Checkbox';
|
||||
import Menu from 'antd/lib/menu';
|
||||
|
||||
interface ResourceFilterProps {
|
||||
predefinedVisible: boolean;
|
||||
recentVisible: boolean;
|
||||
builderVisible: boolean;
|
||||
onPredefinedVisibleChange(visible: boolean): void;
|
||||
onBuilderVisibleChange(visible: boolean): void;
|
||||
onRecentVisibleChange(visible: boolean): void;
|
||||
onApplyFilter(filter: string | null): void;
|
||||
}
|
||||
|
||||
export default function ResourceFilterHOC(
|
||||
filtrationCfg: Partial<Config>,
|
||||
localStorageRecentKeyword: string,
|
||||
localStorageRecentCapacity: number,
|
||||
predefinedFilterValues: Record<string, string>,
|
||||
defaultEnabledFilters: string[],
|
||||
): React.FunctionComponent<ResourceFilterProps> {
|
||||
const config: Config = { ...AntdConfig, ...filtrationCfg };
|
||||
const defaultTree = QbUtils.checkTree(
|
||||
QbUtils.loadTree({ id: QbUtils.uuid(), type: 'group' }), config,
|
||||
) as ImmutableTree;
|
||||
|
||||
function keepFilterInLocalStorage(filter: string): void {
|
||||
if (typeof filter !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let savedItems: string[] = [];
|
||||
try {
|
||||
savedItems = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
|
||||
if (!Array.isArray(savedItems) || savedItems.some((item: any) => typeof item !== 'string')) {
|
||||
throw new Error('Wrong filters value stored');
|
||||
}
|
||||
} catch (_: any) {
|
||||
// nothing to do
|
||||
}
|
||||
savedItems.splice(0, 0, filter);
|
||||
savedItems = Array.from(new Set(savedItems)).slice(0, localStorageRecentCapacity);
|
||||
localStorage.setItem(localStorageRecentKeyword, JSON.stringify(savedItems));
|
||||
}
|
||||
|
||||
function receiveRecentFilters(): Record<string, string> {
|
||||
let recentFilters: string[] = [];
|
||||
try {
|
||||
recentFilters = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
|
||||
if (!Array.isArray(recentFilters) || recentFilters.some((item: any) => typeof item !== 'string')) {
|
||||
throw new Error('Wrong filters value stored');
|
||||
}
|
||||
} catch (_: any) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
return recentFilters
|
||||
.reduce((acc: Record<string, string>, val: string) => ({ ...acc, [val]: val }), {});
|
||||
}
|
||||
|
||||
const defaultAppliedFilter: {
|
||||
predefined: string[] | null;
|
||||
recent: string | null;
|
||||
built: string | null;
|
||||
} = {
|
||||
predefined: null,
|
||||
recent: null,
|
||||
built: null,
|
||||
};
|
||||
|
||||
function ResourceFilterComponent(props: ResourceFilterProps): JSX.Element {
|
||||
const {
|
||||
predefinedVisible, builderVisible, recentVisible,
|
||||
onPredefinedVisibleChange, onBuilderVisibleChange, onRecentVisibleChange, onApplyFilter,
|
||||
} = props;
|
||||
|
||||
const user = useSelector((state: CombinedState) => state.auth.user);
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false);
|
||||
const [recentFilters, setRecentFilters] = useState<Record<string, string>>({});
|
||||
const [predefinedFilters, setPredefinedFilters] = useState<Record<string, string>>({});
|
||||
const [appliedFilter, setAppliedFilter] = useState<typeof defaultAppliedFilter>(defaultAppliedFilter);
|
||||
const [state, setState] = useState<ImmutableTree>(defaultTree);
|
||||
|
||||
useEffect(() => {
|
||||
setRecentFilters(receiveRecentFilters());
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of Object.keys(predefinedFilterValues)) {
|
||||
result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`);
|
||||
}
|
||||
|
||||
setPredefinedFilters(result);
|
||||
setAppliedFilter({
|
||||
...appliedFilter,
|
||||
predefined: defaultEnabledFilters
|
||||
.filter((filterKey: string) => filterKey in result)
|
||||
.map((filterKey: string) => result[filterKey]),
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
function unite(filters: string[]): string {
|
||||
if (filters.length > 1) {
|
||||
return JSON.stringify({
|
||||
and: filters.map((filter: string): JSON => JSON.parse(filter)),
|
||||
});
|
||||
}
|
||||
|
||||
return filters[0];
|
||||
}
|
||||
|
||||
function isValidTree(tree: ImmutableTree): boolean {
|
||||
return (QbUtils.queryString(tree, config) || '').trim().length > 0 && QbUtils.isValidTree(tree);
|
||||
}
|
||||
|
||||
if (!isMounted) {
|
||||
// do not request jobs before until on mount hook is done
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliedFilter.predefined?.length) {
|
||||
onApplyFilter(unite(appliedFilter.predefined));
|
||||
} else if (appliedFilter.recent) {
|
||||
onApplyFilter(appliedFilter.recent);
|
||||
const tree = QbUtils.loadFromJsonLogic(JSON.parse(appliedFilter.recent), config);
|
||||
if (isValidTree(tree)) {
|
||||
setState(tree);
|
||||
}
|
||||
} else if (appliedFilter.built) {
|
||||
onApplyFilter(appliedFilter.built);
|
||||
} else {
|
||||
onApplyFilter(null);
|
||||
setState(defaultTree);
|
||||
}
|
||||
}, [appliedFilter]);
|
||||
|
||||
const renderBuilder = (builderProps: any): JSX.Element => (
|
||||
<div className='query-builder-container'>
|
||||
<div className='query-builder qb-lite'>
|
||||
<Builder {...builderProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='cvat-jobs-page-filters'>
|
||||
<Dropdown
|
||||
destroyPopupOnHide
|
||||
visible={predefinedVisible}
|
||||
placement='bottomLeft'
|
||||
overlay={(
|
||||
<div className='cvat-jobs-page-predefined-filters-list'>
|
||||
{Object.keys(predefinedFilters).map((key: string): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={appliedFilter.predefined?.includes(predefinedFilters[key])}
|
||||
onChange={(event: CheckboxChangeEvent) => {
|
||||
let updatedValue: string[] | null = appliedFilter.predefined || [];
|
||||
if (event.target.checked) {
|
||||
updatedValue.push(predefinedFilters[key]);
|
||||
} else {
|
||||
updatedValue = updatedValue
|
||||
.filter((appliedValue: string) => (
|
||||
appliedValue !== predefinedFilters[key]
|
||||
));
|
||||
}
|
||||
|
||||
if (!updatedValue.length) {
|
||||
updatedValue = null;
|
||||
}
|
||||
|
||||
setAppliedFilter({
|
||||
...defaultAppliedFilter,
|
||||
predefined: updatedValue,
|
||||
});
|
||||
}}
|
||||
key={key}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)) }
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button type='default' onClick={() => onPredefinedVisibleChange(!predefinedVisible)}>
|
||||
Quick filters
|
||||
{ appliedFilter.predefined ?
|
||||
<FilterFilled /> :
|
||||
<FilterOutlined />}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
placement='bottomRight'
|
||||
visible={builderVisible}
|
||||
destroyPopupOnHide
|
||||
overlay={(
|
||||
<div className='cvat-jobs-page-filters-builder'>
|
||||
{ Object.keys(recentFilters).length ? (
|
||||
<Dropdown
|
||||
placement='bottomRight'
|
||||
visible={recentVisible}
|
||||
destroyPopupOnHide
|
||||
overlay={(
|
||||
<div className='cvat-jobs-page-recent-filters-list'>
|
||||
<Menu selectable={false}>
|
||||
{Object.keys(recentFilters).map((key: string): JSX.Element | null => {
|
||||
const tree = QbUtils.loadFromJsonLogic(JSON.parse(key), config);
|
||||
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={key}
|
||||
onClick={() => {
|
||||
if (appliedFilter.recent === key) {
|
||||
setAppliedFilter(defaultAppliedFilter);
|
||||
} else {
|
||||
setAppliedFilter({
|
||||
...defaultAppliedFilter,
|
||||
recent: key,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{QbUtils.queryString(tree, config)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='text'
|
||||
onClick={
|
||||
() => onRecentVisibleChange(!recentVisible)
|
||||
}
|
||||
>
|
||||
Recent
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
|
||||
<Query
|
||||
{...config}
|
||||
onChange={(tree: ImmutableTree) => {
|
||||
setState(tree);
|
||||
}}
|
||||
value={state}
|
||||
renderBuilder={renderBuilder}
|
||||
/>
|
||||
<Space className='cvat-jobs-page-filters-space'>
|
||||
<Button
|
||||
disabled={!QbUtils.queryString(state, config)}
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setState(defaultTree);
|
||||
setAppliedFilter({
|
||||
...appliedFilter,
|
||||
recent: null,
|
||||
built: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
const filter = QbUtils.jsonLogicFormat(state, config).logic;
|
||||
const stringified = JSON.stringify(filter);
|
||||
keepFilterInLocalStorage(stringified);
|
||||
setRecentFilters(receiveRecentFilters());
|
||||
onBuilderVisibleChange(false);
|
||||
setAppliedFilter({
|
||||
predefined: null,
|
||||
recent: null,
|
||||
built: stringified,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button type='default' onClick={() => onBuilderVisibleChange(!builderVisible)}>
|
||||
Filter
|
||||
{ appliedFilter.built || appliedFilter.recent ?
|
||||
<FilterFilled /> :
|
||||
<FilterOutlined />}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
disabled={!(appliedFilter.built || appliedFilter.predefined || appliedFilter.recent)}
|
||||
size='small'
|
||||
type='link'
|
||||
onClick={() => { setAppliedFilter({ ...defaultAppliedFilter }); }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return React.memo(ResourceFilterComponent);
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Config } from 'react-awesome-query-builder';
|
||||
|
||||
export const config: Partial<Config> = {
|
||||
fields: {
|
||||
state: {
|
||||
label: 'State',
|
||||
type: 'select',
|
||||
operators: ['select_any_in', 'select_equals'], // ['select_equals', 'select_not_equals', 'select_any_in', 'select_not_any_in']
|
||||
valueSources: ['value'],
|
||||
fieldSettings: {
|
||||
listValues: [
|
||||
{ value: 'new', title: 'new' },
|
||||
{ value: 'in progress', title: 'in progress' },
|
||||
{ value: 'rejected', title: 'rejected' },
|
||||
{ value: 'completed', title: 'completed' },
|
||||
],
|
||||
},
|
||||
},
|
||||
stage: {
|
||||
label: 'Stage',
|
||||
type: 'select',
|
||||
operators: ['select_any_in', 'select_equals'],
|
||||
valueSources: ['value'],
|
||||
fieldSettings: {
|
||||
listValues: [
|
||||
{ value: 'annotation', title: 'annotation' },
|
||||
{ value: 'validation', title: 'validation' },
|
||||
{ value: 'acceptance', title: 'acceptance' },
|
||||
],
|
||||
},
|
||||
},
|
||||
dimension: {
|
||||
label: 'Dimension',
|
||||
type: 'select',
|
||||
operators: ['select_equals'],
|
||||
valueSources: ['value'],
|
||||
fieldSettings: {
|
||||
listValues: [
|
||||
{ value: '2d', title: '2D' },
|
||||
{ value: '3d', title: '3D' },
|
||||
],
|
||||
},
|
||||
},
|
||||
assignee: {
|
||||
label: 'Assignee',
|
||||
type: 'text', // todo: change to select
|
||||
valueSources: ['value'],
|
||||
fieldSettings: {
|
||||
// useAsyncSearch: true,
|
||||
// forceAsyncSearch: true,
|
||||
// async fetch does not work for now in this library for AntdConfig
|
||||
// but that issue was solved, see https://github.com/ukrbublik/react-awesome-query-builder/issues/616
|
||||
// waiting for a new release, alternative is to use material design, but it is not the best option too
|
||||
// asyncFetch: async (search: string | null) => {
|
||||
// const users = await core.users.get({
|
||||
// limit: 10,
|
||||
// is_active: true,
|
||||
// ...(search ? { search } : {}),
|
||||
// });
|
||||
|
||||
// return {
|
||||
// values: users.map((user: any) => ({
|
||||
// value: user.username, title: user.username,
|
||||
// })),
|
||||
// hasMore: false,
|
||||
// };
|
||||
// },
|
||||
},
|
||||
},
|
||||
updated_date: {
|
||||
label: 'Last updated',
|
||||
type: 'datetime',
|
||||
operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
|
||||
},
|
||||
id: {
|
||||
label: 'ID',
|
||||
type: 'number',
|
||||
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
|
||||
fieldSettings: { min: 0 },
|
||||
valueSources: ['value'],
|
||||
},
|
||||
task_id: {
|
||||
label: 'Task ID',
|
||||
type: 'number',
|
||||
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
|
||||
fieldSettings: { min: 0 },
|
||||
valueSources: ['value'],
|
||||
},
|
||||
project_id: {
|
||||
label: 'Project ID',
|
||||
type: 'number',
|
||||
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
|
||||
fieldSettings: { min: 0 },
|
||||
valueSources: ['value'],
|
||||
},
|
||||
task_name: {
|
||||
label: 'Task name',
|
||||
type: 'text',
|
||||
valueSources: ['value'],
|
||||
operators: ['like'],
|
||||
},
|
||||
project_name: {
|
||||
label: 'Project name',
|
||||
type: 'text',
|
||||
valueSources: ['value'],
|
||||
operators: ['like'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const localStorageRecentCapacity = 10;
|
||||
export const localStorageRecentKeyword = 'recentlyAppliedJobsFilters';
|
||||
export const predefinedFilterValues = {
|
||||
'Assigned to me': '{"and":[{"==":[{"var":"assignee"},"<username>"]}]}',
|
||||
'Not completed': '{"!":{"or":[{"==":[{"var":"state"},"completed"]},{"==":[{"var":"stage"},"acceptance"]}]}}',
|
||||
};
|
||||
export const defaultEnabledFilters = ['Not completed'];
|
||||
@ -0,0 +1,182 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
|
||||
import {
|
||||
OrderedListOutlined, SortAscendingOutlined, SortDescendingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Button from 'antd/lib/button';
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Radio from 'antd/lib/radio';
|
||||
|
||||
import CVATTooltip from 'components/common/cvat-tooltip';
|
||||
|
||||
interface Props {
|
||||
sortingFields: string[];
|
||||
defaultFields: string[];
|
||||
visible: boolean;
|
||||
onVisibleChange(visible: boolean): void;
|
||||
onApplySorting(sorting: string | null): void;
|
||||
}
|
||||
|
||||
const ANCHOR_KEYWORD = '__anchor__';
|
||||
|
||||
const SortableItem = SortableElement(
|
||||
({
|
||||
value, appliedSorting, setAppliedSorting, valueIndex, anchorIndex,
|
||||
}: {
|
||||
value: string;
|
||||
valueIndex: number;
|
||||
anchorIndex: number;
|
||||
appliedSorting: Record<string, string>;
|
||||
setAppliedSorting: (arg: Record<string, string>) => void;
|
||||
}): JSX.Element => {
|
||||
const isActiveField = value in appliedSorting;
|
||||
const isAscendingField = isActiveField && !appliedSorting[value]?.startsWith('-');
|
||||
const isDescendingField = isActiveField && !isAscendingField;
|
||||
const onClick = (): void => {
|
||||
if (isDescendingField) {
|
||||
setAppliedSorting({ ...appliedSorting, [value]: value });
|
||||
} else if (isAscendingField) {
|
||||
setAppliedSorting({ ...appliedSorting, [value]: `-${value}` });
|
||||
}
|
||||
};
|
||||
|
||||
if (value === ANCHOR_KEYWORD) {
|
||||
return (
|
||||
<hr className='cvat-sorting-anchor' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='cvat-sorting-field'>
|
||||
<Radio.Button disabled={valueIndex > anchorIndex}>{value}</Radio.Button>
|
||||
<div>
|
||||
<CVATTooltip overlay={appliedSorting[value]?.startsWith('-') ? 'Descending sort' : 'Ascending sort'}>
|
||||
<Button type='text' disabled={!isActiveField} onClick={onClick}>
|
||||
{
|
||||
isDescendingField ? (
|
||||
<SortDescendingOutlined />
|
||||
) : (
|
||||
<SortAscendingOutlined />
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
</CVATTooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SortableList = SortableContainer(
|
||||
({ items, appliedSorting, setAppliedSorting } :
|
||||
{
|
||||
items: string[];
|
||||
appliedSorting: Record<string, string>;
|
||||
setAppliedSorting: (arg: Record<string, string>) => void;
|
||||
}) => (
|
||||
<div className='cvat-jobs-page-sorting-list'>
|
||||
{ items.map((value: string, index: number) => (
|
||||
<SortableItem
|
||||
key={`item-${value}`}
|
||||
appliedSorting={appliedSorting}
|
||||
setAppliedSorting={setAppliedSorting}
|
||||
index={index}
|
||||
value={value}
|
||||
valueIndex={index}
|
||||
anchorIndex={items.indexOf(ANCHOR_KEYWORD)}
|
||||
/>
|
||||
)) }
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
function SortingModalComponent(props: Props): JSX.Element {
|
||||
const {
|
||||
sortingFields: sortingFieldsProp,
|
||||
defaultFields, visible, onApplySorting, onVisibleChange,
|
||||
} = props;
|
||||
const [appliedSorting, setAppliedSorting] = useState<Record<string, string>>(
|
||||
defaultFields.reduce((acc: Record<string, string>, field: string) => {
|
||||
const [isAscending, absField] = field.startsWith('-') ?
|
||||
[false, field.slice(1).replace('_', ' ')] : [true, field.replace('_', ' ')];
|
||||
const originalField = sortingFieldsProp.find((el: string) => el.toLowerCase() === absField.toLowerCase());
|
||||
if (originalField) {
|
||||
return { ...acc, [originalField]: isAscending ? originalField : `-${originalField}` };
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
const [sortingFields, setSortingFields] = useState<string[]>(
|
||||
Array.from(new Set([...Object.keys(appliedSorting), ANCHOR_KEYWORD, ...sortingFieldsProp])),
|
||||
);
|
||||
const [appliedOrder, setAppliedOrder] = useState<string[]>([...defaultFields]);
|
||||
|
||||
useEffect(() => {
|
||||
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
|
||||
const appliedSortingCopy = { ...appliedSorting };
|
||||
const slicedSortingFields = sortingFields.slice(0, anchorIdx);
|
||||
const updated = slicedSortingFields.length !== appliedOrder.length || slicedSortingFields
|
||||
.some((field: string, index: number) => field !== appliedOrder[index]);
|
||||
|
||||
sortingFields.forEach((field: string, index: number) => {
|
||||
if (index < anchorIdx && !(field in appliedSortingCopy)) {
|
||||
appliedSortingCopy[field] = field;
|
||||
} else if (index >= anchorIdx && field in appliedSortingCopy) {
|
||||
delete appliedSortingCopy[field];
|
||||
}
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
setAppliedOrder(slicedSortingFields);
|
||||
setAppliedSorting(appliedSortingCopy);
|
||||
}
|
||||
}, [sortingFields]);
|
||||
|
||||
useEffect(() => {
|
||||
// this hook uses sortingFields to understand order
|
||||
// but we do not specify this field in dependencies
|
||||
// because we do not want the hook to be called after changing sortingField
|
||||
// sortingField value is always relevant because if order changes, the hook before will be called first
|
||||
|
||||
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
|
||||
const sortingString = sortingFields.slice(0, anchorIdx)
|
||||
.map((field: string): string => appliedSorting[field])
|
||||
.join(',').toLowerCase().replace(/\s/g, '_');
|
||||
onApplySorting(sortingString || null);
|
||||
}, [appliedSorting]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
destroyPopupOnHide
|
||||
visible={visible}
|
||||
placement='bottomLeft'
|
||||
overlay={(
|
||||
<SortableList
|
||||
onSortEnd={({ oldIndex, newIndex }: { oldIndex: number, newIndex: number }) => {
|
||||
if (oldIndex !== newIndex) {
|
||||
const sortingFieldsCopy = [...sortingFields];
|
||||
sortingFieldsCopy.splice(newIndex, 0, ...sortingFieldsCopy.splice(oldIndex, 1));
|
||||
setSortingFields(sortingFieldsCopy);
|
||||
}
|
||||
}}
|
||||
helperClass='cvat-sorting-dragged-item'
|
||||
items={sortingFields}
|
||||
appliedSorting={appliedSorting}
|
||||
setAppliedSorting={setAppliedSorting}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button type='default' onClick={() => onVisibleChange(!visible)}>
|
||||
Sort by
|
||||
<OrderedListOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SortingModalComponent);
|
||||
@ -0,0 +1,218 @@
|
||||
# Copyright (C) 2022 Intel Corporation
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from rest_framework import filters
|
||||
from functools import reduce
|
||||
import operator
|
||||
import json
|
||||
from django.db.models import Q
|
||||
from rest_framework.compat import coreapi, coreschema
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import force_str
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
class SearchFilter(filters.SearchFilter):
|
||||
|
||||
def get_search_fields(self, view, request):
|
||||
search_fields = getattr(view, 'search_fields', [])
|
||||
lookup_fields = {field:field for field in search_fields}
|
||||
view_lookup_fields = getattr(view, 'lookup_fields', {})
|
||||
keys_to_update = set(search_fields) & set(view_lookup_fields.keys())
|
||||
for key in keys_to_update:
|
||||
lookup_fields[key] = view_lookup_fields[key]
|
||||
|
||||
return lookup_fields.values()
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
|
||||
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
|
||||
|
||||
search_fields = getattr(view, 'search_fields', [])
|
||||
full_description = self.search_description + \
|
||||
f' Avaliable search_fields: {search_fields}'
|
||||
|
||||
return [
|
||||
coreapi.Field(
|
||||
name=self.search_param,
|
||||
required=False,
|
||||
location='query',
|
||||
schema=coreschema.String(
|
||||
title=force_str(self.search_title),
|
||||
description=force_str(full_description)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
search_fields = getattr(view, 'search_fields', [])
|
||||
full_description = self.search_description + \
|
||||
f' Avaliable search_fields: {search_fields}'
|
||||
|
||||
return [{
|
||||
'name': self.search_param,
|
||||
'required': False,
|
||||
'in': 'query',
|
||||
'description': force_str(full_description),
|
||||
'schema': {
|
||||
'type': 'string',
|
||||
},
|
||||
}]
|
||||
|
||||
class OrderingFilter(filters.OrderingFilter):
|
||||
ordering_param = 'sort'
|
||||
def get_ordering(self, request, queryset, view):
|
||||
ordering = []
|
||||
lookup_fields = self._get_lookup_fields(request, queryset, view)
|
||||
for term in super().get_ordering(request, queryset, view):
|
||||
flag = ''
|
||||
if term.startswith("-"):
|
||||
flag = '-'
|
||||
term = term[1:]
|
||||
ordering.append(flag + lookup_fields[term])
|
||||
|
||||
return ordering
|
||||
|
||||
def _get_lookup_fields(self, request, queryset, view):
|
||||
ordering_fields = self.get_valid_fields(queryset, view, {'request': request})
|
||||
lookup_fields = {field:field for field, _ in ordering_fields}
|
||||
lookup_fields.update(getattr(view, 'lookup_fields', {}))
|
||||
|
||||
return lookup_fields
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
|
||||
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
|
||||
|
||||
ordering_fields = getattr(view, 'ordering_fields', [])
|
||||
full_description = self.ordering_description + \
|
||||
f' Avaliable ordering_fields: {ordering_fields}'
|
||||
|
||||
return [
|
||||
coreapi.Field(
|
||||
name=self.ordering_param,
|
||||
required=False,
|
||||
location='query',
|
||||
schema=coreschema.String(
|
||||
title=force_str(self.ordering_title),
|
||||
description=force_str(full_description)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
ordering_fields = getattr(view, 'ordering_fields', [])
|
||||
full_description = self.ordering_description + \
|
||||
f' Avaliable ordering_fields: {ordering_fields}'
|
||||
|
||||
return [{
|
||||
'name': self.ordering_param,
|
||||
'required': False,
|
||||
'in': 'query',
|
||||
'description': force_str(full_description),
|
||||
'schema': {
|
||||
'type': 'string',
|
||||
},
|
||||
}]
|
||||
|
||||
class JsonLogicFilter(filters.BaseFilterBackend):
|
||||
filter_param = 'filter'
|
||||
filter_title = _('Filter')
|
||||
filter_description = _('A filter term.')
|
||||
|
||||
def _build_Q(self, rules, lookup_fields):
|
||||
op, args = next(iter(rules.items()))
|
||||
if op in ['or', 'and']:
|
||||
return reduce({
|
||||
'or': operator.or_,
|
||||
'and': operator.and_
|
||||
}[op], [self._build_Q(arg, lookup_fields) for arg in args])
|
||||
elif op == '!':
|
||||
return ~self._build_Q(args, lookup_fields)
|
||||
elif op == '!!':
|
||||
return self._build_Q(args, lookup_fields)
|
||||
elif op == 'var':
|
||||
return Q(**{args + '__isnull': False})
|
||||
elif op in ['==', '<', '>', '<=', '>='] and len(args) == 2:
|
||||
var = lookup_fields[args[0]['var']]
|
||||
q_var = var + {
|
||||
'==': '',
|
||||
'<': '__lt',
|
||||
'<=': '__lte',
|
||||
'>': '__gt',
|
||||
'>=': '__gte'
|
||||
}[op]
|
||||
return Q(**{q_var: args[1]})
|
||||
elif op == 'in':
|
||||
if isinstance(args[0], dict):
|
||||
var = lookup_fields[args[0]['var']]
|
||||
return Q(**{var + '__in': args[1]})
|
||||
else:
|
||||
var = lookup_fields[args[1]['var']]
|
||||
return Q(**{var + '__contains': args[0]})
|
||||
elif op == '<=' and len(args) == 3:
|
||||
var = lookup_fields[args[1]['var']]
|
||||
return Q(**{var + '__gte': args[0]}) & Q(**{var + '__lte': args[2]})
|
||||
else:
|
||||
raise ValidationError(f'filter: {op} operation with {args} arguments is not implemented')
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
json_rules = request.query_params.get(self.filter_param)
|
||||
if json_rules:
|
||||
try:
|
||||
rules = json.loads(json_rules)
|
||||
if not len(rules):
|
||||
raise ValidationError(f"filter shouldn't be empty")
|
||||
except json.decoder.JSONDecodeError:
|
||||
raise ValidationError(f'filter: Json syntax should be used')
|
||||
lookup_fields = self._get_lookup_fields(request, view)
|
||||
try:
|
||||
q_object = self._build_Q(rules, lookup_fields)
|
||||
except KeyError as ex:
|
||||
raise ValidationError(f'filter: {str(ex)} term is not supported')
|
||||
return queryset.filter(q_object)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
|
||||
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
|
||||
|
||||
filter_fields = getattr(view, 'filter_fields', [])
|
||||
full_description = self.filter_description + \
|
||||
f' Avaliable filter_fields: {filter_fields}'
|
||||
|
||||
return [
|
||||
coreapi.Field(
|
||||
name=self.filter_param,
|
||||
required=False,
|
||||
location='query',
|
||||
schema=coreschema.String(
|
||||
title=force_str(self.filter_title),
|
||||
description=force_str(full_description)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
filter_fields = getattr(view, 'filter_fields', [])
|
||||
full_description = self.filter_description + \
|
||||
f' Avaliable filter_fields: {filter_fields}'
|
||||
return [
|
||||
{
|
||||
'name': self.filter_param,
|
||||
'required': False,
|
||||
'in': 'query',
|
||||
'description': force_str(full_description),
|
||||
'schema': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def _get_lookup_fields(self, request, view):
|
||||
filter_fields = getattr(view, 'filter_fields', [])
|
||||
lookup_fields = {field:field for field in filter_fields}
|
||||
lookup_fields.update(getattr(view, 'lookup_fields', {}))
|
||||
|
||||
return lookup_fields
|
||||
Loading…
Reference in New Issue