Break out reordering logic into new stores

This commit is contained in:
Andrew Kingston 2023-02-22 15:42:20 +00:00
parent 35dcd51322
commit ca7aed617f
9 changed files with 273 additions and 173 deletions

View File

@ -0,0 +1,32 @@
<script>
import { getContext } from "svelte"
const { reorderingPlaceholder } = getContext("spreadsheet")
$: style = getStyle($reorderingPlaceholder)
const getStyle = state => {
return (
`--x:${state.x}px;` +
`--width:${state.width}px;` +
`--height:${state.height}px;`
)
}
</script>
{#if $reorderingPlaceholder.x != null}
<div {style} />
{/if}
<style>
div {
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>

View File

@ -1,32 +1,55 @@
<script> <script>
import { getContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { fetchData, LuceneUtils } from "@budibase/frontend-core" import { fetchData, LuceneUtils } from "@budibase/frontend-core"
import { Icon, ActionButton } from "@budibase/bbui" import { Icon, ActionButton } from "@budibase/bbui"
import TextCell from "./TextCell.svelte" import TextCell from "./cells/TextCell.svelte"
import OptionsCell from "./OptionsCell.svelte" import OptionsCell from "./cells/OptionsCell.svelte"
import DateCell from "./DateCell.svelte" import DateCell from "./cells/DateCell.svelte"
import MultiSelectCell from "./MultiSelectCell.svelte" import MultiSelectCell from "./cells/MultiSelectCell.svelte"
import NumberCell from "./NumberCell.svelte" import NumberCell from "./cells/NumberCell.svelte"
import RelationshipCell from "./RelationshipCell.svelte" import RelationshipCell from "./cells/RelationshipCell.svelte"
import { getColor } from "./utils.js" import { getColor } from "./utils.js"
import { createReorderingStores } from "./stores/reordering"
import ReorderingPlaceholder from "./ReorderingPlaceholder.svelte"
export let table export let table
export let filter export let filter
export let sortColumn export let sortColumn
export let sortOrder export let sortOrder
const { styleable, API, confirmationStore, notificationStore } = const { styleable, API, confirmationStore } = getContext("sdk")
getContext("sdk")
const component = getContext("component") const component = getContext("component")
// Sheet constants
const limit = 100 const limit = 100
const defaultWidth = 160 const defaultWidth = 160
const minWidth = 100 const minWidth = 100
const rand = Math.random() const rand = Math.random()
let fieldConfigs = [] // State stores
let hoveredRow const columns = writable([])
let selectedCell const hoveredRowId = writable(null)
let selectedRows = {} const selectedCellId = writable(null)
const selectedRows = writable({})
const rows = writable([])
// Build up spreadsheet context and additional stores
const context = {
rand,
rows,
columns,
hoveredRowId,
selectedCellId,
selectedRows,
}
const { reordering, reorderingPlaceholder } = createReorderingStores(context)
setContext("spreadsheet", {
...context,
reordering,
reorderingPlaceholder,
})
let horizontallyScrolled = false let horizontallyScrolled = false
let changeCache = {} let changeCache = {}
let newRows = [] let newRows = []
@ -36,17 +59,6 @@
let resizeInitialWidth let resizeInitialWidth
let resizeFieldIndex 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({
@ -55,11 +67,11 @@
query, query,
limit, limit,
}) })
$: updateFieldConfig($fetch) $: generateColumns($fetch)
$: gridStyles = getGridStyles(fieldConfigs) $: gridStyles = getGridStyles($columns)
$: rowCount = $fetch.rows?.length || 0 $: rowCount = $rows.length
$: selectedRowCount = Object.values(selectedRows).filter(x => !!x).length $: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: rows = getSortedRows($fetch.rows, newRows) $: updateSortedRows($fetch.rows, newRows)
const createFetch = datasource => { const createFetch = datasource => {
return fetchData({ return fetchData({
@ -75,15 +87,15 @@
}) })
} }
const updateFieldConfig = ({ schema, definition }) => { // Generates the column array the first time the schema loads
// Generate first time config if required const generateColumns = ({ schema, definition }) => {
if (!fieldConfigs.length && schema) { if (!$columns.length && schema) {
let fields = Object.keys(schema || {}) let fields = Object.keys(schema || {})
const primaryDisplay = definition?.primaryDisplay const primaryDisplay = definition?.primaryDisplay
if (primaryDisplay) { if (primaryDisplay) {
fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)] fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)]
} }
fieldConfigs = fields.map(field => ({ $columns = fields.map(field => ({
name: field, name: field,
width: defaultWidth, width: defaultWidth,
schema: schema[field], schema: schema[field],
@ -92,8 +104,8 @@
} }
} }
const getGridStyles = fieldConfig => { const getGridStyles = columns => {
const widths = fieldConfig?.map(x => x.width) const widths = columns?.map(x => x.width)
if (!widths?.length) { if (!widths?.length) {
return "--grid: 1fr;" return "--grid: 1fr;"
} }
@ -134,22 +146,28 @@
} }
const selectRow = id => { const selectRow = id => {
selectedRows[id] = !selectedRows[id] selectedRows.update(state => {
state[id] = !state[id]
return state
})
} }
const selectAll = () => { const selectAll = () => {
const allSelected = selectedRowCount === rowCount const allSelected = selectedRowCount === rowCount
if (allSelected) { if (allSelected) {
selectedRows = {} $selectedRows = {}
} else { } else {
rows.forEach(row => { selectedRows.update(state => {
selectedRows[row._id] = true $rows.forEach(row => {
state[row._id] = true
})
return state
}) })
} }
} }
const handleChange = async (rowId, field, value) => { const handleChange = async (rowId, field, value) => {
let row = rows.find(x => x._id === rowId) let row = $rows.find(x => x._id === rowId)
if (!row) { if (!row) {
return return
} }
@ -167,10 +185,10 @@
const deleteRows = () => { const deleteRows = () => {
// Fetch full row objects to be deleted // Fetch full row objects to be deleted
const rowsToDelete = Object.entries(selectedRows) const rowsToDelete = Object.entries($selectedRows)
.map(entry => { .map(entry => {
if (entry[1] === true) { if (entry[1] === true) {
return rows.find(x => x._id === entry[0]) return $rows.find(x => x._id === entry[0])
} else { } else {
return null return null
} }
@ -186,9 +204,9 @@
await fetch.refresh() await fetch.refresh()
// Refresh state // Refresh state
selectedCell = null $selectedCellId = null
hoveredRow = null $hoveredRowId = null
selectedRows = {} $selectedRows = {}
} }
// Show confirmation // Show confirmation
@ -203,95 +221,25 @@
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.name}` $selectedCellId = `${res._id}-${field.name}`
newRows.push(res._id) newRows.push(res._id)
await fetch.refresh() await fetch.refresh()
} }
const getSortedRows = (rows, newRows) => { const updateSortedRows = (unsortedRows, newRows) => {
let sortedRows = rows.slice() let sortedRows = unsortedRows.slice()
sortedRows.sort((a, b) => { sortedRows.sort((a, b) => {
const aIndex = newRows.indexOf(a._id) const aIndex = newRows.indexOf(a._id)
const bIndex = newRows.indexOf(b._id) const bIndex = newRows.indexOf(b._id)
return aIndex < bIndex ? -1 : 1 return aIndex < bIndex ? -1 : 1
}) })
return sortedRows $rows = sortedRows
}
const startReordering = (fieldIdx, e) => {
isReordering = true
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() e.stopPropagation()
resizeInitialX = e.clientX resizeInitialX = e.clientX
resizeInitialWidth = fieldConfigs[fieldIdx].width resizeInitialWidth = $columns[fieldIdx].width
resizeFieldIndex = fieldIdx resizeFieldIndex = fieldIdx
document.addEventListener("mousemove", onResizeMove) document.addEventListener("mousemove", onResizeMove)
document.addEventListener("mouseup", stopResizing) document.addEventListener("mouseup", stopResizing)
@ -299,10 +247,13 @@
const onResizeMove = e => { const onResizeMove = e => {
const dx = e.clientX - resizeInitialX const dx = e.clientX - resizeInitialX
fieldConfigs[resizeFieldIndex].width = Math.max( columns.update(state => {
minWidth, state[resizeFieldIndex].width = Math.max(
resizeInitialWidth + dx minWidth,
) resizeInitialWidth + dx
)
return state
})
} }
const stopResizing = () => { const stopResizing = () => {
@ -335,7 +286,7 @@
class="spreadsheet" class="spreadsheet"
on:scroll={handleScroll} on:scroll={handleScroll}
style={gridStyles} style={gridStyles}
on:click|self={() => (selectedCell = null)} on:click|self={() => ($selectedCellId = null)}
id={`sheet-${rand}-body`} id={`sheet-${rand}-body`}
> >
<!-- Field headers --> <!-- Field headers -->
@ -345,14 +296,14 @@
checked={rowCount && selectedRowCount === rowCount} checked={rowCount && selectedRowCount === rowCount}
/> />
</div> </div>
{#each fieldConfigs as field, fieldIdx} {#each $columns 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:reordering-source={$reordering.columnIdx === fieldIdx}
class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx} class:reordering-target={$reordering.swapColumnIdx === fieldIdx}
on:mousedown={e => startReordering(fieldIdx, e)} on:mousedown={e => reordering.actions.startReordering(fieldIdx, e)}
id={`sheet-${rand}-header-${fieldIdx}`} id={`sheet-${rand}-header-${fieldIdx}`}
> >
<Icon <Icon
@ -369,21 +320,20 @@
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div <div
class="header cell spacer" class="header cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx === class:reordering-target={$reordering.swapColumnIdx === $columns.length}
fieldConfigs.length}
/> />
<!-- All real rows --> <!-- All real rows -->
{#each rows as row, rowIdx (row._id)} {#each $rows as row, rowIdx (row._id)}
{@const rowSelected = !!selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = hoveredRow === row._id} {@const rowHovered = $hoveredRowId === row._id}
{@const data = { ...row, ...changeCache[row._id] }} {@const data = { ...row, ...changeCache[row._id] }}
<div <div
class="cell label" class="cell label"
class:row-selected={rowSelected} class:row-selected={rowSelected}
class:hovered={rowHovered} class:hovered={rowHovered}
on:focus on:focus
on:mouseover={() => (hoveredRow = row._id)} on:mouseover={() => ($hoveredRowId = row._id)}
on:click={() => selectRow(row._id)} on:click={() => selectRow(row._id)}
> >
{#if rowSelected || rowHovered} {#if rowSelected || rowHovered}
@ -394,7 +344,7 @@
</span> </span>
{/if} {/if}
</div> </div>
{#each fieldConfigs as field, fieldIdx} {#each $columns as field, fieldIdx}
{@const cellIdx = `${row._id}-${field.name}`} {@const cellIdx = `${row._id}-${field.name}`}
{#key cellIdx} {#key cellIdx}
<div <div
@ -402,19 +352,19 @@
class:row-selected={rowSelected} class:row-selected={rowSelected}
class:sticky={fieldIdx === 0} class:sticky={fieldIdx === 0}
class:hovered={rowHovered} class:hovered={rowHovered}
class:selected={selectedCell === cellIdx} class:selected={$selectedCellId === cellIdx}
class:shadow={horizontallyScrolled} class:shadow={horizontallyScrolled}
class:reordering={reorderFieldIndex === fieldIdx} class:reordering-source={$reordering.columnIdx === fieldIdx}
class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx} class:reordering-target={$reordering.swapColumnIdx === fieldIdx}
on:focus on:focus
on:mouseover={() => (hoveredRow = row._id)} on:mouseover={() => ($hoveredRowId = row._id)}
on:click={() => (selectedCell = cellIdx)} on:click={() => ($selectedCellId = cellIdx)}
> >
<svelte:component <svelte:component
this={getCellForField(field)} this={getCellForField(field)}
value={data[field.name]} value={data[field.name]}
schema={field.schema} schema={field.schema}
selected={selectedCell === cellIdx} selected={$selectedCellId === cellIdx}
onChange={val => handleChange(row._id, field, val)} onChange={val => handleChange(row._id, field, val)}
readonly={field.schema.autocolumn} readonly={field.schema.autocolumn}
/> />
@ -424,8 +374,8 @@
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div <div
class="cell spacer" class="cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx === class:reordering-target={$reordering.swapColumnIdx ===
fieldConfigs.length} $columns.length}
/> />
{/each} {/each}
@ -434,29 +384,28 @@
class="cell label new" class="cell label new"
on:click={addRow} on:click={addRow}
on:focus on:focus
on:mouseover={() => (hoveredRow = "new")} on:mouseover={() => ($hoveredRowId = "new")}
class:hovered={hoveredRow === "new"} class:hovered={$hoveredRowId === "new"}
> >
<Icon hoverable name="Add" size="S" /> <Icon hoverable name="Add" size="S" />
</div> </div>
{#each fieldConfigs as field, fieldIdx} {#each $columns as field, fieldIdx}
<div <div
class="cell new" class="cell new"
class:sticky={fieldIdx === 0} class:sticky={fieldIdx === 0}
class:shadow={horizontallyScrolled} class:shadow={horizontallyScrolled}
class:hovered={hoveredRow === "new"} class:hovered={$hoveredRowId === "new"}
class:reordering={reorderFieldIndex === fieldIdx} class:reordering-source={$reordering.columnIdx === fieldIdx}
class:reorder-candidate={reorderCandidateFieldIdx === fieldIdx} class:reordering-target={$reordering.swapColumnIdx === fieldIdx}
on:click={() => addRow(field)} on:click={() => addRow(field)}
on:focus on:focus
on:mouseover={() => (hoveredRow = "new")} on:mouseover={() => ($hoveredRowId = "new")}
/> />
{/each} {/each}
<!-- Horizontal spacer --> <!-- Horizontal spacer -->
<div <div
class="cell spacer" class="cell spacer"
class:reorder-candidate={reorderCandidateFieldIdx === class:reordering-target={$reordering.swapColumnIdx === $columns.length}
fieldConfigs.length}
/> />
<!-- Vertical spacer --> <!-- Vertical spacer -->
@ -464,12 +413,7 @@
</div> </div>
<!-- Reorder placeholder --> <!-- Reorder placeholder -->
{#if isReordering} <ReorderingPlaceholder />
<div
class="reorder-placeholder"
style="--x:{reorderPlaceholderX}px;--width:{reorderPlaceholderWidth}px;--height:{reorderPlaceholderHeight}px;"
/>
{/if}
</div> </div>
</div> </div>
@ -630,10 +574,10 @@
} }
/* Reordering styles */ /* Reordering styles */
.cell.reordering { .cell.reordering-source {
background: var(--spectrum-global-color-gray-200); background: var(--spectrum-global-color-gray-200);
} }
.cell.reorder-candidate { .cell.reordering-target {
border-left-color: var(--spectrum-global-color-blue-400); border-left-color: var(--spectrum-global-color-blue-400);
} }
@ -682,14 +626,4 @@
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>

View File

@ -1,6 +1,6 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { getColor } from "./utils" import { getColor } from "../utils"
export let value export let value
export let schema export let schema

View File

@ -1,5 +1,5 @@
<script> <script>
import { getColor } from "./utils" import { getColor } from "../utils"
export let value export let value
</script> </script>

View File

@ -0,0 +1,134 @@
import { get, writable } from "svelte/store"
export const createReorderingStores = context => {
const { columns, rand, rows } = context
const reorderingInitialState = {
columnIdx: null,
swapColumnIdx: null,
breakpoints: [],
initialMouseX: null,
}
const reordering = writable(reorderingInitialState)
// This is broken into its own store as it is rapidly updated, and we want to
// ensure good performance by avoiding updating other components which depend
// on other reordering state
const placeholderInitialState = {
x: null,
initialX: null,
width: null,
height: null,
}
const placeholder = writable(placeholderInitialState)
// Callback when dragging on a colum header and starting reordering
const startReordering = (columnIdx, e) => {
// Generate new breakpoints for the current columns
let breakpoints = []
const cols = get(columns)
console.log(cols)
cols.forEach((col, idx) => {
const header = document.getElementById(`sheet-${rand}-header-${idx}`)
const bounds = header.getBoundingClientRect()
breakpoints.push(bounds.x)
if (idx === cols.length - 1) {
breakpoints.push(bounds.x + bounds.width)
}
})
// Get bounds of the selected header and sheet body
const self = document.getElementById(`sheet-${rand}-header-${columnIdx}`)
const selfBounds = self.getBoundingClientRect()
const body = document.getElementById(`sheet-${rand}-body`)
const bodyBounds = body.getBoundingClientRect()
// Update state
reordering.set({
columnIdx,
breakpoints,
swapColumnIdx: null,
initialMouseX: e.clientX,
})
placeholder.set({
initialX: selfBounds.x - bodyBounds.x,
x: selfBounds.x - bodyBounds.x,
width: selfBounds.width,
height: (get(rows).length + 2) * 32,
})
// Add listeners to handle mouse movement
document.addEventListener("mousemove", onReorderMouseMove)
document.addEventListener("mouseup", stopReordering)
// Trigger a move event immediately so ensure a candidate column is chosen
onReorderMouseMove(e)
}
// Callback when moving the mouse when reordering columns
const onReorderMouseMove = e => {
const $reordering = get(reordering)
if ($reordering.columnIdx == null) {
return
}
// Compute new placeholder position
const $placeholder = get(placeholder)
let newX = e.clientX - $reordering.initialMouseX + $placeholder.initialX
newX = Math.max(0, newX)
// Compute the closest breakpoint to the current position
let swapColumnIdx
let minDistance = Number.MAX_SAFE_INTEGER
$reordering.breakpoints.forEach((point, idx) => {
const distance = Math.abs(point - e.clientX)
if (distance < minDistance) {
minDistance = distance
swapColumnIdx = idx
}
})
// Update state
placeholder.update(state => {
state.x = newX
return state
})
if (swapColumnIdx !== $reordering.swapColumnIdx) {
reordering.update(state => {
state.swapColumnIdx = swapColumnIdx
return state
})
}
}
// Callback when stopping reordering columns
const stopReordering = () => {
// Swap position of columns
let { columnIdx, swapColumnIdx } = get(reordering)
const newColumns = get(columns).slice()
const removed = newColumns.splice(columnIdx, 1)
if (--swapColumnIdx < columnIdx) {
swapColumnIdx++
}
newColumns.splice(swapColumnIdx, 0, removed[0])
columns.set(newColumns)
// Reset state
reordering.set(reorderingInitialState)
placeholder.set(placeholderInitialState)
// Remove event handlers
document.removeEventListener("mousemove", onReorderMouseMove)
document.removeEventListener("mouseup", stopReordering)
}
return {
reordering: {
...reordering,
actions: {
startReordering,
stopReordering,
},
},
reorderingPlaceholder: placeholder,
}
}