blob: 6cfdd67c5543b48c710123695fcd96b7e8cd4101 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import {
useFilters,
usePagination,
useRowSelect,
useRowState,
useSortBy,
useTable,
} from 'react-table';
import {
NumberParam,
StringParam,
useQueryParams,
QueryParamConfig,
} from 'use-query-params';
import rison from 'rison';
import { isEqual } from 'lodash';
import { PartialStylesConfig } from 'src/components/Select';
import {
FetchDataConfig,
Filter,
FilterValue,
InternalFilter,
SortColumn,
ViewModeType,
} from './types';
// Define custom RisonParam for proper encoding/decoding
const RisonParam: QueryParamConfig<string, any> = {
encode: (data?: any | null) =>
data === undefined ? undefined : rison.encode(data),
decode: (dataStr?: string | string[]) =>
dataStr === undefined || Array.isArray(dataStr)
? undefined
: rison.decode(dataStr),
};
export class ListViewError extends Error {
name = 'ListViewError';
}
// removes element from a list, returns new list
export function removeFromList(list: any[], index: number): any[] {
return list.filter((_, i) => index !== i);
}
// apply update to elements of object list, returns new list
function updateInList(list: any[], index: number, update: any): any[] {
const element = list.find((_, i) => index === i);
return [
...list.slice(0, index),
{ ...element, ...update },
...list.slice(index + 1),
];
}
type QueryFilterState = {
[id: string]: FilterValue['value'];
};
function mergeCreateFilterValues(list: Filter[], updateObj: QueryFilterState) {
return list.map(({ id, urlDisplay, operator }) => {
const currentFilterId = urlDisplay || id;
const update = updateObj[currentFilterId];
return { id, urlDisplay, operator, value: update };
});
}
// convert filters from UI objects to data objects
export function convertFilters(fts: InternalFilter[]): FilterValue[] {
return fts
.filter(
f =>
!(
typeof f.value === 'undefined' ||
(Array.isArray(f.value) && !f.value.length)
),
)
.map(({ value, operator, id }) => {
// handle between filter using 2 api filters
if (operator === 'between' && Array.isArray(value)) {
return [
{
value: value[0],
operator: 'gt',
id,
},
{
value: value[1],
operator: 'lt',
id,
},
];
}
return {
value,
operator,
id,
};
})
.flat();
}
// convertFilters but to handle new decoded rison format
export function convertFiltersRison(
filterObj: any,
list: Filter[],
): FilterValue[] {
const filters: FilterValue[] = [];
const refs = {};
Object.keys(filterObj).forEach(id => {
const filter: FilterValue = {
id,
value: filterObj[id],
// operator: filterObj[id][1], // TODO: can probably get rid of this
};
refs[id] = filter;
filters.push(filter);
});
// Add operators from filter list
list.forEach(value => {
const currentFilterId = value.urlDisplay || value.id;
const filter = refs[currentFilterId];
if (filter) {
filter.operator = value.operator;
filter.id = value.id;
}
});
return filters;
}
export function extractInputValue(inputType: Filter['input'], event: any) {
if (!inputType || inputType === 'text') {
return event.currentTarget.value;
}
if (inputType === 'checkbox') {
return event.currentTarget.checked;
}
return null;
}
interface UseListViewConfig {
fetchData: (conf: FetchDataConfig) => any;
columns: any[];
data: any[];
count: number;
initialPageSize: number;
initialSort?: SortColumn[];
bulkSelectMode?: boolean;
initialFilters?: Filter[];
bulkSelectColumnConfig?: {
id: string;
Header: (conf: any) => React.ReactNode;
Cell: (conf: any) => React.ReactNode;
};
renderCard?: boolean;
defaultViewMode?: ViewModeType;
}
export function useListViewState({
fetchData,
columns,
data,
count,
initialPageSize,
initialFilters = [],
initialSort = [],
bulkSelectMode = false,
bulkSelectColumnConfig,
renderCard = false,
defaultViewMode = 'card',
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: RisonParam,
pageIndex: NumberParam,
sortColumn: StringParam,
sortOrder: StringParam,
viewMode: StringParam,
});
const initialSortBy = useMemo(
() =>
query.sortColumn && query.sortOrder
? [{ id: query.sortColumn, desc: query.sortOrder === 'desc' }]
: initialSort,
[query.sortColumn, query.sortOrder],
);
const initialState = {
filters: query.filters
? convertFiltersRison(query.filters, initialFilters)
: [],
pageIndex: query.pageIndex || 0,
pageSize: initialPageSize,
sortBy: initialSortBy,
};
const [viewMode, setViewMode] = useState<ViewModeType>(
(query.viewMode as ViewModeType) ||
(renderCard ? defaultViewMode : 'table'),
);
const columnsWithSelect = useMemo(() => {
// add exact filter type so filters with falsey values are not filtered out
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
return bulkSelectMode
? [bulkSelectColumnConfig, ...columnsWithFilter]
: columnsWithFilter;
}, [bulkSelectMode, columns]);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
canPreviousPage,
canNextPage,
pageCount,
gotoPage,
setAllFilters,
selectedFlatRows,
toggleAllRowsSelected,
state: { pageIndex, pageSize, sortBy, filters },
} = useTable(
{
columns: columnsWithSelect,
count,
data,
disableFilters: true,
disableSortRemove: true,
initialState,
manualFilters: true,
manualPagination: true,
manualSortBy: true,
autoResetFilters: false,
pageCount: Math.ceil(count / initialPageSize),
},
useFilters,
useSortBy,
usePagination,
useRowState,
useRowSelect,
);
const [internalFilters, setInternalFilters] = useState<InternalFilter[]>(
query.filters && initialFilters.length
? mergeCreateFilterValues(initialFilters, query.filters)
: [],
);
useEffect(() => {
if (initialFilters.length) {
setInternalFilters(
mergeCreateFilterValues(
initialFilters,
query.filters ? query.filters : {},
),
);
}
}, [initialFilters]);
useEffect(() => {
// From internalFilters, produce a simplified obj
const filterObj = {};
internalFilters.forEach(filter => {
if (
filter.value !== undefined &&
(typeof filter.value !== 'string' || filter.value.length > 0)
) {
const currentFilterId = filter.urlDisplay || filter.id;
filterObj[currentFilterId] = filter.value;
}
});
const queryParams: any = {
filters: Object.keys(filterObj).length ? filterObj : undefined,
pageIndex,
};
if (sortBy[0]) {
queryParams.sortColumn = sortBy[0].id;
queryParams.sortOrder = sortBy[0].desc ? 'desc' : 'asc';
}
if (renderCard) {
queryParams.viewMode = viewMode;
}
const method =
typeof query.pageIndex !== 'undefined' &&
queryParams.pageIndex !== query.pageIndex
? 'push'
: 'replace';
setQuery(queryParams, method);
fetchData({ pageIndex, pageSize, sortBy, filters });
}, [fetchData, pageIndex, pageSize, sortBy, filters, viewMode]);
useEffect(() => {
if (!isEqual(initialState.pageIndex, pageIndex)) {
gotoPage(initialState.pageIndex);
}
}, [query]);
const applyFilterValue = (index: number, value: any) => {
setInternalFilters(currentInternalFilters => {
// skip redunundant updates
if (currentInternalFilters[index].value === value) {
return currentInternalFilters;
}
const update = { ...currentInternalFilters[index], value };
const updatedFilters = updateInList(
currentInternalFilters,
index,
update,
);
setAllFilters(convertFilters(updatedFilters));
gotoPage(0); // clear pagination on filter
return updatedFilters;
});
};
return {
canNextPage,
canPreviousPage,
getTableBodyProps,
getTableProps,
gotoPage,
headerGroups,
pageCount,
prepareRow,
rows,
selectedFlatRows,
setAllFilters,
state: { pageIndex, pageSize, sortBy, filters, internalFilters, viewMode },
toggleAllRowsSelected,
applyFilterValue,
setViewMode,
};
}
export const filterSelectStyles: PartialStylesConfig = {
container: (provider, { getValue }) => ({
...provider,
// dynamic width based on label string length
minWidth: `${Math.min(
12,
Math.max(5, 3 + getValue()[0].label.length / 2),
)}em`,
}),
control: provider => ({
...provider,
borderWidth: 0,
boxShadow: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
}),
};