Refactor spreadsheet into more discreet components

This commit is contained in:
Andrew Kingston 2023-03-01 11:53:09 +00:00
parent 43eadf2ec6
commit 40df22d791
16 changed files with 501 additions and 290 deletions

View File

@ -2,65 +2,37 @@
import SheetCell from "./SheetCell.svelte" import SheetCell from "./SheetCell.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { Checkbox } from "@budibase/bbui" import { getIconForField } from "./utils"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
const { visibleColumns, reorder, selectedRows, rows } = const { visibleColumns, reorder, selectedRows, rows } =
getContext("spreadsheet") getContext("spreadsheet")
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
const getIconForField = field => {
const type = field.schema.type
if (type === "options") {
return "ChevronDown"
} else if (type === "datetime") {
return "Date"
}
return "Text"
}
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
if (allSelected) {
$selectedRows = {}
} else {
let allRows = {}
$rows.forEach(row => {
allRows[row._id] = true
})
$selectedRows = allRows
}
}
</script> </script>
<div class="row"> <div>
<!-- Field headers --> <SheetScrollWrapper scrollVertically={false} wheelInteractive={false}>
<SheetCell header label on:click={selectAll} width="40" left="0"> <div class="row">
<Checkbox value={rowCount && selectedRowCount === rowCount} /> {#each $visibleColumns as column}
</SheetCell> <SheetCell
{#each $visibleColumns as column} header
<SheetCell reorderSource={$reorder.columnIdx === column.idx}
header reorderTarget={$reorder.swapColumnIdx === column.idx}
sticky={column.idx === 0} on:mousedown={e => reorder.actions.startReordering(column.idx, e)}
reorderSource={$reorder.columnIdx === column.idx} width={column.width}
reorderTarget={$reorder.swapColumnIdx === column.idx} left={column.left}
on:mousedown={column.idx === 0 >
? null <Icon
: e => reorder.actions.startReordering(column.idx, e)} size="S"
width={column.width} name={getIconForField(column)}
left={column.left} color="var(--spectrum-global-color-gray-600)"
> />
<Icon <span>
size="S" {column.name}
name={getIconForField(column)} </span>
color="var(--spectrum-global-color-gray-600)" </SheetCell>
/> {/each}
<span> </div>
{column.name} </SheetScrollWrapper>
</span>
</SheetCell>
{/each}
</div> </div>
<style> <style>
@ -70,6 +42,7 @@
top: 0; top: 0;
width: inherit; width: inherit;
z-index: 10; z-index: 10;
height: var(--cell-height);
} }
.row :global(> :last-child) { .row :global(> :last-child) {
border-right-width: 1px; border-right-width: 1px;

View File

@ -15,12 +15,8 @@
</script> </script>
<div class="row new"> <div class="row new">
<SheetCell label on:click={addRow} width="40" left="0">
<Icon hoverable name="Add" size="S" />
</SheetCell>
{#each $visibleColumns as column} {#each $visibleColumns as column}
<SheetCell <SheetCell
sticky={column.idx === 0}
on:click={() => addRow(column)} on:click={() => addRow(column)}
width={column.width} width={column.width}
left={column.left} left={column.left}
@ -34,12 +30,10 @@
.row { .row {
display: flex; display: flex;
width: inherit; width: inherit;
height: var(--cell-height);
} }
:global(.sheet:not(.is-resizing):not(.is-reordering) .row:hover .cell) { :global(.sheet:not(.is-resizing):not(.is-reordering) .row:hover .cell) {
background: var(--cell-background-hover); background: var(--cell-background-hover);
cursor: pointer; cursor: pointer;
} }
.row :global(> :last-child) {
border-right-width: 1px;
}
</style> </style>

View File

@ -1,8 +1,15 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
const { visibleRows, columns, rand, scroll, visibleColumns, cellHeight } = const {
getContext("spreadsheet") visibleRows,
columns,
rand,
scroll,
visibleColumns,
cellHeight,
stickyColumn,
} = getContext("spreadsheet")
const MinColumnWidth = 100 const MinColumnWidth = 100
let initialMouseX = null let initialMouseX = null
@ -14,14 +21,17 @@
$: scrollLeft = $scroll.left $: scrollLeft = $scroll.left
$: cutoff = scrollLeft + 40 + $columns[0]?.width || 0 $: cutoff = scrollLeft + 40 + $columns[0]?.width || 0
$: offset = 40 + $stickyColumn?.width || 0
$: rowCount = $visibleRows.length $: rowCount = $visibleRows.length
$: contentHeight = (rowCount + 2) * cellHeight
const startResizing = (idx, e) => { const startResizing = (idx, e) => {
// Prevent propagation to stop reordering triggering // Prevent propagation to stop reordering triggering
e.stopPropagation() e.stopPropagation()
width = $columns[idx].width const col = idx === "sticky" ? $stickyColumn : $columns[idx]
left = $columns[idx].left width = col.width
left = col.left
initialWidth = width initialWidth = width
initialMouseX = e.clientX initialMouseX = e.clientX
columnIdx = idx columnIdx = idx
@ -41,15 +51,22 @@
return return
} }
columns.update(state => { if (columnIdx === "sticky") {
state[columnIdx].width = newWidth stickyColumn.update(state => ({
let offset = state[columnIdx].left + newWidth ...state,
for (let i = columnIdx + 1; i < state.length; i++) { width: newWidth,
state[i].left = offset }))
offset += state[i].width } else {
} columns.update(state => {
return [...state] state[columnIdx].width = newWidth
}) let offset = state[columnIdx].left + newWidth
for (let i = columnIdx + 1; i < state.length; i++) {
state[i].left = offset
offset += state[i].width
}
return [...state]
})
}
width = newWidth width = newWidth
} }
@ -61,30 +78,38 @@
document.getElementById(`sheet-${rand}`).classList.remove("is-resizing") document.getElementById(`sheet-${rand}`).classList.remove("is-resizing")
} }
const getStyle = (col, scrollLeft, rowCount) => { const getStyle = (col, offset, scrollLeft, contentHeight) => {
const left = col.left + col.width - (col.idx === 0 ? 0 : scrollLeft) const left = offset + col.left + col.width - scrollLeft
const contentHeight = (rowCount + 2) * cellHeight
return `--left:${left}px; --content-height:${contentHeight}px;` return `--left:${left}px; --content-height:${contentHeight}px;`
} }
</script> </script>
{#if $stickyColumn}
<div
class="resize-slider sticky"
class:visible={columnIdx === "sticky"}
on:mousedown={e => startResizing("sticky", e)}
style="--left:{40 +
$stickyColumn.width}px; --content-height:{contentHeight}px;"
>
<div class="resize-indicator" />
</div>
{/if}
{#each $visibleColumns as col} {#each $visibleColumns as col}
{#if col.idx === 0 || col.left + col.width > cutoff} <div
<div class="resize-slider"
class="resize-slider" class:visible={columnIdx === col.idx}
class:visible={columnIdx === col.idx} on:mousedown={e => startResizing(col.idx, e)}
on:mousedown={e => startResizing(col.idx, e)} style={getStyle(col, offset, scrollLeft, contentHeight)}
style={getStyle(col, scrollLeft, rowCount)} >
> <div class="resize-indicator" />
<div class="resize-indicator" /> </div>
</div>
{/if}
{/each} {/each}
<style> <style>
.resize-slider { .resize-slider {
position: absolute; position: absolute;
top: var(--controls-height); top: 0;
z-index: 10; z-index: 10;
height: var(--cell-height); height: var(--cell-height);
left: var(--left); left: var(--left);
@ -97,7 +122,10 @@
.resize-slider.visible { .resize-slider.visible {
cursor: col-resize; cursor: col-resize;
opacity: 1; opacity: 1;
height: min(var(--content-height), calc(100% - var(--controls-height))); height: min(var(--content-height), 100%);
}
.resize-slider.sticky {
z-index: 25;
} }
.resize-indicator { .resize-indicator {
margin-left: -1px; margin-left: -1px;

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { domDebounce, debounce, throttle } from "../../utils/utils" import { domDebounce, debounce, throttle } from "../../utils/utils"
const { scroll, bounds, rows, cellHeight, columns } = const { scroll, bounds, rows, cellHeight, columns, stickyColumn } =
getContext("spreadsheet") getContext("spreadsheet")
// Bar config // Bar config
@ -23,20 +23,28 @@
$: barHeight = Math.max(50, (height / contentHeight) * height) $: barHeight = Math.max(50, (height / contentHeight) * height)
$: availHeight = height - barHeight - 2 * barOffset $: availHeight = height - barHeight - 2 * barOffset
$: maxScrollTop = contentHeight - height $: maxScrollTop = contentHeight - height
$: barTop = barOffset + availHeight * (scrollTop / maxScrollTop) $: barTop = barOffset + cellHeight + availHeight * (scrollTop / maxScrollTop)
// Calculate H scrollbar size and offset // Calculate H scrollbar size and offset
$: lastCol = $columns[$columns.length - 1] $: contentWidth = calculateContentWidth($columns, $stickyColumn)
$: contentWidth = lastCol ? lastCol?.left + lastCol?.width : 0 $: totalWidth = width + 40 + $stickyColumn?.width || 0
$: barWidth = Math.max(50, (width / contentWidth) * width) $: barWidth = Math.max(50, (totalWidth / contentWidth) * totalWidth)
$: availWidth = width - barWidth - 8 $: availWidth = totalWidth - barWidth - 2 * barOffset
$: maxScrollLeft = contentWidth - width $: maxScrollLeft = contentWidth - totalWidth
$: barLeft = 4 + availWidth * (scrollLeft / maxScrollLeft) $: barLeft = barOffset + availWidth * (scrollLeft / maxScrollLeft)
// Calculate whether to show scrollbars or not // Calculate whether to show scrollbars or not
$: showVScrollbar = contentHeight > height $: showVScrollbar = contentHeight > height
$: showHScrollbar = contentWidth > width $: showHScrollbar = contentWidth > width
const calculateContentWidth = (columns, stickyColumn) => {
let width = 40 + stickyColumn?.width
columns.forEach(col => {
width += col.width
})
return width
}
// V scrollbar drag handlers // V scrollbar drag handlers
const startVDragging = e => { const startVDragging = e => {
e.preventDefault() e.preventDefault()

View File

@ -6,13 +6,17 @@
import { createReorderStores } from "./stores/reorder" import { createReorderStores } from "./stores/reorder"
import { createViewportStores } from "./stores/viewport" import { createViewportStores } from "./stores/viewport"
import { createRowsStore } from "./stores/rows" import { createRowsStore } from "./stores/rows"
import SheetHeader from "./SheetHeader.svelte" import { createColumnsStores } from "./stores/columns"
import SheetControls from "./SheetControls.svelte"
import SheetBody from "./SheetBody.svelte" import SheetBody from "./SheetBody.svelte"
import SheetRow from "./SheetRow.svelte" import SheetRow from "./SheetRow.svelte"
import ResizeOverlay from "./ResizeOverlay.svelte" import ResizeOverlay from "./ResizeOverlay.svelte"
import HeaderRow from "./HeaderRow.svelte" import HeaderRow from "./HeaderRow.svelte"
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import { createAPIClient } from "../../api" import { createAPIClient } from "../../api"
import ScrollOverlay from "./ScrollOverlay.svelte"
import StickyColumn from "./StickyColumn.svelte"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
export let tableId export let tableId
export let filter export let filter
@ -22,12 +26,10 @@
// Sheet constants // Sheet constants
const cellHeight = 36 const cellHeight = 36
const defaultWidth = 200
const rand = Math.random() const rand = Math.random()
// State stores // State stores
const tableIdStore = writable() const tableIdStore = writable()
const columns = writable([])
const selectedCellId = writable() const selectedCellId = writable()
const selectedRows = writable({}) const selectedRows = writable({})
const scroll = writable({ const scroll = writable({
@ -45,70 +47,45 @@
let context = { let context = {
API: API || createAPIClient(), API: API || createAPIClient(),
rand, rand,
columns,
selectedCellId, selectedCellId,
selectedRows, selectedRows,
cellHeight, cellHeight,
bounds, bounds,
scroll, scroll,
tableId: tableIdStore, tableId: tableIdStore,
} }
const { rows, schema, primaryDisplay } = createRowsStore(context) const { rows, schema } = createRowsStore(context)
context = { ...context, rows } context = { ...context, rows, schema }
const { columns, stickyColumn } = createColumnsStores(context)
context = { ...context, columns, stickyColumn }
const { visibleRows, visibleColumns } = createViewportStores(context) const { visibleRows, visibleColumns } = createViewportStores(context)
context = { ...context, visibleRows, visibleColumns } context = { ...context, visibleRows, visibleColumns }
const { reorder } = createReorderStores(context) const { reorder } = createReorderStores(context)
context = { ...context, reorder } context = { ...context, reorder }
$: tableIdStore.set(tableId) $: tableIdStore.set(tableId)
$: generateColumns($schema, $primaryDisplay)
// Generates the column array the first time the schema loads
const generateColumns = (schema, primaryDisplay) => {
if (!schema) {
$columns = []
return
}
const currentColumns = $columns
// Get fields in new schema
let fields = Object.keys(schema || {})
if (primaryDisplay) {
fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)]
}
// Update columns, removing extraneous columns and adding missing ones
let offset = 40
$columns = fields.map((field, idx) => {
const existing = currentColumns.find(x => x.name === field)
const newCol = {
idx,
name: field,
width: existing?.width || defaultWidth,
left: offset,
schema: schema[field],
primaryDisplay: field === primaryDisplay,
}
offset += newCol.width
return newCol
})
}
// Set context for children to consume // Set context for children to consume
setContext("spreadsheet", context) setContext("spreadsheet", context)
</script> </script>
<div class="sheet" style="--cell-height:{cellHeight}px;" id="sheet-{rand}"> <div class="sheet" style="--cell-height:{cellHeight}px;" id="sheet-{rand}">
<SheetHeader /> <SheetControls />
<HeaderRow /> <div class="sheet-data">
<StickyColumn />
<SheetBody> <div class="sheet-main">
{#each $visibleRows as row} <HeaderRow />
<SheetRow {row} /> <SheetBody>
{/each} {#each $visibleRows as row}
<NewRow /> <SheetRow {row} />
</SheetBody> {/each}
<ResizeOverlay /> <NewRow />
</SheetBody>
</div>
<ResizeOverlay />
<ScrollOverlay />
</div>
</div> </div>
<style> <style>
@ -133,4 +110,21 @@
.sheet :global(*) { .sheet :global(*) {
box-sizing: border-box; box-sizing: border-box;
} }
.sheet-data {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-items: flex-start;
align-items: stretch;
overflow: hidden;
height: 0;
position: relative;
}
.sheet-main {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style> </style>

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Utils } from "../../utils" import { Utils } from "../../utils"
import ScrollOverlay from "./ScrollOverlay.svelte" import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
const { columns, selectedCellId, cellHeight, rows, bounds, scroll } = const { columns, selectedCellId, cellHeight, rows, bounds, scroll } =
getContext("spreadsheet") getContext("spreadsheet")
@ -10,47 +10,9 @@
let ref let ref
$: contentHeight = ($rows.length + 2) * cellHeight
$: contentWidth = computeContentWidth($columns)
$: scrollLeft = $scroll.left $: scrollLeft = $scroll.left
$: scrollTop = $scroll.top $: scrollTop = $scroll.top
const computeContentWidth = columns => {
if (!columns.length) {
return 0
}
const last = columns[columns.length - 1]
return last.left + last.width
}
const updateScrollStore = Utils.domDebounce((left, top) => {
scroll.set({ left, top })
})
const handleScroll = e => {
updateScrollStore(e.target.scrollLeft, e.target.scrollTop)
}
const handleWheel = e => {
const step = cellHeight * 3
const deltaY = e.deltaY < 0 ? -1 : 1
const offset = deltaY * step
let newScrollTop = scrollTop
newScrollTop += offset
newScrollTop = Math.max(0, newScrollTop)
newScrollTop = Math.min(
newScrollTop,
$rows.length * cellHeight - $bounds.height
)
scroll.update(state => ({
...state,
top: newScrollTop,
}))
}
$: fakeOffsetY = -1 * (scrollTop % cellHeight)
$: fakeOffsetX = -1 * scrollLeft
onMount(() => { onMount(() => {
// Observe and record the height of the body // Observe and record the height of the body
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
@ -68,15 +30,10 @@
class="sheet-body" class="sheet-body"
class:horizontally-scrolled={scrollLeft > 0} class:horizontally-scrolled={scrollLeft > 0}
on:click|self={() => ($selectedCellId = null)} on:click|self={() => ($selectedCellId = null)}
on:wheel|passive={handleWheel}
> >
<div <SheetScrollWrapper>
class="content"
style="width:{contentWidth}px; --offset-y:{fakeOffsetY}px; --offset-x:{fakeOffsetX}px;"
>
<slot /> <slot />
</div> </SheetScrollWrapper>
<ScrollOverlay />
</div> </div>
<style> <style>
@ -86,18 +43,10 @@
cursor: default; cursor: default;
overflow: hidden; overflow: hidden;
flex: 1 1 auto; flex: 1 1 auto;
height: 0;
} }
.sheet-body::-webkit-scrollbar-track { .sheet-body::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.content {
min-width: 100%;
min-height: 100%;
background: var(--background-alt);
transform: translate3d(var(--offset-x), var(--offset-y), 0);
overflow: hidden;
}
/* Add shadow to sticky cells when horizontally scrolled */ /* Add shadow to sticky cells when horizontally scrolled */
.horizontally-scrolled :global(.cell.sticky) { .horizontally-scrolled :global(.cell.sticky) {

View File

@ -10,11 +10,10 @@
export let reorderTarget = false export let reorderTarget = false
export let left export let left
export let width export let width
export let column
</script> </script>
<div <div
class="cell col-{column}" class="cell"
class:header class:header
class:label class:label
class:row-selected={rowSelected} class:row-selected={rowSelected}
@ -26,7 +25,7 @@
on:mouseenter on:mouseenter
on:click on:click
on:mousedown on:mousedown
style="--left: {left}px; --width:{width}px;" style="--width:{width}px; --left:{left}px;"
> >
<slot /> <slot />
</div> </div>
@ -39,7 +38,7 @@
border-color: var(--spectrum-global-color-gray-200); border-color: var(--spectrum-global-color-gray-200);
border-width: 0; border-width: 0;
border-bottom-width: 1px; border-bottom-width: 1px;
border-left-width: 1px; border-right-width: 1px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
@ -48,10 +47,11 @@
font-size: var(--cell-font-size); font-size: var(--cell-font-size);
gap: var(--cell-spacing); gap: var(--cell-spacing);
background: var(--cell-background); background: var(--cell-background);
position: absolute;
transition: border-color 130ms ease-out; transition: border-color 130ms ease-out;
width: var(--width); flex: 0 0 var(--width);
position: absolute;
left: var(--left); left: var(--left);
width: var(--width);
} }
.cell.selected { .cell.selected {
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400); box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400);
@ -91,18 +91,6 @@
z-index: 11; z-index: 11;
} }
/* Sticky styles */
.cell.sticky {
position: sticky;
border-left-width: 0;
transform: none;
left: 40px;
z-index: 5;
}
.cell.selected.sticky {
z-index: 6;
}
/* Reorder styles */ /* Reorder styles */
.cell.reorder-source { .cell.reorder-source {
background: var(--spectrum-global-color-gray-100); background: var(--spectrum-global-color-gray-100);
@ -122,8 +110,8 @@
/* Label cells */ /* Label cells */
.cell.label { .cell.label {
padding: var(--cell-padding); padding: var(--cell-padding);
width: 40px; flex: 0 0 40px;
border-left-width: 0; border-right-width: 0;
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 5; z-index: 5;

View File

@ -2,14 +2,8 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import SpreadsheetCell from "./SheetCell.svelte" import SheetCell from "./SheetCell.svelte"
import OptionsCell from "./cells/OptionsCell.svelte" import { getCellComponent } from "./utils"
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 TextCell from "./cells/TextCell.svelte"
import { Checkbox } from "@budibase/bbui"
export let row export let row
@ -21,57 +15,33 @@
visibleColumns, visibleColumns,
cellHeight, cellHeight,
} = getContext("spreadsheet") } = getContext("spreadsheet")
const TypeComponentMap = {
options: OptionsCell,
datetime: DateCell,
array: MultiSelectCell,
number: NumberCell,
link: RelationshipCell,
}
console.log("mount") console.log("mount")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
const selectRow = id => {
selectedRows.update(state => ({
...state,
[id]: !state[id],
}))
}
</script> </script>
<div class="row"> <div class="row">
<SpreadsheetCell label {rowSelected} on:click={() => selectRow(row._id)}>
<div class="checkbox" class:visible={rowSelected}>
<Checkbox value={rowSelected} />
</div>
<div class="number" class:visible={!rowSelected}>
{row.__idx + 1}
</div>
</SpreadsheetCell>
{#each $visibleColumns as column (column.name)} {#each $visibleColumns as column (column.name)}
{@const cellIdx = `${row._id}-${column.name}`} {@const cellIdx = `${row._id}-${column.name}`}
<SpreadsheetCell <SheetCell
{rowSelected} {rowSelected}
sticky={column.idx === 0}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellIdx}
reorderSource={$reorder.columnIdx === column.idx} reorderSource={$reorder.columnIdx === column.idx}
reorderTarget={$reorder.swapColumnIdx === column.idx} reorderTarget={$reorder.swapColumnIdx === column.idx}
on:click={() => ($selectedCellId = cellIdx)} on:click={() => ($selectedCellId = cellIdx)}
width={column.width} width={column.width}
left={column.left} left={column.left}
column={column.idx}
> >
<svelte:component <svelte:component
this={TypeComponentMap[column.schema.type] || TextCell} this={getCellComponent(column)}
value={row[column.name]} value={row[column.name]}
schema={column.schema} schema={column.schema}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellIdx}
onChange={val => rows.actions.updateRow(row._id, column, val)} onChange={val => rows.actions.updateRow(row._id, column, val)}
readonly={column.schema.autocolumn} readonly={column.schema.autocolumn}
/> />
</SpreadsheetCell> </SheetCell>
{/each} {/each}
</div> </div>
@ -80,32 +50,9 @@
display: flex; display: flex;
position: relative; position: relative;
width: inherit; width: inherit;
height: var(--cell-height);
} }
:global(.sheet:not(.is-resizing):not(.is-reordering) .row:hover .cell) { :global(.sheet:not(.is-resizing):not(.is-reordering) .row:hover .cell) {
background: var(--cell-background-hover); background: var(--cell-background-hover);
} }
/* Styles for label cell */
.checkbox {
display: none;
}
.number {
display: none;
color: var(--spectrum-global-color-gray-500);
}
.row:hover .checkbox,
.checkbox.visible {
display: flex;
}
.number.visible {
display: block;
}
.row:hover .number {
display: none;
}
/* Add right border to last cell */
.row :global(> :last-child) {
border-right-width: 1px;
}
</style> </style>

View File

@ -0,0 +1,82 @@
<script>
import { getContext } from "svelte"
const { cellHeight, scroll, bounds, rows, columns, visibleRows } =
getContext("spreadsheet")
export let scrollVertically = true
export let scrollHorizontally = true
export let wheelInteractive = true
$: scrollTop = $scroll.top
$: scrollLeft = $scroll.left
$: offsetY = scrollVertically ? -1 * (scrollTop % cellHeight) : 0
$: offsetX = scrollHorizontally ? -1 * scrollLeft : 0
$: rowCount = $visibleRows.length
$: contentWidth = calculateContentWidth($columns, scrollHorizontally)
$: contentHeight = calculateContentHeight(rowCount, scrollVertically)
$: style = getStyle(offsetX, offsetY, contentWidth, contentHeight)
const getStyle = (offsetX, offsetY, contentWidth, contentHeight) => {
let style = `--offset-y:${offsetY}px; --offset-x:${offsetX}px;`
if (contentWidth) {
style += `--width:${contentWidth}px;`
}
if (contentHeight) {
style += `--height:${contentHeight}px;`
}
return style
}
const calculateContentWidth = (columns, scroll) => {
if (!scroll) {
return null
}
let width = 0
columns.forEach(col => (width += col.width))
return width
}
const calculateContentHeight = (rowCount, scroll) => {
if (!scroll) {
return null
}
return (rowCount + 1) * cellHeight
}
const handleWheel = e => {
const step = cellHeight * 3
const deltaY = e.deltaY < 0 ? -1 : 1
const offset = deltaY * step
let newScrollTop = scrollTop
newScrollTop += offset
newScrollTop = Math.max(0, newScrollTop)
newScrollTop = Math.min(
newScrollTop,
($rows.length + 1) * cellHeight - $bounds.height
)
scroll.update(state => ({
...state,
top: newScrollTop,
}))
}
</script>
<div
class="scroll-wrapper"
{style}
on:wheel|passive={wheelInteractive ? handleWheel : null}
>
<slot />
</div>
<style>
.scroll-wrapper {
background: var(--background-alt);
transform: translate3d(var(--offset-x), var(--offset-y), 0);
overflow: hidden;
width: var(--width);
height: var(--height);
position: relative;
}
</style>

View File

@ -0,0 +1,153 @@
<script>
import { getContext } from "svelte"
import { Checkbox, Icon } from "@budibase/bbui"
import { getIconForField } from "./utils"
import SheetCell from "./SheetCell.svelte"
import { getCellComponent } from "./utils"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
const { rows, selectedRows, stickyColumn, visibleRows, selectedCellId } =
getContext("spreadsheet")
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: width = 40 + $stickyColumn?.width || 0
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
if (allSelected) {
$selectedRows = {}
} else {
let allRows = {}
$rows.forEach(row => {
allRows[row._id] = true
})
$selectedRows = allRows
}
}
const selectRow = id => {
selectedRows.update(state => ({
...state,
[id]: !state[id],
}))
}
const addRow = async field => {
const newRow = await rows.actions.addRow()
if (newRow) {
$selectedCellId = `${newRow._id}-${field.name}`
}
}
</script>
<div class="sticky-column" style="--width:{width}px;">
<div class="row">
<!-- Field headers -->
<SheetCell header label on:click={selectAll} width="40" left="0">
<Checkbox value={rowCount && selectedRowCount === rowCount} />
</SheetCell>
{#if $stickyColumn}
<SheetCell
header
sticky
width={$stickyColumn.width}
left={$stickyColumn.left}
>
<Icon
size="S"
name={getIconForField($stickyColumn)}
color="var(--spectrum-global-color-gray-600)"
/>
<span>
{$stickyColumn.name}
</span>
</SheetCell>
{/if}
</div>
<SheetScrollWrapper scrollHorizontally={false}>
{#each $visibleRows as row}
{@const rowSelected = !!$selectedRows[row._id]}
<div class="row">
<SheetCell
label
{rowSelected}
on:click={() => selectRow(row._id)}
width="40"
>
<div class="checkbox" class:visible={rowSelected}>
<Checkbox value={rowSelected} />
</div>
<div class="number" class:visible={!rowSelected}>
{row.__idx + 1}
</div>
</SheetCell>
{#if $stickyColumn}
{@const cellIdx = `${row._id}-${$stickyColumn.name}`}
<SheetCell
{rowSelected}
sticky
selected={$selectedCellId === cellIdx}
on:click={() => ($selectedCellId = cellIdx)}
width={$stickyColumn.width}
left="40"
>
<svelte:component
this={getCellComponent($stickyColumn)}
value={row[$stickyColumn.name]}
schema={$stickyColumn.schema}
selected={$selectedCellId === cellIdx}
onChange={val =>
rows.actions.updateRow(row._id, $stickyColumn, val)}
readonly={$stickyColumn.schema.autocolumn}
/>
</SheetCell>
{/if}
</div>
{/each}
<div class="row">
<SheetCell label on:click={addRow} width="40">
<Icon hoverable name="Add" size="S" />
</SheetCell>
{#if $stickyColumn}
<SheetCell on:click={addRow} width={$stickyColumn.width} left="40" />
{/if}
</div>
</SheetScrollWrapper>
</div>
<style>
.sticky-column {
flex: 0 0 var(--width);
z-index: 20;
}
.row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
/* Styles for label cell */
.checkbox {
display: none;
}
.number {
display: none;
color: var(--spectrum-global-color-gray-500);
}
.row:hover .checkbox,
.checkbox.visible {
display: flex;
}
.number.visible {
display: block;
}
.row:hover .number {
display: none;
}
</style>

View File

@ -0,0 +1,64 @@
import { get, writable } from "svelte/store"
export const createColumnsStores = context => {
const { schema } = context
const defaultWidth = 200
const columns = writable([])
const stickyColumn = writable(null)
schema.subscribe($schema => {
const currentColumns = get(columns)
if (!$schema) {
columns.set([])
return
}
// Get field list
let fields = []
Object.entries($schema || {}).forEach(([field, fieldSchema]) => {
if (!fieldSchema.primaryDisplay) {
fields.push(field)
}
})
// Update columns, removing extraneous columns and adding missing ones
let offset = 0
columns.set(
fields.map((field, idx) => {
const existing = currentColumns.find(x => x.name === field)
const newCol = {
idx,
name: field,
width: existing?.width || defaultWidth,
left: offset,
schema: $schema[field],
}
offset += newCol.width
return newCol
})
)
})
schema.subscribe($schema => {
const primaryDisplay = Object.entries($schema).find(entry => {
return entry[1].primaryDisplay
})
if (!primaryDisplay) {
stickyColumn.set(null)
return
}
const existingWidth = get(stickyColumn)?.width
const same = primaryDisplay[0] === get(stickyColumn)?.name
stickyColumn.set({
name: primaryDisplay[0],
width: same ? existingWidth : defaultWidth,
left: 40,
schema: primaryDisplay[1],
})
})
return {
columns,
stickyColumn,
}
}

View File

@ -76,7 +76,7 @@ export const createReorderStores = context => {
swapColumnIdx++ swapColumnIdx++
} }
state.splice(swapColumnIdx, 0, removed[0]) state.splice(swapColumnIdx, 0, removed[0])
let offset = 40 let offset = 0
return state.map((col, idx) => { return state.map((col, idx) => {
const newCol = { const newCol = {
...col, ...col,

View File

@ -15,7 +15,6 @@ export const createRowsStore = context => {
// Exported stores // Exported stores
const rows = writable([]) const rows = writable([])
const schema = writable({}) const schema = writable({})
const primaryDisplay = writable(null)
// Local stores for managing fetching data // Local stores for managing fetching data
const query = derived(filter, $filter => buildLuceneQuery($filter)) const query = derived(filter, $filter => buildLuceneQuery($filter))
@ -62,8 +61,12 @@ export const createRowsStore = context => {
loaded = true loaded = true
rowCacheMap = {} rowCacheMap = {}
rows.set([]) rows.set([])
schema.set($$fetch.schema) let newSchema = $$fetch.schema
primaryDisplay.set($$fetch.definition?.primaryDisplay) const primaryDisplay = $$fetch.definition?.primaryDisplay
if (primaryDisplay && newSchema[primaryDisplay]) {
newSchema[primaryDisplay].primaryDisplay = true
}
schema.set(newSchema)
} }
// Process new rows // Process new rows
@ -220,6 +223,5 @@ export const createRowsStore = context => {
}, },
}, },
schema, schema,
primaryDisplay,
} }
} }

View File

@ -39,25 +39,26 @@ export const createViewportStores = context => {
if (!$columns.length) { if (!$columns.length) {
return [] return []
} }
let startColIdx = 1 let startColIdx = 0
let rightEdge = $columns[1].width let rightEdge = $columns[0].width
while (rightEdge < $scrollLeft) { while (rightEdge < $scrollLeft) {
startColIdx++ startColIdx++
rightEdge += $columns[startColIdx].width rightEdge += $columns[startColIdx].width
} }
let endColIdx = startColIdx + 1 let endColIdx = startColIdx + 1
let leftEdge = $columns[0].width + 40 + rightEdge let leftEdge = rightEdge
while (leftEdge < $width + $scrollLeft) { while (leftEdge < $width + $scrollLeft) {
leftEdge += $columns[endColIdx]?.width leftEdge += $columns[endColIdx]?.width
endColIdx++ endColIdx++
} }
return [ return $columns.slice(Math.max(0, startColIdx - 1), endColIdx + 1)
$columns[0],
...$columns.slice(Math.max(1, startColIdx - 2), endColIdx + 2),
]
} }
) )
// visibleColumns.subscribe(state => {
// console.log(state)
// })
// Fetch next page when approaching end of data // Fetch next page when approaching end of data
visibleRows.subscribe($visibleRows => { visibleRows.subscribe($visibleRows => {
const lastVisible = $visibleRows[$visibleRows.length - 1] const lastVisible = $visibleRows[$visibleRows.length - 1]

View File

@ -1,6 +1,34 @@
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 TextCell from "./cells/TextCell.svelte"
export const getColor = idx => { export const getColor = idx => {
if (idx == null || idx === -1) { if (idx == null || idx === -1) {
return null return null
} }
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)` return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)`
} }
export const getIconForField = field => {
const type = field.schema.type
if (type === "options") {
return "ChevronDown"
} else if (type === "datetime") {
return "Date"
}
return "Text"
}
const TypeComponentMap = {
options: OptionsCell,
datetime: DateCell,
array: MultiSelectCell,
number: NumberCell,
link: RelationshipCell,
}
export const getCellComponent = column => {
return TypeComponentMap[column?.schema?.type] || TextCell
}