mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 16:19:10 +03:00
Merge pull request #11264 from gitstart/feat/filter-sort-on-apply-workflow
feat: Move table filters and sorting to an "Apply" workflow
This commit is contained in:
4 files changed
+174
-180
No files matched your search
@@ -1,10 +1,14 @@
|
||||
import { FC } from 'react'
|
||||
import { FC, useState, useMemo, useCallback } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Button, IconPlus, IconFilter, Popover } from 'ui'
|
||||
|
||||
import update from 'immutability-helper'
|
||||
import { useUrlState } from 'hooks'
|
||||
|
||||
import FilterRow from './FilterRow'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
import { Filter } from 'components/grid/types'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
|
||||
const FilterPopover: FC = () => {
|
||||
const [{ filter: filters }]: any = useUrlState({ arrayKeys: ['filter'] })
|
||||
@@ -14,7 +18,12 @@ const FilterPopover: FC = () => {
|
||||
: 'Filter'
|
||||
|
||||
return (
|
||||
<Popover size="large" align="start" className="sb-grid-filter-popover" overlay={<Filter />}>
|
||||
<Popover
|
||||
size="large"
|
||||
align="start"
|
||||
className="sb-grid-filter-popover"
|
||||
overlay={<FilterOverlay />}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type={(filters || []).length > 0 ? 'link' : 'text'}
|
||||
@@ -31,19 +40,60 @@ const FilterPopover: FC = () => {
|
||||
}
|
||||
export default FilterPopover
|
||||
|
||||
const Filter: FC = () => {
|
||||
const FilterOverlay: FC = () => {
|
||||
const state = useTrackedState()
|
||||
|
||||
const [{ filter: filters }, setParams] = useUrlState({ arrayKeys: ['filter'] })
|
||||
const formattedFilters = formatFilterURLParams(filters as string[])
|
||||
const [{ filter: filtersFromUrl }, setParams] = useUrlState({ arrayKeys: ['filter'] })
|
||||
const initialFilters = useMemo(
|
||||
() => formatFilterURLParams((filtersFromUrl as string[]) ?? []),
|
||||
[filtersFromUrl]
|
||||
)
|
||||
const [filters, setFilters] = useState<Filter[]>(initialFilters)
|
||||
|
||||
function onAddFilter() {
|
||||
const column = state.table?.columns[0]?.name
|
||||
|
||||
if (column) {
|
||||
setFilters([
|
||||
...filters,
|
||||
{
|
||||
column,
|
||||
operator: '=',
|
||||
value: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeFilter = useCallback((index: number, filter: Filter) => {
|
||||
setFilters((currentFilters) =>
|
||||
update(currentFilters, {
|
||||
[index]: {
|
||||
$set: filter,
|
||||
},
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const onDeleteFilter = useCallback((index: number) => {
|
||||
setFilters((currentFilters) =>
|
||||
update(currentFilters, {
|
||||
$splice: [[index, 1]],
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
function onApplyFilter() {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const column = state.table?.columns[0].name
|
||||
return {
|
||||
...prevParams,
|
||||
filter: existingFilters.concat([`${column}:eq:`]),
|
||||
filter: filters.map((filter) => {
|
||||
const selectedOperator = FilterOperatorOptions.find(
|
||||
(option) => option.value === filter.operator
|
||||
)
|
||||
|
||||
return `${filter.column}:${selectedOperator?.abbrev}:${filter.value}`
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,14 +101,16 @@ const Filter: FC = () => {
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="space-y-2">
|
||||
{formattedFilters.map((filter, index) => (
|
||||
{filters.map((filter, index) => (
|
||||
<FilterRow
|
||||
key={`filter-${filter.column}-${[index]}`}
|
||||
filter={filter}
|
||||
filterIdx={index}
|
||||
onChange={onChangeFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
/>
|
||||
))}
|
||||
{formattedFilters.length == 0 && (
|
||||
{filters.length == 0 && (
|
||||
<div className="space-y-1 px-3">
|
||||
<h5 className="text-sm text-scale-1100">No filters applied to this view</h5>
|
||||
<p className="text-xs text-scale-900">Add a column below to filter the view</p>
|
||||
@@ -66,10 +118,13 @@ const Filter: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<Popover.Separator />
|
||||
<div className="px-3">
|
||||
<div className="px-3 flex flex-row justify-between">
|
||||
<Button icon={<IconPlus />} type="text" onClick={onAddFilter}>
|
||||
Add filter
|
||||
</Button>
|
||||
<Button disabled={isEqual(filters, initialFilters)} type="default" onClick={onApplyFilter}>
|
||||
Apply filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,113 +1,26 @@
|
||||
import { debounce } from 'lodash'
|
||||
import { FC, memo, useState, useEffect, ChangeEvent, useCallback } from 'react'
|
||||
import { FC, memo } from 'react'
|
||||
import { Button, Input, IconChevronDown, IconX } from 'ui'
|
||||
|
||||
import { useUrlState } from 'hooks'
|
||||
import { Filter } from 'components/grid/types'
|
||||
import { Filter, FilterOperator } from 'components/grid/types'
|
||||
import { DropdownControl } from 'components/grid/components/common'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
|
||||
interface Props {
|
||||
filter: Filter
|
||||
filterIdx: number
|
||||
filter: Filter
|
||||
onChange: (index: number, filter: Filter) => void
|
||||
onDelete: (index: number) => void
|
||||
}
|
||||
|
||||
// [Area of improvement] Input field loses focus after the debounce (because of useUrlState?)
|
||||
const FilterRow: FC<Props> = ({ filter, filterIdx }) => {
|
||||
const FilterRow: FC<Props> = ({ filter, filterIdx, onChange, onDelete }) => {
|
||||
const state = useTrackedState()
|
||||
const [{ filter: filters }, setParams] = useUrlState({ arrayKeys: ['filter'] })
|
||||
|
||||
const column = state.table?.columns.find((x) => x.name === filter.column)
|
||||
const columnOptions =
|
||||
state.table?.columns?.map((x) => {
|
||||
return { value: x.name, label: x.name, postLabel: x.dataType }
|
||||
}) || []
|
||||
const [filterValue, setFilterValue] = useState(filter.value)
|
||||
|
||||
useEffect(() => {
|
||||
setFilterValue(filter.value)
|
||||
}, [filterIdx])
|
||||
|
||||
function onRemoveFilter() {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const updatedFilters = existingFilters.filter((filter: string, idx: number) => {
|
||||
if (idx !== filterIdx) return filter
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
filter: updatedFilters,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onColumnChange(column: string | number) {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const updatedFilters = existingFilters.map((filter: string, idx: number) => {
|
||||
if (idx === filterIdx) {
|
||||
const [_, operator, ...value] = filter.split(':')
|
||||
const formattedValue = value.join(':')
|
||||
return `${column}:${operator}:${formattedValue}`
|
||||
} else {
|
||||
return filter
|
||||
}
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
filter: updatedFilters,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onOperatorChange(operator: string | number) {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const updatedFilters = existingFilters.map((filter: string, idx: number) => {
|
||||
if (idx === filterIdx) {
|
||||
const [column, _, ...value] = filter.split(':')
|
||||
const formattedValue = value.join(':')
|
||||
const selectedOperator = FilterOperatorOptions.find((option) => option.value === operator)
|
||||
return `${column}:${selectedOperator?.abbrev}:${formattedValue}`
|
||||
} else {
|
||||
return filter
|
||||
}
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
filter: updatedFilters,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onValueChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.target.value
|
||||
setFilterValue(value)
|
||||
debounceHandler({
|
||||
filterIdx,
|
||||
value: { ...filter, value: value },
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilterValue = (payload: { filterIdx: number; value: Filter }) => {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const updatedFilters = existingFilters.map((filter: string, idx: number) => {
|
||||
if (idx === filterIdx) {
|
||||
const [column, operator] = filter.split(':')
|
||||
return `${column}:${operator}:${payload.value.value}`
|
||||
} else {
|
||||
return filter
|
||||
}
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
filter: updatedFilters,
|
||||
}
|
||||
})
|
||||
}
|
||||
const debounceHandler = useCallback(debounce(updateFilterValue, 600), [filters])
|
||||
|
||||
const placeholder =
|
||||
column?.format === 'timestamptz'
|
||||
@@ -118,7 +31,11 @@ const FilterRow: FC<Props> = ({ filter, filterIdx }) => {
|
||||
|
||||
return (
|
||||
<div className="sb-grid-filter-row px-3">
|
||||
<DropdownControl align="start" options={columnOptions} onSelect={onColumnChange}>
|
||||
<DropdownControl
|
||||
align="start"
|
||||
options={columnOptions}
|
||||
onSelect={(nextColumn) => onChange(filterIdx, { ...filter, column: nextColumn as string })}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type="outline"
|
||||
@@ -129,10 +46,19 @@ const FilterRow: FC<Props> = ({ filter, filterIdx }) => {
|
||||
}
|
||||
className="w-32"
|
||||
>
|
||||
{column?.name || ''}
|
||||
{column?.name ?? ''}
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
<DropdownControl align="start" options={FilterOperatorOptions} onSelect={onOperatorChange}>
|
||||
<DropdownControl
|
||||
align="start"
|
||||
options={FilterOperatorOptions}
|
||||
onSelect={(nextOperator) =>
|
||||
onChange(filterIdx, {
|
||||
...filter,
|
||||
operator: nextOperator as FilterOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type="outline"
|
||||
@@ -149,14 +75,19 @@ const FilterRow: FC<Props> = ({ filter, filterIdx }) => {
|
||||
size="tiny"
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
value={filterValue}
|
||||
onChange={onValueChange}
|
||||
value={filter.value}
|
||||
onChange={(event) =>
|
||||
onChange(filterIdx, {
|
||||
...filter,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconX strokeWidth={1.5} size={14} />}
|
||||
size="tiny"
|
||||
type="text"
|
||||
onClick={onRemoveFilter}
|
||||
onClick={() => onDelete(filterIdx)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { FC } from 'react'
|
||||
import React, { FC, useState, useMemo, useCallback } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import update from 'immutability-helper'
|
||||
import { Button, IconList, IconChevronDown, Popover } from 'ui'
|
||||
|
||||
import { useUrlState } from 'hooks'
|
||||
|
||||
import SortRow from './SortRow'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { DropdownControl } from 'components/grid/components/common'
|
||||
import { Sort } from 'components/grid/types'
|
||||
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
|
||||
const SortPopover: FC = () => {
|
||||
@@ -15,7 +18,7 @@ const SortPopover: FC = () => {
|
||||
: 'Sort'
|
||||
|
||||
return (
|
||||
<Popover size="large" align="start" className="sb-grid-sort-popover" overlay={<Sort />}>
|
||||
<Popover size="large" align="start" className="sb-grid-sort-popover" overlay={<SortOverlay />}>
|
||||
<Button
|
||||
as="span"
|
||||
type={(sorts || []).length > 0 ? 'link' : 'text'}
|
||||
@@ -32,14 +35,18 @@ const SortPopover: FC = () => {
|
||||
}
|
||||
export default SortPopover
|
||||
|
||||
const Sort: FC = () => {
|
||||
const SortOverlay: FC = () => {
|
||||
const state = useTrackedState()
|
||||
|
||||
const [{ sort: sorts }, setParams] = useUrlState({ arrayKeys: ['sort'] })
|
||||
const formattedSorts = formatSortURLParams(sorts as string[])
|
||||
const [{ sort: sortsFromUrl }, setParams] = useUrlState({ arrayKeys: ['sort'] })
|
||||
const initialSorts = useMemo(
|
||||
() => formatSortURLParams((sortsFromUrl as string[]) ?? []),
|
||||
[sortsFromUrl]
|
||||
)
|
||||
const [sorts, setSorts] = useState<Sort[]>(initialSorts)
|
||||
|
||||
const columns = state?.table?.columns!.filter((x) => {
|
||||
const found = formattedSorts.find((y) => y.column == x.name)
|
||||
const found = sorts.find((y) => y.column == x.name)
|
||||
return !found
|
||||
})
|
||||
|
||||
@@ -49,21 +56,59 @@ const Sort: FC = () => {
|
||||
}) || []
|
||||
|
||||
function onAddSort(columnName: string | number) {
|
||||
setSorts([...sorts, { column: columnName as string, ascending: true }])
|
||||
}
|
||||
|
||||
function onApplySort() {
|
||||
setParams((prevParams) => {
|
||||
const existingSorts = (prevParams?.sort ?? []) as string[]
|
||||
return {
|
||||
...prevParams,
|
||||
sort: existingSorts.concat([`${columnName}:asc`]),
|
||||
sort: sorts.map((sort) => `${sort.column}:${sort.ascending ? 'asc' : 'desc'}`),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDeleteSort = useCallback((column: string) => {
|
||||
setSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column))
|
||||
}, [])
|
||||
|
||||
const onToggleSort = useCallback((column: string, ascending: boolean) => {
|
||||
setSorts((currentSorts) => {
|
||||
const idx = currentSorts.findIndex((x) => x.column === column)
|
||||
|
||||
return update(currentSorts, {
|
||||
[idx]: {
|
||||
$merge: { ascending },
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
setSorts((currentSort) =>
|
||||
update(currentSort, {
|
||||
$splice: [
|
||||
[dragIndex, 1],
|
||||
[hoverIndex, 0, currentSort[dragIndex]],
|
||||
],
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
{formattedSorts.map((sort, index) => (
|
||||
<SortRow key={sort.column} index={index} columnName={sort.column} sort={sort} />
|
||||
{sorts.map((sort, index) => (
|
||||
<SortRow
|
||||
key={sort.column}
|
||||
index={index}
|
||||
columnName={sort.column}
|
||||
sort={sort}
|
||||
onDelete={onDeleteSort}
|
||||
onToggle={onToggleSort}
|
||||
onDrag={onDragSort}
|
||||
/>
|
||||
))}
|
||||
{formattedSorts.length === 0 && (
|
||||
{sorts.length === 0 && (
|
||||
<div className="space-y-1 px-3">
|
||||
<h5 className="text-sm text-scale-1100">No sorts applied to this view</h5>
|
||||
<p className="text-xs text-scale-900">Add a column below to sort the view</p>
|
||||
@@ -71,7 +116,7 @@ const Sort: FC = () => {
|
||||
)}
|
||||
|
||||
<Popover.Separator />
|
||||
<div className="px-3">
|
||||
<div className="px-3 flex flex-row justify-between">
|
||||
{columns && columns.length > 0 ? (
|
||||
<DropdownControl
|
||||
options={dropdownOptions}
|
||||
@@ -85,12 +130,15 @@ const Sort: FC = () => {
|
||||
iconRight={<IconChevronDown />}
|
||||
className="sb-grid-dropdown__item-trigger"
|
||||
>
|
||||
{`Pick ${formattedSorts.length > 1 ? 'another' : 'a'} column to sort by`}
|
||||
{`Pick ${sorts.length > 1 ? 'another' : 'a'} column to sort by`}
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
) : (
|
||||
<p className="text-sm text-scale-1100">All columns have been added</p>
|
||||
)}
|
||||
<Button disabled={isEqual(sorts, initialSorts)} type="default" onClick={onApplySort}>
|
||||
Apply sorting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import update from 'immutability-helper'
|
||||
import { FC, useRef, memo } from 'react'
|
||||
import { Button, IconMenu, Toggle, IconX } from 'ui'
|
||||
import { XYCoord } from 'dnd-core'
|
||||
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'
|
||||
|
||||
import { useUrlState } from 'hooks'
|
||||
import { DragItem, Sort } from 'components/grid/types'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
|
||||
@@ -12,11 +10,13 @@ type SortRowProps = {
|
||||
index: number
|
||||
columnName: string
|
||||
sort: Sort
|
||||
onDelete: (columnName: string) => void
|
||||
onToggle: (columnName: string, ascending: boolean) => void
|
||||
onDrag: (dragIndex: number, hoverIndex: number) => void
|
||||
}
|
||||
|
||||
const SortRow: FC<SortRowProps> = ({ index, columnName, sort }) => {
|
||||
const SortRow: FC<SortRowProps> = ({ index, columnName, sort, onDelete, onToggle, onDrag }) => {
|
||||
const state = useTrackedState()
|
||||
const [_, setParams] = useUrlState({ arrayKeys: ['sort'] })
|
||||
|
||||
const column = state?.table?.columns.find((x) => x.name === columnName)
|
||||
if (!column) return null
|
||||
@@ -89,49 +89,9 @@ const SortRow: FC<SortRowProps> = ({ index, columnName, sort }) => {
|
||||
},
|
||||
})
|
||||
|
||||
function onToggle(value: boolean) {
|
||||
setParams((prevParams) => {
|
||||
const existingSorts = (prevParams?.sort ?? []) as string[]
|
||||
const updatedSorts = existingSorts.map((sort: string) => {
|
||||
const [column] = sort.split(':')
|
||||
return column === columnName ? `${columnName}:${value ? 'asc' : 'desc'}` : sort
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
sort: updatedSorts,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onRemoveSort() {
|
||||
setParams((prevParams) => {
|
||||
const existingSorts = (prevParams?.sort ?? []) as string[]
|
||||
const updatedSorts = existingSorts.filter((sort: string) => {
|
||||
const [column] = sort.split(':')
|
||||
if (column !== columnName) return sort
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
sort: updatedSorts,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const moveSort = (dragIndex: number, hoverIndex: number) => {
|
||||
if (dragIndex == hoverIndex) return
|
||||
setParams((prevParams) => {
|
||||
const existingSorts = (prevParams?.sort ?? []) as string[]
|
||||
const updatedSorts = update(existingSorts, {
|
||||
$splice: [
|
||||
[dragIndex, 1],
|
||||
[hoverIndex, 0, existingSorts[dragIndex]],
|
||||
],
|
||||
})
|
||||
return {
|
||||
...prevParams,
|
||||
sort: updatedSorts,
|
||||
}
|
||||
})
|
||||
onDrag(dragIndex, hoverIndex)
|
||||
}
|
||||
|
||||
const opacity = isDragging ? 0 : 1
|
||||
@@ -144,12 +104,9 @@ const SortRow: FC<SortRowProps> = ({ index, columnName, sort }) => {
|
||||
style={{ opacity }}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
<Button
|
||||
icon={<IconX strokeWidth={1.5} size={14} />}
|
||||
size="tiny"
|
||||
type="text"
|
||||
onClick={onRemoveSort}
|
||||
/>
|
||||
<span className="transition-color text-scale-900 hover:text-scale-1100">
|
||||
<IconMenu strokeWidth={2} size={16} />
|
||||
</span>
|
||||
<div className="grow">
|
||||
<span className="flex grow items-center gap-1 truncate text-sm text-scale-1200">
|
||||
<span className="text-xs text-scale-900">{index > 0 ? 'then by' : 'sort by'}</span>
|
||||
@@ -163,12 +120,15 @@ const SortRow: FC<SortRowProps> = ({ index, columnName, sort }) => {
|
||||
layout="flex"
|
||||
defaultChecked={sort.ascending}
|
||||
// @ts-ignore
|
||||
onChange={(e: boolean) => onToggle(e)}
|
||||
onChange={(e: boolean) => onToggle(columnName, e)}
|
||||
/>
|
||||
</div>
|
||||
<span className="transition-color text-scale-900 hover:text-scale-1100">
|
||||
<IconMenu strokeWidth={2} size={16} />
|
||||
</span>
|
||||
<Button
|
||||
icon={<IconX strokeWidth={1.5} size={14} />}
|
||||
size="tiny"
|
||||
type="text"
|
||||
onClick={() => onDelete(columnName)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user