Add drag and drop column reordering

This commit is contained in:
Andrew Kingston 2023-02-22 14:05:20 +00:00
parent 8da1e507b1
commit 35dcd51322
1 changed files with 234 additions and 84 deletions

View File

@ -8,6 +8,7 @@
import MultiSelectCell from "./MultiSelectCell.svelte" import MultiSelectCell from "./MultiSelectCell.svelte"
import NumberCell from "./NumberCell.svelte" import NumberCell from "./NumberCell.svelte"
import RelationshipCell from "./RelationshipCell.svelte" import RelationshipCell from "./RelationshipCell.svelte"
import { getColor } from "./utils.js"
export let table export let table
export let filter export let filter
@ -20,8 +21,9 @@
const limit = 100 const limit = 100
const defaultWidth = 160 const defaultWidth = 160
const minWidth = 100 const minWidth = 100
const rand = Math.random()
let widths let fieldConfigs = []
let hoveredRow let hoveredRow
let selectedCell let selectedCell
let selectedRows = {} let selectedRows = {}
@ -29,6 +31,22 @@
let changeCache = {} let changeCache = {}
let newRows = [] let newRows = []
// State for resizing columns
let resizeInitialX
let resizeInitialWidth
let resizeFieldIndex
// State for reordering columns
let isReordering = false
let reorderFieldIndex
let reorderBreakpoints
let reorderPlaceholderX
let reorderPlaceholderInitialX
let reorderPlaceholderWidth
let reorderInitialX
let reorderPlaceholderHeight
let reorderCandidateFieldIdx
$: query = LuceneUtils.buildLuceneQuery(filter) $: query = LuceneUtils.buildLuceneQuery(filter)
$: fetch = createFetch(table) $: fetch = createFetch(table)
$: fetch.update({ $: fetch.update({
@ -37,12 +55,8 @@
query, query,
limit, limit,
}) })
$: schema = $fetch.schema $: updateFieldConfig($fetch)
$: primaryDisplay = $fetch.definition?.primaryDisplay $: gridStyles = getGridStyles(fieldConfigs)
$: fields = getFields(schema, primaryDisplay)
$: fieldCount = fields.length
$: fieldCount, initWidths()
$: gridStyles = getGridStyles(widths)
$: rowCount = $fetch.rows?.length || 0 $: rowCount = $fetch.rows?.length || 0
$: selectedRowCount = Object.values(selectedRows).filter(x => !!x).length $: selectedRowCount = Object.values(selectedRows).filter(x => !!x).length
$: rows = getSortedRows($fetch.rows, newRows) $: rows = getSortedRows($fetch.rows, newRows)
@ -61,19 +75,25 @@
}) })
} }
const getFields = (schema, primaryDisplay) => { const updateFieldConfig = ({ schema, definition }) => {
let fields = Object.keys(schema || {}) // Generate first time config if required
if (primaryDisplay) { if (!fieldConfigs.length && schema) {
fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)] let fields = Object.keys(schema || {})
const primaryDisplay = definition?.primaryDisplay
if (primaryDisplay) {
fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)]
}
fieldConfigs = fields.map(field => ({
name: field,
width: defaultWidth,
schema: schema[field],
primaryDisplay: field === primaryDisplay,
}))
} }
return fields
} }
const initWidths = () => { const getGridStyles = fieldConfig => {
widths = fields.map(() => defaultWidth) const widths = fieldConfig?.map(x => x.width)
}
const getGridStyles = widths => {
if (!widths?.length) { if (!widths?.length) {
return "--grid: 1fr;" return "--grid: 1fr;"
} }
@ -88,7 +108,7 @@
} }
const getCellForField = field => { const getCellForField = field => {
const type = schema?.[field]?.type const type = field.schema.type
if (type === "options") { if (type === "options") {
return OptionsCell return OptionsCell
} else if (type === "datetime") { } else if (type === "datetime") {
@ -104,7 +124,7 @@
} }
const getIconForField = field => { const getIconForField = field => {
const type = schema?.[field]?.type const type = field.schema.type
if (type === "options") { if (type === "options") {
return "ChevronDown" return "ChevronDown"
} else if (type === "datetime") { } else if (type === "datetime") {
@ -133,10 +153,10 @@
if (!row) { if (!row) {
return return
} }
if (row[field] === value) { if (row[field.name] === value) {
return return
} }
changeCache[rowId] = { [field]: value } changeCache[rowId] = { [field.name]: value }
await API.saveRow({ await API.saveRow({
...row, ...row,
...changeCache[rowId], ...changeCache[rowId],
@ -164,11 +184,6 @@
rows: rowsToDelete, rows: rowsToDelete,
}) })
await fetch.refresh() await fetch.refresh()
// notificationStore.actions.success(
// `${selectedRowCount} row${
// selectedRowCount === 1 ? "" : "s"
// } deleted successfully`
// )
// Refresh state // Refresh state
selectedCell = null selectedCell = null
@ -188,7 +203,7 @@
const addRow = async field => { const addRow = async field => {
const res = await API.saveRow({ tableId: table.tableId }) const res = await API.saveRow({ tableId: table.tableId })
selectedCell = `${res._id}-${field}` selectedCell = `${res._id}-${field.name}`
newRows.push(res._id) newRows.push(res._id)
await fetch.refresh() await fetch.refresh()
} }
@ -203,31 +218,101 @@
return sortedRows return sortedRows
} }
let resizeInitialX const startReordering = (fieldIdx, e) => {
let resizeInitialWidth isReordering = true
let resizeFieldIndex reorderFieldIndex = fieldIdx
let breakpoints = []
fieldConfigs.forEach((config, idx) => {
const header = document.getElementById(`sheet-${rand}-header-${idx}`)
const bounds = header.getBoundingClientRect()
breakpoints.push(bounds.x)
if (idx === fieldConfigs.length - 1) {
breakpoints.push(bounds.x + bounds.width)
}
})
reorderBreakpoints = breakpoints
const self = document.getElementById(`sheet-${rand}-header-${fieldIdx}`)
const selfBounds = self.getBoundingClientRect()
const body = document.getElementById(`sheet-${rand}-body`)
const bodyBounds = body.getBoundingClientRect()
reorderPlaceholderInitialX = selfBounds.x - bodyBounds.x
reorderPlaceholderX = reorderPlaceholderInitialX
reorderPlaceholderWidth = selfBounds.width
reorderInitialX = e.clientX
reorderPlaceholderHeight = (rows.length + 2) * 32
onReorderMove(e)
document.addEventListener("mousemove", onReorderMove)
document.addEventListener("mouseup", stopReordering)
}
const onReorderMove = e => {
if (!isReordering) {
return
}
reorderPlaceholderX =
e.clientX - reorderInitialX + reorderPlaceholderInitialX
reorderPlaceholderX = Math.max(0, reorderPlaceholderX)
let candidateFieldIdx
let minDistance = Number.MAX_SAFE_INTEGER
reorderBreakpoints.forEach((point, idx) => {
const distance = Math.abs(point - e.clientX)
if (distance < minDistance) {
minDistance = distance
candidateFieldIdx = idx
}
})
reorderCandidateFieldIdx = candidateFieldIdx
}
const stopReordering = () => {
const newConfigs = fieldConfigs.slice()
const removed = newConfigs.splice(reorderFieldIndex, 1)
if (--reorderCandidateFieldIdx < reorderFieldIndex) {
reorderCandidateFieldIdx++
}
newConfigs.splice(reorderCandidateFieldIdx, 0, removed[0])
fieldConfigs = newConfigs
isReordering = false
reorderFieldIndex = null
reorderBreakpoints = null
reorderPlaceholderX = null
reorderPlaceholderInitialX = null
reorderPlaceholderWidth = null
reorderInitialX = null
reorderPlaceholderHeight = null
reorderCandidateFieldIdx = null
document.removeEventListener("mousemove", onReorderMove)
document.removeEventListener("mouseup", stopReordering)
}
const startResizing = (fieldIdx, e) => { const startResizing = (fieldIdx, e) => {
e.stopPropagation()
resizeInitialX = e.clientX resizeInitialX = e.clientX
resizeInitialWidth = widths[fieldIdx] resizeInitialWidth = fieldConfigs[fieldIdx].width
resizeFieldIndex = fieldIdx resizeFieldIndex = fieldIdx
document.addEventListener("mousemove", onResize) document.addEventListener("mousemove", onResizeMove)
document.addEventListener("mouseup", stopResizing) document.addEventListener("mouseup", stopResizing)
} }
const onResize = e => { const onResizeMove = e => {
const dx = e.clientX - resizeInitialX const dx = e.clientX - resizeInitialX
widths[resizeFieldIndex] = Math.max(minWidth, resizeInitialWidth + dx) fieldConfigs[resizeFieldIndex].width = Math.max(
minWidth,
resizeInitialWidth + dx
)
} }
const stopResizing = () => { const stopResizing = () => {
document.removeEventListener("mousemove", onResize) document.removeEventListener("mousemove", onResizeMove)
document.removeEventListener("mouseup", stopResizing) document.removeEventListener("mouseup", stopResizing)
} }
</script> </script>
<div use:styleable={$component.styles}> <div use:styleable={$component.styles}>
<div class="wrapper"> <div class="wrapper" style="--highlight-color:{getColor(0)}">
<div class="controls"> <div class="controls">
<div class="buttons"> <div class="buttons">
<ActionButton icon="Filter" size="S">Filter</ActionButton> <ActionButton icon="Filter" size="S">Filter</ActionButton>
@ -251,6 +336,7 @@
on:scroll={handleScroll} on:scroll={handleScroll}
style={gridStyles} style={gridStyles}
on:click|self={() => (selectedCell = null)} on:click|self={() => (selectedCell = null)}
id={`sheet-${rand}-body`}
> >
<!-- Field headers --> <!-- Field headers -->
<div class="header cell label" on:click={selectAll}> <div class="header cell label" on:click={selectAll}>
@ -259,11 +345,15 @@
checked={rowCount && selectedRowCount === rowCount} checked={rowCount && selectedRowCount === rowCount}
/> />
</div> </div>
{#each fields as field, fieldIdx} {#each fieldConfigs as field, fieldIdx}
<div <div
class="header cell" class="header cell"
class:sticky={fieldIdx === 0} class:sticky={fieldIdx === 0}
class:shadow={horizontallyScrolled} class:shadow={horizontallyScrolled}
class:reordering={reorderFieldIndex === fieldIdx}
class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx}
on:mousedown={e => startReordering(fieldIdx, e)}
id={`sheet-${rand}-header-${fieldIdx}`}
> >
<Icon <Icon
size="S" size="S"
@ -271,13 +361,17 @@
color="var(--spectrum-global-color-gray-600)" color="var(--spectrum-global-color-gray-600)"
/> />
<span> <span>
{field} {field.name}
</span> </span>
<div class="slider" on:mousedown={e => startResizing(fieldIdx, e)} /> <div class="slider" on:mousedown={e => startResizing(fieldIdx, e)} />
</div> </div>
{/each} {/each}
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div /> <div
class="header cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx ===
fieldConfigs.length}
/>
<!-- All real rows --> <!-- All real rows -->
{#each rows as row, rowIdx (row._id)} {#each rows as row, rowIdx (row._id)}
@ -300,31 +394,39 @@
</span> </span>
{/if} {/if}
</div> </div>
{#each fields as field, fieldIdx} {#each fieldConfigs as field, fieldIdx}
{@const cellIdx = `${row._id}-${field}`} {@const cellIdx = `${row._id}-${field.name}`}
<div {#key cellIdx}
class="cell" <div
class:row-selected={rowSelected} class="cell"
class:sticky={field === primaryDisplay} class:row-selected={rowSelected}
class:hovered={rowHovered} class:sticky={fieldIdx === 0}
class:selected={selectedCell === cellIdx} class:hovered={rowHovered}
class:shadow={horizontallyScrolled} class:selected={selectedCell === cellIdx}
on:focus class:shadow={horizontallyScrolled}
on:mouseover={() => (hoveredRow = row._id)} class:reordering={reorderFieldIndex === fieldIdx}
on:click={() => (selectedCell = cellIdx)} class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx}
> on:focus
<svelte:component on:mouseover={() => (hoveredRow = row._id)}
this={getCellForField(field)} on:click={() => (selectedCell = cellIdx)}
value={data[field]} >
schema={schema[field]} <svelte:component
selected={selectedCell === cellIdx} this={getCellForField(field)}
onChange={val => handleChange(row._id, field, val)} value={data[field.name]}
readonly={schema[field]?.autocolumn} schema={field.schema}
/> selected={selectedCell === cellIdx}
</div> onChange={val => handleChange(row._id, field, val)}
readonly={field.schema.autocolumn}
/>
</div>
{/key}
{/each} {/each}
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div /> <div
class="cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx ===
fieldConfigs.length}
/>
{/each} {/each}
<!-- New row placeholder --> <!-- New row placeholder -->
@ -337,23 +439,37 @@
> >
<Icon hoverable name="Add" size="S" /> <Icon hoverable name="Add" size="S" />
</div> </div>
{#each fields as field, fieldIdx} {#each fieldConfigs as field, fieldIdx}
<div <div
class="cell new" class="cell new"
class:sticky={field === primaryDisplay} class:sticky={fieldIdx === 0}
class:shadow={horizontallyScrolled} class:shadow={horizontallyScrolled}
class:hovered={hoveredRow === "new"} class:hovered={hoveredRow === "new"}
class:reordering={reorderFieldIndex === fieldIdx}
class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx}
on:click={() => addRow(field)} on:click={() => addRow(field)}
on:focus on:focus
on:mouseover={() => (hoveredRow = "new")} on:mouseover={() => (hoveredRow = "new")}
/> />
{/each} {/each}
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div /> <div
class="cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx ===
fieldConfigs.length}
/>
<!-- Vertical spacer --> <!-- Vertical spacer -->
<div class="vertical-spacer" /> <div class="vertical-spacer" />
</div> </div>
<!-- Reorder placeholder -->
{#if isReordering}
<div
class="reorder-placeholder"
style="--x:{reorderPlaceholderX}px;--width:{reorderPlaceholderWidth}px;--height:{reorderPlaceholderHeight}px;"
/>
{/if}
</div> </div>
</div> </div>
@ -365,6 +481,7 @@
align-items: stretch; align-items: stretch;
border: 1px solid var(--spectrum-global-color-gray-400); border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px; border-radius: 4px;
position: relative;
/* Variables */ /* Variables */
--cell-background: var(--spectrum-global-color-gray-50); --cell-background: var(--spectrum-global-color-gray-50);
@ -427,10 +544,14 @@
fill: var(--spectrum-global-color-red-600); fill: var(--spectrum-global-color-red-600);
} }
/* Cells */
.cell { .cell {
height: var(--cell-height); height: var(--cell-height);
border-bottom: 1px solid var(--spectrum-global-color-gray-300); border-style: solid;
border-right: 1px solid var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
border-width: 0;
border-bottom-width: 1px;
border-left-width: 1px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
@ -440,6 +561,7 @@
gap: var(--cell-spacing); gap: var(--cell-spacing);
background: var(--cell-background); background: var(--cell-background);
position: relative; position: relative;
transition: border-color 130ms ease-out;
} }
.cell.hovered { .cell.hovered {
background: var(--cell-background-hover); background: var(--cell-background-hover);
@ -454,21 +576,18 @@
.cell:hover { .cell:hover {
cursor: default; cursor: default;
} }
.cell.sticky {
position: sticky;
left: 40px;
z-index: 2;
}
.cell.sticky.selected {
z-index: 3;
}
.cell.row-selected { .cell.row-selected {
background-color: rgb(224, 242, 255); background-color: rgb(224, 242, 255);
} }
.cell.new:hover { .cell.new:hover {
cursor: pointer; cursor: pointer;
} }
.cell.spacer {
background: none;
border-bottom: none;
}
/* Header cells */
.header { .header {
background: var(--spectrum-global-color-gray-200); background: var(--spectrum-global-color-gray-200);
position: sticky; position: sticky;
@ -488,6 +607,36 @@
z-index: 4; z-index: 4;
} }
/* Sticky styles */
.sticky {
position: sticky;
left: 40px;
z-index: 2;
border-left-color: transparent;
}
.sticky.selected {
z-index: 3;
}
.sticky.shadow {
border-right-width: 1px;
}
.sticky.shadow:after {
content: " ";
position: absolute;
width: 10px;
left: 100%;
height: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
}
/* Reordering styles */
.cell.reordering {
background: var(--spectrum-global-color-gray-200);
}
.cell.reorder-candidate {
border-left-color: var(--spectrum-global-color-blue-400);
}
/* Column resizing */ /* Column resizing */
.slider { .slider {
position: absolute; position: absolute;
@ -514,15 +663,6 @@
opacity: 1; opacity: 1;
} }
.sticky.shadow:after {
content: " ";
position: absolute;
width: 10px;
left: 100%;
height: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
}
.label { .label {
padding: 0 12px; padding: 0 12px;
border-right: none; border-right: none;
@ -542,4 +682,14 @@
input[type="checkbox"] { input[type="checkbox"] {
margin: 0; margin: 0;
} }
.reorder-placeholder {
height: min(calc(100% - 36px), var(--height));
width: var(--width);
left: var(--x);
position: absolute;
top: 36px;
background: var(--spectrum-global-color-blue-400);
opacity: 0.2;
z-index: 7;
}
</style> </style>