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

View File

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

View File

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