feat: persist view filters and sorts on Update View button click (#1290)

* feat: add viewFilters table

Closes #1121

* feat: add Update View button + Create View dropdown

Closes #1124, #1289

* feat: add View Filter resolvers

* feat: persist view filters and sorts on Update View button click

Closes #1123

* refactor: code review

- Rename recoil selectors
- Rename filters `field` property to `key`
This commit is contained in:
Thaïs
2023-08-23 18:20:43 +02:00
committed by GitHub
parent 76246ec880
commit 74ab0142c7
54 changed files with 1331 additions and 277 deletions

View File

@ -4,6 +4,7 @@ import { useRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { FilterOperand } from '@/ui/filter-n-sort/types/FilterOperand';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { activeTabIdScopedState } from '@/ui/tab/states/activeTabIdScopedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
@ -37,10 +38,10 @@ export function useTasks() {
if (currentUser && !filters.length) {
setFilters([
{
field: 'assigneeId',
key: 'assigneeId',
type: 'entity',
value: currentUser.id,
operand: 'is',
operand: FilterOperand.Is,
displayValue: currentUser.displayName,
displayAvatarUrl: currentUser.avatarUrl ?? undefined,
},

View File

@ -1,20 +1,20 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { companyViewFields } from '@/companies/constants/companyViewFields';
import { useCompanyTableActionBarEntries } from '@/companies/hooks/useCompanyTableActionBarEntries';
import { useCompanyTableContextMenuEntries } from '@/companies/hooks/useCompanyTableContextMenuEntries';
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { sortsOrderByScopedSelector } from '@/ui/filter-n-sort/states/sortsOrderByScopedSelector';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { EntityTable } from '@/ui/table/components/EntityTable';
import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData';
import { useUpsertEntityTableItem } from '@/ui/table/hooks/useUpsertEntityTableItem';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useTableViewFields } from '@/views/hooks/useTableViewFields';
import { useTableViews } from '@/views/hooks/useTableViews';
import { useViewFilters } from '@/views/hooks/useViewFilters';
import { useViewSorts } from '@/views/hooks/useViewSorts';
import {
SortOrder,
@ -26,12 +26,8 @@ import { companiesFilters } from '~/pages/companies/companies-filters';
import { availableSorts } from '~/pages/companies/companies-sorts';
export function CompanyTable() {
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const orderBy = useRecoilScopedValue(
sortsOrderByScopedState,
sortsOrderByScopedSelector,
TableRecoilScopeContext,
);
const [updateEntityMutation] = useUpdateOneCompanyMutation();
@ -44,7 +40,10 @@ export function CompanyTable() {
viewFieldDefinitions: companyViewFields,
});
const { handleSortsChange } = useViewSorts({ availableSorts });
const { persistFilters } = useViewFilters({
availableFilters: companiesFilters,
});
const { persistSorts } = useViewSorts({ availableSorts });
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
const filters = useRecoilScopedValue(
@ -59,6 +58,11 @@ export function CompanyTable() {
const { setContextMenuEntries } = useCompanyTableContextMenuEntries();
const { setActionBarEntries } = useCompanyTableActionBarEntries();
const handleViewSubmit = useCallback(async () => {
await persistFilters();
await persistSorts();
}, [persistFilters, persistSorts]);
function handleImport() {
openCompanySpreadsheetImport();
}
@ -68,15 +72,7 @@ export function CompanyTable() {
<GenericEntityTableData
getRequestResultKey="companies"
useGetRequest={useGetCompaniesQuery}
orderBy={
orderBy.length
? orderBy
: [
{
createdAt: SortOrder.Desc,
},
]
}
orderBy={orderBy.length ? orderBy : [{ createdAt: SortOrder.Desc }]}
whereFilters={whereFilters}
filterDefinitionArray={companiesFilters}
setContextMenuEntries={setContextMenuEntries}
@ -86,8 +82,8 @@ export function CompanyTable() {
viewName="All Companies"
availableSorts={availableSorts}
onColumnsChange={handleColumnsChange}
onSortsUpdate={currentViewId ? handleSortsChange : undefined}
onViewsChange={handleViewsChange}
onViewSubmit={handleViewSubmit}
onImport={handleImport}
updateEntityMutation={({
variables,

View File

@ -1,20 +1,20 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { peopleViewFields } from '@/people/constants/peopleViewFields';
import { usePersonTableContextMenuEntries } from '@/people/hooks/usePeopleTableContextMenuEntries';
import { usePersonTableActionBarEntries } from '@/people/hooks/usePersonTableActionBarEntries';
import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { sortsOrderByScopedSelector } from '@/ui/filter-n-sort/states/sortsOrderByScopedSelector';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { EntityTable } from '@/ui/table/components/EntityTable';
import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData';
import { useUpsertEntityTableItem } from '@/ui/table/hooks/useUpsertEntityTableItem';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useTableViewFields } from '@/views/hooks/useTableViewFields';
import { useTableViews } from '@/views/hooks/useTableViews';
import { useViewFilters } from '@/views/hooks/useViewFilters';
import { useViewSorts } from '@/views/hooks/useViewSorts';
import {
SortOrder,
@ -26,12 +26,8 @@ import { peopleFilters } from '~/pages/people/people-filters';
import { availableSorts } from '~/pages/people/people-sorts';
export function PeopleTable() {
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const orderBy = useRecoilScopedValue(
sortsOrderByScopedState,
sortsOrderByScopedSelector,
TableRecoilScopeContext,
);
const [updateEntityMutation] = useUpdateOnePersonMutation();
@ -44,7 +40,10 @@ export function PeopleTable() {
objectName: objectId,
viewFieldDefinitions: peopleViewFields,
});
const { handleSortsChange } = useViewSorts({ availableSorts });
const { persistFilters } = useViewFilters({
availableFilters: peopleFilters,
});
const { persistSorts } = useViewSorts({ availableSorts });
const filters = useRecoilScopedValue(
filtersScopedState,
@ -58,6 +57,11 @@ export function PeopleTable() {
const { setContextMenuEntries } = usePersonTableContextMenuEntries();
const { setActionBarEntries } = usePersonTableActionBarEntries();
const handleViewSubmit = useCallback(async () => {
await persistFilters();
await persistSorts();
}, [persistFilters, persistSorts]);
function handleImport() {
openPersonSpreadsheetImport();
}
@ -67,15 +71,7 @@ export function PeopleTable() {
<GenericEntityTableData
getRequestResultKey="people"
useGetRequest={useGetPeopleQuery}
orderBy={
orderBy.length
? orderBy
: [
{
createdAt: SortOrder.Desc,
},
]
}
orderBy={orderBy.length ? orderBy : [{ createdAt: SortOrder.Desc }]}
whereFilters={whereFilters}
filterDefinitionArray={peopleFilters}
setContextMenuEntries={setContextMenuEntries}
@ -85,8 +81,8 @@ export function PeopleTable() {
viewName="All People"
availableSorts={availableSorts}
onColumnsChange={handleColumnsChange}
onSortsUpdate={currentViewId ? handleSortsChange : undefined}
onViewsChange={handleViewsChange}
onViewSubmit={handleViewSubmit}
onImport={handleImport}
updateEntityMutation={({
variables,

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import styled from '@emotion/styled';
import { ButtonPosition, ButtonProps } from './Button';
@ -9,13 +9,15 @@ const StyledButtonGroupContainer = styled.div`
`;
type ButtonGroupProps = Pick<ButtonProps, 'variant' | 'size'> & {
children: React.ReactElement[];
children: ReactNode[];
};
export function ButtonGroup({ children, variant, size }: ButtonGroupProps) {
return (
<StyledButtonGroupContainer>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return null;
let position: ButtonPosition;
if (index === 0) {

View File

@ -28,7 +28,7 @@ export function FilterDropdownDateSearchInput({
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return;
upsertFilter({
field: filterDefinitionUsedInDropdown.field,
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: date.toISOString(),
operand: selectedOperandInDropdown,

View File

@ -51,14 +51,14 @@ export function FilterDropdownEntitySearchSelect({
selectedEntity.id === filterDropdownSelectedEntityId;
if (clickedOnAlreadySelectedEntity) {
removeFilter(filterDefinitionUsedInDropdown.field);
removeFilter(filterDefinitionUsedInDropdown.key);
setFilterDropdownSelectedEntityId(null);
} else {
setFilterDropdownSelectedEntityId(selectedEntity.id);
upsertFilter({
displayValue: selectedEntity.name,
field: filterDefinitionUsedInDropdown.field,
key: filterDefinitionUsedInDropdown.key,
operand: selectedOperandInDropdown,
type: filterDefinitionUsedInDropdown.type,
value: selectedEntity.id,

View File

@ -34,10 +34,10 @@ export function FilterDropdownNumberSearchInput({
placeholder={filterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value === '') {
removeFilter(filterDefinitionUsedInDropdown.field);
removeFilter(filterDefinitionUsedInDropdown.key);
} else {
upsertFilter({
field: filterDefinitionUsedInDropdown.field,
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,

View File

@ -48,7 +48,7 @@ export function FilterDropdownOperandSelect({
if (filterDefinitionUsedInDropdown && filterCurrentlyEdited) {
upsertFilter({
field: filterCurrentlyEdited.field,
key: filterCurrentlyEdited.key,
displayValue: filterCurrentlyEdited.displayValue,
operand: newOperand,
type: filterCurrentlyEdited.type,

View File

@ -44,10 +44,10 @@ export function FilterDropdownTextSearchInput({
setFilterDropdownSearchInput(event.target.value);
if (event.target.value === '') {
removeFilter(filterDefinitionUsedInDropdown.field);
removeFilter(filterDefinitionUsedInDropdown.key);
} else {
upsertFilter({
field: filterDefinitionUsedInDropdown.field,
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,

View File

@ -1,4 +1,4 @@
import { Context } from 'react';
import type { Context, ReactNode } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -26,6 +26,7 @@ type OwnProps<SortField> = {
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
onCancelClick: () => void;
hasFilterButton?: boolean;
rightComponent?: ReactNode;
};
const StyledBar = styled.div`
@ -97,6 +98,7 @@ function SortAndFilterBar<SortField>({
onRemoveSort,
onCancelClick,
hasFilterButton = false,
rightComponent,
}: OwnProps<SortField>) {
const theme = useTheme();
@ -117,7 +119,7 @@ function SortAndFilterBar<SortField>({
const filtersWithDefinition = filters.map((filter) => {
const filterDefinition = availableFilters.find((availableFilter) => {
return availableFilter.field === filter.field;
return availableFilter.key === filter.key;
});
return {
@ -170,15 +172,15 @@ function SortAndFilterBar<SortField>({
{filtersWithDefinition.map((filter) => {
return (
<SortOrFilterChip
key={filter.field}
key={filter.key}
labelKey={filter.label}
labelValue={`${getOperandLabelShort(filter.operand)} ${
filter.displayValue
}`}
id={filter.field}
id={filter.key}
icon={filter.icon}
onRemove={() => {
removeFilter(filter.field);
removeFilter(filter.key);
}}
/>
);
@ -190,18 +192,19 @@ function SortAndFilterBar<SortField>({
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
color={theme.font.color.tertiary}
icon={<IconPlus size={theme.icon.size.md} />}
label={`Add filter`}
label="Add filter"
/>
)}
</StyledFilterContainer>
{filters.length + sorts.length > 0 && (
<StyledCancelButton
data-testid={'cancel-button'}
data-testid="cancel-button"
onClick={handleCancelClick}
>
Cancel
</StyledCancelButton>
)}
{rightComponent}
</StyledBar>
);
}

View File

@ -15,7 +15,7 @@ export function useFilterCurrentlyEdited(context: Context<string | null>) {
return useMemo(() => {
return filters.find(
(filter) => filter.field === filterDefinitionUsedInDropdown?.field,
(filter) => filter.key === filterDefinitionUsedInDropdown?.key,
);
}, [filterDefinitionUsedInDropdown, filters]);
}

View File

@ -7,10 +7,10 @@ import { filtersScopedState } from '../states/filtersScopedState';
export function useRemoveFilter(context: Context<string | null>) {
const [, setFilters] = useRecoilScopedState(filtersScopedState, context);
return function removeFilter(filterField: string) {
return function removeFilter(filterKey: string) {
setFilters((filters) => {
return filters.filter((filter) => {
return filter.field !== filterField;
return filter.key !== filterKey;
});
});
};

View File

@ -13,7 +13,7 @@ export function useUpsertFilter(context: Context<string | null>) {
setFilters((filters) => {
return produce(filters, (filtersDraft) => {
const index = filtersDraft.findIndex(
(filter) => filter.field === filterToUpsert.field,
(filter) => filter.key === filterToUpsert.key,
);
if (index === -1) {

View File

@ -1,6 +1,6 @@
import { atomFamily } from 'recoil';
import { Filter } from '../types/Filter';
import type { Filter } from '../types/Filter';
export const filtersScopedState = atomFamily<Filter[], string>({
key: 'filtersScopedState',

View File

@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import type { Filter } from '../types/Filter';
import { savedFiltersScopedState } from './savedFiltersScopedState';
export const savedFiltersByKeyScopedSelector = selectorFamily({
key: 'savedFiltersByKeyScopedSelector',
get:
(param: string | undefined) =>
({ get }) =>
get(savedFiltersScopedState(param)).reduce<Record<string, Filter>>(
(result, filter) => ({ ...result, [filter.key]: filter }),
{},
),
});

View File

@ -0,0 +1,10 @@
import { atomFamily } from 'recoil';
import type { Filter } from '../types/Filter';
export const savedFiltersScopedState = atomFamily<Filter[], string | undefined>(
{
key: 'savedFiltersScopedState',
default: [],
},
);

View File

@ -0,0 +1,15 @@
import { selectorFamily } from 'recoil';
import type { SelectedSortType } from '../types/interface';
import { savedSortsScopedState } from './savedSortsScopedState';
export const savedSortsByKeyScopedSelector = selectorFamily({
key: 'savedSortsByKeyScopedSelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedSortsScopedState(viewId)).reduce<
Record<string, SelectedSortType<any>>
>((result, sort) => ({ ...result, [sort.key]: sort }), {}),
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import type { SelectedSortType } from '../types/interface';
export const savedSortsScopedState = atomFamily<
SelectedSortType<any>[],
string | undefined
>({
key: 'savedSortsScopedState',
default: [],
});

View File

@ -1,28 +0,0 @@
import { atomFamily, selectorFamily } from 'recoil';
import { reduceSortsToOrderBy } from '../helpers';
import { SelectedSortType } from '../types/interface';
export const sortScopedState = atomFamily<SelectedSortType<any>[], string>({
key: 'sortScopedState',
default: [],
});
export const sortsByKeyScopedState = selectorFamily({
key: 'sortsByKeyScopedState',
get:
(param: string) =>
({ get }) =>
get(sortScopedState(param)).reduce<Record<string, SelectedSortType<any>>>(
(result, sort) => ({ ...result, [sort.key]: sort }),
{},
),
});
export const sortsOrderByScopedState = selectorFamily({
key: 'sortsOrderByScopedState',
get:
(param: string) =>
({ get }) =>
reduceSortsToOrderBy(get(sortScopedState(param))),
});

View File

@ -0,0 +1,13 @@
import { selectorFamily } from 'recoil';
import { reduceSortsToOrderBy } from '../helpers';
import { sortsScopedState } from './sortsScopedState';
export const sortsOrderByScopedSelector = selectorFamily({
key: 'sortsOrderByScopedSelector',
get:
(param: string) =>
({ get }) =>
reduceSortsToOrderBy(get(sortsScopedState(param))),
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import type { SelectedSortType } from '../types/interface';
export const sortsScopedState = atomFamily<SelectedSortType<any>[], string>({
key: 'sortsScopedState',
default: [],
});

View File

@ -2,7 +2,7 @@ import { FilterOperand } from './FilterOperand';
import { FilterType } from './FilterType';
export type Filter = {
field: string;
key: string;
type: FilterType;
value: string;
displayValue: string;

View File

@ -1,7 +1,7 @@
import { FilterType } from './FilterType';
export type FilterDefinition = {
field: string;
key: string;
label: string;
icon: JSX.Element;
type: FilterType;

View File

@ -1,5 +1,5 @@
import { FilterDefinition } from './FilterDefinition';
export type FilterDefinitionByEntity<T> = FilterDefinition & {
field: keyof T;
key: keyof T;
};

View File

@ -1,7 +1 @@
export type FilterOperand =
| 'contains'
| 'does-not-contain'
| 'greater-than'
| 'less-than'
| 'is'
| 'is-not';
export { ViewFilterOperand as FilterOperand } from '~/generated/graphql';

View File

@ -2,17 +2,17 @@ import { FilterOperand } from '../types/FilterOperand';
export function getOperandLabel(operand: FilterOperand | null | undefined) {
switch (operand) {
case 'contains':
case FilterOperand.Contains:
return 'Contains';
case 'does-not-contain':
case FilterOperand.DoesNotContain:
return "Doesn't contain";
case 'greater-than':
case FilterOperand.GreaterThan:
return 'Greater than';
case 'less-than':
case FilterOperand.LessThan:
return 'Less than';
case 'is':
case FilterOperand.Is:
return 'Is';
case 'is-not':
case FilterOperand.IsNot:
return 'Is not';
default:
return '';
@ -22,15 +22,15 @@ export function getOperandLabelShort(
operand: FilterOperand | null | undefined,
) {
switch (operand) {
case 'is':
case 'contains':
case FilterOperand.Is:
case FilterOperand.Contains:
return ': ';
case 'is-not':
case 'does-not-contain':
case FilterOperand.IsNot:
case FilterOperand.DoesNotContain:
return ': Not';
case 'greater-than':
case FilterOperand.GreaterThan:
return '\u00A0> ';
case 'less-than':
case FilterOperand.LessThan:
return '\u00A0< ';
default:
return ': ';

View File

@ -6,12 +6,12 @@ export function getOperandsForFilterType(
): FilterOperand[] {
switch (filterType) {
case 'text':
return ['contains', 'does-not-contain'];
return [FilterOperand.Contains, FilterOperand.DoesNotContain];
case 'number':
case 'date':
return ['greater-than', 'less-than'];
return [FilterOperand.GreaterThan, FilterOperand.LessThan];
case 'entity':
return ['is', 'is-not'];
return [FilterOperand.Is, FilterOperand.IsNot];
default:
return [];
}

View File

@ -1,21 +1,22 @@
import { QueryMode } from '~/generated/graphql';
import { Filter } from '../types/Filter';
import { FilterOperand } from '../types/FilterOperand';
export function turnFilterIntoWhereClause(filter: Filter) {
switch (filter.type) {
case 'text':
switch (filter.operand) {
case 'contains':
case FilterOperand.Contains:
return {
[filter.field]: {
[filter.key]: {
contains: filter.value,
mode: QueryMode.Insensitive,
},
};
case 'does-not-contain':
case FilterOperand.DoesNotContain:
return {
[filter.field]: {
[filter.key]: {
not: {
contains: filter.value,
mode: QueryMode.Insensitive,
@ -29,15 +30,15 @@ export function turnFilterIntoWhereClause(filter: Filter) {
}
case 'number':
switch (filter.operand) {
case 'greater-than':
case FilterOperand.GreaterThan:
return {
[filter.field]: {
[filter.key]: {
gte: parseFloat(filter.value),
},
};
case 'less-than':
case FilterOperand.LessThan:
return {
[filter.field]: {
[filter.key]: {
lte: parseFloat(filter.value),
},
};
@ -48,15 +49,15 @@ export function turnFilterIntoWhereClause(filter: Filter) {
}
case 'date':
switch (filter.operand) {
case 'greater-than':
case FilterOperand.GreaterThan:
return {
[filter.field]: {
[filter.key]: {
gte: filter.value,
},
};
case 'less-than':
case FilterOperand.LessThan:
return {
[filter.field]: {
[filter.key]: {
lte: filter.value,
},
};
@ -67,15 +68,15 @@ export function turnFilterIntoWhereClause(filter: Filter) {
}
case 'entity':
switch (filter.operand) {
case 'is':
case FilterOperand.Is:
return {
[filter.field]: {
[filter.key]: {
equals: filter.value,
},
};
case 'is-not':
case FilterOperand.IsNot:
return {
[filter.field]: {
[filter.key]: {
not: { equals: filter.value },
},
};

View File

@ -5,7 +5,7 @@ import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { SortType } from '@/ui/filter-n-sort/types/interface';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -97,8 +97,8 @@ type OwnProps<SortField> = {
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onViewsChange?: (views: TableView[]) => void;
onViewSubmit?: () => void;
onImport?: () => void;
updateEntityMutation: any;
};
@ -107,8 +107,8 @@ export function EntityTable<SortField>({
viewName,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
onViewSubmit,
onImport,
updateEntityMutation,
}: OwnProps<SortField>) {
@ -136,8 +136,8 @@ export function EntityTable<SortField>({
viewName={viewName}
availableSorts={availableSorts}
onColumnsChange={onColumnsChange}
onSortsUpdate={onSortsUpdate}
onViewsChange={onViewsChange}
onViewSubmit={onViewSubmit}
onImport={onImport}
/>
<StyledTableWrapper>

View File

@ -0,0 +1,123 @@
import { useCallback, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { Button, ButtonSize } from '@/ui/button/components/Button';
import { ButtonGroup } from '@/ui/button/components/ButtonGroup';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuContainer } from '@/ui/filter-n-sort/components/DropdownMenuContainer';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { savedFiltersScopedState } from '@/ui/filter-n-sort/states/savedFiltersScopedState';
import { savedSortsScopedState } from '@/ui/filter-n-sort/states/savedSortsScopedState';
import { sortsScopedState } from '@/ui/filter-n-sort/states/sortsScopedState';
import { IconChevronDown, IconPlus } from '@/ui/icon';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import {
currentTableViewIdState,
tableViewEditModeState,
} from '../../states/tableViewsState';
const StyledContainer = styled.div`
display: inline-flex;
margin-right: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
const StyledDropdownMenuContainer = styled(DropdownMenuContainer)`
z-index: 1;
`;
type TableUpdateViewButtonGroupProps = {
onViewSubmit?: () => void;
HotkeyScope: string;
};
export const TableUpdateViewButtonGroup = ({
onViewSubmit,
HotkeyScope,
}: TableUpdateViewButtonGroupProps) => {
const theme = useTheme();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const tableScopeId = useContextScopeId(TableRecoilScopeContext);
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const setViewEditMode = useSetRecoilState(tableViewEditModeState);
const handleArrowDownButtonClick = useCallback(() => {
setIsDropdownOpen((previousIsOpen) => !previousIsOpen);
}, []);
const handleCreateViewButtonClick = useCallback(() => {
setViewEditMode({ mode: 'create', viewId: undefined });
setIsDropdownOpen(false);
}, [setViewEditMode]);
const handleDropdownClose = useCallback(() => {
setIsDropdownOpen(false);
}, []);
const handleViewSubmit = useRecoilCallback(
({ set, snapshot }) =>
async () => {
await Promise.resolve(onViewSubmit?.());
const selectedFilters = await snapshot.getPromise(
filtersScopedState(tableScopeId),
);
set(savedFiltersScopedState(currentViewId), selectedFilters);
const selectedSorts = await snapshot.getPromise(
sortsScopedState(tableScopeId),
);
set(savedSortsScopedState(currentViewId), selectedSorts);
},
[currentViewId, onViewSubmit],
);
useScopedHotkeys(
[Key.Enter, Key.Escape],
handleDropdownClose,
HotkeyScope,
[],
);
return (
<StyledContainer>
<ButtonGroup size={ButtonSize.Small}>
<Button
title="Update view"
disabled={!currentViewId}
onClick={handleViewSubmit}
/>
<Button
size={ButtonSize.Small}
icon={<IconChevronDown />}
onClick={handleArrowDownButtonClick}
/>
</ButtonGroup>
{isDropdownOpen && (
<StyledDropdownMenuContainer onClose={handleDropdownClose}>
<DropdownMenuItemsContainer>
<DropdownMenuItem onClick={handleCreateViewButtonClick}>
<IconPlus size={theme.icon.size.md} />
Create view
</DropdownMenuItem>
</DropdownMenuItemsContainer>
</StyledDropdownMenuContainer>
)}
</StyledContainer>
);
};

View File

@ -1,13 +1,17 @@
import { type MouseEvent, useCallback, useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { savedFiltersScopedState } from '@/ui/filter-n-sort/states/savedFiltersScopedState';
import { savedSortsScopedState } from '@/ui/filter-n-sort/states/savedSortsScopedState';
import { sortsScopedState } from '@/ui/filter-n-sort/states/sortsScopedState';
import {
IconChevronDown,
IconList,
@ -23,6 +27,7 @@ import {
tableViewsState,
} from '@/ui/table/states/tableViewsState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
@ -59,6 +64,8 @@ export const TableViewsDropdownButton = ({
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const tableScopeId = useContextScopeId(TableRecoilScopeContext);
const currentView = useRecoilScopedValue(
currentTableViewState,
TableRecoilScopeContext,
@ -78,11 +85,21 @@ export const TableViewsDropdownButton = ({
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleViewSelect = useCallback(
(viewId?: string) => {
setCurrentViewId(viewId);
setIsUnfolded(false);
},
const handleViewSelect = useRecoilCallback(
({ set, snapshot }) =>
async (viewId?: string) => {
const savedFilters = await snapshot.getPromise(
savedFiltersScopedState(viewId),
);
const savedSorts = await snapshot.getPromise(
savedSortsScopedState(viewId),
);
set(filtersScopedState(tableScopeId), savedFilters);
set(sortsScopedState(tableScopeId), savedSorts);
setCurrentViewId(viewId);
setIsUnfolded(false);
},
[setCurrentViewId],
);

View File

@ -7,13 +7,14 @@ import type {
import { FilterDropdownButton } from '@/ui/filter-n-sort/components/FilterDropdownButton';
import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
import { sortScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { sortsScopedState } from '@/ui/filter-n-sort/states/sortsScopedState';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { TableOptionsDropdownButton } from '@/ui/table/options/components/TableOptionsDropdownButton';
import { TopBar } from '@/ui/top-bar/TopBar';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { TableUpdateViewButtonGroup } from '../../options/components/TableUpdateViewButtonGroup';
import { TableViewsDropdownButton } from '../../options/components/TableViewsDropdownButton';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import type { TableView } from '../../states/tableViewsState';
@ -24,8 +25,8 @@ type OwnProps<SortField> = {
viewName: string;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onViewsChange?: (views: TableView[]) => void;
onViewSubmit?: () => void;
onImport?: () => void;
};
@ -33,30 +34,29 @@ export function TableHeader<SortField>({
viewName,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
onViewSubmit,
onImport,
}: OwnProps<SortField>) {
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
sortScopedState,
sortsScopedState,
TableRecoilScopeContext,
);
const handleSortsUpdate = onSortsUpdate ?? setSorts;
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
const newSorts = updateSortOrFilterByKey(sorts, newSort);
handleSortsUpdate(newSorts);
setSorts(newSorts);
},
[handleSortsUpdate, sorts],
[setSorts, sorts],
);
const sortUnselect = useCallback(
(sortKey: string) => {
const newSorts = sorts.filter((sort) => sort.key !== sortKey);
handleSortsUpdate(newSorts);
setSorts(newSorts);
},
[handleSortsUpdate, sorts],
[setSorts, sorts],
);
return (
@ -65,7 +65,7 @@ export function TableHeader<SortField>({
<TableViewsDropdownButton
defaultViewName={viewName}
onViewsChange={onViewsChange}
HotkeyScope={TableViewsHotkeyScope.Dropdown}
HotkeyScope={TableViewsHotkeyScope.ListDropdown}
/>
}
displayBottomBorder={false}
@ -97,10 +97,14 @@ export function TableHeader<SortField>({
context={TableRecoilScopeContext}
sorts={sorts}
onRemoveSort={sortUnselect}
onCancelClick={() => {
handleSortsUpdate([]);
}}
onCancelClick={() => setSorts([])}
hasFilterButton
rightComponent={
<TableUpdateViewButtonGroup
onViewSubmit={onViewSubmit}
HotkeyScope={TableViewsHotkeyScope.CreateDropdown}
/>
}
/>
}
/>

View File

@ -1,3 +1,4 @@
export enum TableViewsHotkeyScope {
Dropdown = 'table-views-dropdown',
ListDropdown = 'table-views-list-dropdown',
CreateDropdown = 'table-views-create-dropdown',
}

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_VIEW_FILTERS = gql`
mutation CreateViewFilters($data: [ViewFilterCreateManyInput!]!) {
createManyViewFilter(data: $data) {
count
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_VIEW_FILTERS = gql`
mutation DeleteViewFilters($where: ViewFilterWhereInput!) {
deleteManyViewFilter(where: $where) {
count
}
}
`;

View File

@ -0,0 +1,16 @@
import { gql } from '@apollo/client';
export const UPDATE_VIEW_FILTER = gql`
mutation UpdateViewFilter(
$data: ViewFilterUpdateInput!
$where: ViewFilterWhereUniqueInput!
) {
viewFilter: updateOneViewFilter(data: $data, where: $where) {
displayValue
key
name
operand
value
}
}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const GET_VIEW_FILTERS = gql`
query GetViewFilters($where: ViewFilterWhereInput) {
viewFilters: findManyViewFilter(where: $where) {
displayValue
key
name
operand
value
}
}
`;

View File

@ -0,0 +1,172 @@
import { useCallback } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { savedFiltersByKeyScopedSelector } from '@/ui/filter-n-sort/states/savedFiltersByKeyScopedSelector';
import { savedFiltersScopedState } from '@/ui/filter-n-sort/states/savedFiltersScopedState';
import type { Filter } from '@/ui/filter-n-sort/types/Filter';
import type { FilterDefinitionByEntity } from '@/ui/filter-n-sort/types/FilterDefinitionByEntity';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import {
useCreateViewFiltersMutation,
useDeleteViewFiltersMutation,
useGetViewFiltersQuery,
useUpdateViewFilterMutation,
} from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useViewFilters = <Entity>({
availableFilters,
}: {
availableFilters: FilterDefinitionByEntity<Entity>[];
}) => {
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const [filters, setFilters] = useRecoilScopedState(
filtersScopedState,
TableRecoilScopeContext,
);
const [, setSavedFilters] = useRecoilState(
savedFiltersScopedState(currentViewId),
);
const savedFiltersByKey = useRecoilValue(
savedFiltersByKeyScopedSelector(currentViewId),
);
const { refetch } = useGetViewFiltersQuery({
skip: !currentViewId,
variables: {
where: {
viewId: { equals: currentViewId },
},
},
onCompleted: (data) => {
const nextFilters = data.viewFilters
.map(({ __typename, name: _name, ...viewFilter }) => {
const availableFilter = availableFilters.find(
(filter) => filter.key === viewFilter.key,
);
return availableFilter
? {
...viewFilter,
displayValue: viewFilter.displayValue ?? viewFilter.value,
type: availableFilter.type,
}
: undefined;
})
.filter((filter): filter is Filter => !!filter);
if (!isDeeplyEqual(filters, nextFilters)) {
setSavedFilters(nextFilters);
setFilters(nextFilters);
}
},
});
const [createViewFiltersMutation] = useCreateViewFiltersMutation();
const [updateViewFilterMutation] = useUpdateViewFilterMutation();
const [deleteViewFiltersMutation] = useDeleteViewFiltersMutation();
const createViewFilters = useCallback(
(filters: Filter[]) => {
if (!currentViewId || !filters.length) return;
return createViewFiltersMutation({
variables: {
data: filters.map((filter) => ({
displayValue: filter.displayValue ?? filter.value,
key: filter.key,
name:
availableFilters.find(({ key }) => key === filter.key)?.label ??
'',
operand: filter.operand,
value: filter.value,
viewId: currentViewId,
})),
},
});
},
[availableFilters, createViewFiltersMutation, currentViewId],
);
const updateViewFilters = useCallback(
(filters: Filter[]) => {
if (!currentViewId || !filters.length) return;
return Promise.all(
filters.map((filter) =>
updateViewFilterMutation({
variables: {
data: {
displayValue: filter.displayValue ?? filter.value,
operand: filter.operand,
value: filter.value,
},
where: {
viewId_key: { key: filter.key, viewId: currentViewId },
},
},
}),
),
);
},
[currentViewId, updateViewFilterMutation],
);
const deleteViewFilters = useCallback(
(filterKeys: string[]) => {
if (!currentViewId || !filterKeys.length) return;
return deleteViewFiltersMutation({
variables: {
where: {
key: { in: filterKeys },
viewId: { equals: currentViewId },
},
},
});
},
[currentViewId, deleteViewFiltersMutation],
);
const persistFilters = useCallback(async () => {
if (!currentViewId) return;
const filtersToCreate = filters.filter(
(filter) => !savedFiltersByKey[filter.key],
);
await createViewFilters(filtersToCreate);
const filtersToUpdate = filters.filter(
(filter) =>
savedFiltersByKey[filter.key] &&
(savedFiltersByKey[filter.key].operand !== filter.operand ||
savedFiltersByKey[filter.key].value !== filter.value),
);
await updateViewFilters(filtersToUpdate);
const filterKeys = filters.map((filter) => filter.key);
const filterKeysToDelete = Object.keys(savedFiltersByKey).filter(
(previousFilterKey) => !filterKeys.includes(previousFilterKey),
);
await deleteViewFilters(filterKeysToDelete);
return refetch();
}, [
currentViewId,
filters,
createViewFilters,
updateViewFilters,
savedFiltersByKey,
deleteViewFilters,
refetch,
]);
return { persistFilters };
};

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useCallback } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
sortsByKeyScopedState,
sortScopedState,
} from '@/ui/filter-n-sort/states/sortScopedState';
import { savedSortsByKeyScopedSelector } from '@/ui/filter-n-sort/states/savedSortsByKeyScopedSelector';
import { savedSortsScopedState } from '@/ui/filter-n-sort/states/savedSortsScopedState';
import { sortsScopedState } from '@/ui/filter-n-sort/states/sortsScopedState';
import type {
SelectedSortType,
SortType,
@ -20,8 +19,7 @@ import {
useUpdateViewSortMutation,
ViewSortDirection,
} from '~/generated/graphql';
import { GET_VIEW_SORTS } from '../graphql/queries/getViewSorts';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useViewSorts = <SortField>({
availableSorts,
@ -32,20 +30,18 @@ export const useViewSorts = <SortField>({
currentTableViewIdState,
TableRecoilScopeContext,
);
const [, setSorts] = useRecoilScopedState(
sortScopedState,
const [sorts, setSorts] = useRecoilScopedState(
sortsScopedState,
TableRecoilScopeContext,
);
const sortsByKey = useRecoilScopedValue(
sortsByKeyScopedState,
TableRecoilScopeContext,
const [, setSavedSorts] = useRecoilState(
savedSortsScopedState(currentViewId),
);
const savedSortsByKey = useRecoilValue(
savedSortsByKeyScopedSelector(currentViewId),
);
useEffect(() => {
if (!currentViewId) setSorts([]);
}, [currentViewId, setSorts]);
useGetViewSortsQuery({
const { refetch } = useGetViewSortsQuery({
skip: !currentViewId,
variables: {
where: {
@ -53,23 +49,26 @@ export const useViewSorts = <SortField>({
},
},
onCompleted: (data) => {
setSorts(
data.viewSorts
.map((viewSort) => {
const availableSort = availableSorts.find(
(sort) => sort.key === viewSort.key,
);
const nextSorts = data.viewSorts
.map((viewSort) => {
const availableSort = availableSorts.find(
(sort) => sort.key === viewSort.key,
);
return availableSort
? {
...availableSort,
label: viewSort.name,
order: viewSort.direction.toLowerCase(),
}
: undefined;
})
.filter((sort): sort is SelectedSortType<SortField> => !!sort),
);
return availableSort
? {
...availableSort,
label: viewSort.name,
order: viewSort.direction.toLowerCase(),
}
: undefined;
})
.filter((sort): sort is SelectedSortType<SortField> => !!sort);
if (!isDeeplyEqual(sorts, nextSorts)) {
setSavedSorts(nextSorts);
setSorts(nextSorts);
}
},
});
@ -90,7 +89,6 @@ export const useViewSorts = <SortField>({
viewId: currentViewId,
})),
},
refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''],
});
},
[createViewSortsMutation, currentViewId],
@ -111,7 +109,6 @@ export const useViewSorts = <SortField>({
viewId_key: { key: sort.key, viewId: currentViewId },
},
},
refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''],
}),
),
);
@ -130,45 +127,40 @@ export const useViewSorts = <SortField>({
viewId: { equals: currentViewId },
},
},
refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''],
});
},
[currentViewId, deleteViewSortsMutation],
);
const handleSortsChange = useCallback(
async (nextSorts: SelectedSortType<SortField>[]) => {
if (!currentViewId) return;
const persistSorts = useCallback(async () => {
if (!currentViewId) return;
setSorts(nextSorts);
const sortsToCreate = sorts.filter((sort) => !savedSortsByKey[sort.key]);
await createViewSorts(sortsToCreate);
const sortsToCreate = nextSorts.filter(
(nextSort) => !sortsByKey[nextSort.key],
);
await createViewSorts(sortsToCreate);
const sortsToUpdate = sorts.filter(
(sort) =>
savedSortsByKey[sort.key] &&
savedSortsByKey[sort.key].order !== sort.order,
);
await updateViewSorts(sortsToUpdate);
const sortsToUpdate = nextSorts.filter(
(nextSort) =>
sortsByKey[nextSort.key] &&
sortsByKey[nextSort.key].order !== nextSort.order,
);
await updateViewSorts(sortsToUpdate);
const sortKeys = sorts.map((sort) => sort.key);
const sortKeysToDelete = Object.keys(savedSortsByKey).filter(
(previousSortKey) => !sortKeys.includes(previousSortKey),
);
await deleteViewSorts(sortKeysToDelete);
const nextSortKeys = nextSorts.map((nextSort) => nextSort.key);
const sortKeysToDelete = Object.keys(sortsByKey).filter(
(previousSortKey) => !nextSortKeys.includes(previousSortKey),
);
return deleteViewSorts(sortKeysToDelete);
},
[
createViewSorts,
currentViewId,
deleteViewSorts,
setSorts,
sortsByKey,
updateViewSorts,
],
);
return refetch();
}, [
currentViewId,
sorts,
createViewSorts,
updateViewSorts,
savedSortsByKey,
deleteViewSorts,
refetch,
]);
return { handleSortsChange };
return { persistSorts };
};