Merge pull request #14023 from Budibase/table-improvements-2

Multi-cell operations for tables
This commit is contained in:
Andrew Kingston 2024-07-22 08:51:32 +01:00 committed by GitHub
commit 8633fad7f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1763 additions and 779 deletions

View File

@ -1,33 +1,22 @@
<script>
import "@spectrum-css/progressbar/dist/index-vars.css"
import { tweened } from "svelte/motion"
import { cubicOut } from "svelte/easing"
export let value = false
export let easing = cubicOut
export let duration = 1000
export let width = false
export let sideLabel = false
export let hidePercentage = true
export let color // red, green, default = blue
export let size = "M"
const progress = tweened(0, {
duration: duration,
easing: easing,
})
$: if (value || value === 0) $progress = value
</script>
<div
class:spectrum-ProgressBar--indeterminate={!value && value !== 0}
class:spectrum-ProgressBar--sideLabel={sideLabel}
class="spectrum-ProgressBar spectrum-ProgressBar--size{size}"
value={$progress}
{value}
role="progressbar"
aria-valuenow={$progress}
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax="100"
style={width ? `width: ${width};` : ""}
@ -43,7 +32,7 @@
<div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
>
{Math.round($progress)}%
{Math.round(value)}%
</div>
{/if}
<div class="spectrum-ProgressBar-track">
@ -51,7 +40,7 @@
class="spectrum-ProgressBar-fill"
class:color-green={color === "green"}
class:color-red={color === "red"}
style={value || value === 0 ? `width: ${$progress}%` : ""}
style="width: {value}%; --duration: {duration}ms;"
/>
</div>
<div class="spectrum-ProgressBar-label" hidden="" />
@ -64,4 +53,7 @@
.color-red {
background: #dd2019;
}
.spectrum-ProgressBar-fill {
transition: width var(--duration) ease-out;
}
</style>

View File

@ -3,13 +3,13 @@
--ink: #000000;
/* Brand colours */
--bb-coral: #FF4E4E;
--bb-coral-light: #F97777;
--bb-indigo: #6E56FF;
--bb-indigo-light: #9F8FFF;
--bb-lime: #ECFFB5;
--bb-coral: #ff4e4e;
--bb-coral-light: #f97777;
--bb-indigo: #6e56ff;
--bb-indigo-light: #9f8fff;
--bb-lime: #ecffb5;
--bb-forest-green: #053835;
--bb-beige: #F6EFEA;
--bb-beige: #f6efea;
--grey-1: #fafafa;
--grey-2: #f5f5f5;
@ -49,10 +49,10 @@
--rounded-medium: 8px;
--rounded-large: 16px;
--font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-serif: "Georgia", Cambria, Times New Roman, Times, serif;
--font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
@ -111,7 +111,7 @@ a {
/* Custom theme additions */
.spectrum--darkest {
--drop-shadow: rgba(0, 0, 0, 0.6);
--spectrum-global-color-blue-100: rgb(28, 33, 43);
--spectrum-global-color-blue-100: rgb(30, 36, 50);
}
.spectrum--dark {
--drop-shadow: rgba(0, 0, 0, 0.3);

View File

@ -84,6 +84,8 @@
showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
minWidth={360}
maxWidth={360}
offset={18}
>
<span class="popover-wrap">

View File

@ -99,4 +99,11 @@
);
align-items: center;
}
.type-icon span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 0;
flex: 1 1 auto;
}
</style>

View File

@ -124,13 +124,12 @@
return readable([])
}
return derived(
[gridContext.selectedRows, gridContext.rowLookupMap, gridContext.rows],
([$selectedRows, $rowLookupMap, $rows]) => {
[gridContext.selectedRows, gridContext.rowLookupMap],
([$selectedRows, $rowLookupMap]) => {
return Object.entries($selectedRows || {})
.filter(([_, selected]) => selected)
.map(([rowId]) => {
const idx = $rowLookupMap[rowId]
return gridContext.rows.actions.cleanRow($rows[idx])
return gridContext.rows.actions.cleanRow($rowLookupMap[rowId])
})
}
)
@ -172,7 +171,6 @@
canEditColumns={false}
canExpandRows={false}
canSaveSchema={false}
canSelectRows={true}
showControls={false}
notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error}

View File

@ -4,12 +4,21 @@
import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store"
const { rows, focusedCellId, focusedCellAPI, menu, config, validation } =
getContext("grid")
const {
rows,
columns,
focusedCellId,
focusedCellAPI,
menu,
config,
validation,
selectedCells,
selectedCellCount,
} = getContext("grid")
export let highlighted
export let selected
export let rowFocused
export let rowSelected
export let rowIdx
export let topRow = false
export let focused
@ -20,29 +29,32 @@
export let updateValue = rows.actions.updateValue
export let contentLines = 1
export let hidden = false
export let isSelectingCells = false
export let cellSelected = false
const emptyError = writable(null)
let api
// Get the error for this cell if the row is focused
// Get the error for this cell if the cell is focused or selected
$: error = getErrorStore(rowFocused, cellId)
// Determine if the cell is editable
$: readonly =
column.schema.autocolumn ||
column.schema.disabled ||
column.schema.type === "formula" ||
(!$config.canEditRows && !row._isNewRow) ||
column.schema.readonly
columns.actions.isReadonly(column) ||
(!$config.canEditRows && !row._isNewRow)
// Register this cell API if the row is focused
// Register this cell API if this cell is focused
$: {
if (focused) {
focusedCellAPI.set(cellAPI)
}
}
// Callbacks for cell selection
$: updateSelectionCallback = isSelectingCells ? updateSelection : null
$: stopSelectionCallback = isSelectingCells ? stopSelection : null
const getErrorStore = (selected, cellId) => {
if (!selected) {
return emptyError
@ -68,20 +80,55 @@
})
},
}
const startSelection = e => {
if (e.button !== 0 || e.shiftKey) {
return
}
selectedCells.actions.startSelecting(cellId)
}
const updateSelection = e => {
if (e.buttons !== 1) {
selectedCells.actions.stopSelecting()
return
}
selectedCells.actions.updateTarget(cellId)
}
const stopSelection = () => {
selectedCells.actions.stopSelecting()
}
const handleClick = e => {
if (e.shiftKey && $focusedCellId) {
// If we have a focused cell, select the range from that cell to here
selectedCells.actions.selectRange($focusedCellId, cellId)
} else if (e.shiftKey && $selectedCellCount) {
// If we already have a selected range of cell, update it
selectedCells.actions.updateTarget(cellId)
} else {
// Otherwise just select this cell
focusedCellId.set(cellId)
}
}
</script>
<GridCell
{highlighted}
{selected}
{rowIdx}
{topRow}
{focused}
{selectedUser}
{readonly}
{hidden}
selected={rowSelected || cellSelected}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
on:mousedown={startSelection}
on:mouseenter={updateSelectionCallback}
on:mouseup={stopSelectionCallback}
on:click={handleClick}
width={column.width}
>
<svelte:component

View File

@ -46,6 +46,7 @@
on:touchstart
on:touchend
on:touchcancel
on:mouseenter
{style}
>
{#if error}
@ -155,6 +156,7 @@
.cell.focused.readonly {
--cell-background: var(--cell-background-hover);
}
.cell.selected.focused,
.cell.selected:not(.focused) {
--cell-background: var(--spectrum-global-color-blue-100);
}

View File

@ -16,14 +16,22 @@
const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher()
$: selectionEnabled = $config.canSelectRows || $config.canDeleteRows
const select = e => {
e.stopPropagation()
svelteDispatch("select")
const id = row?._id
if (id) {
selectedRows.actions.toggleRow(id)
// Bulk select with shift
if (e.shiftKey) {
// Prevent default if already selected, to prevent checkbox clearing
if (rowSelected) {
e.preventDefault()
} else {
selectedRows.actions.bulkSelectRows(id)
}
} else {
selectedRows.actions.toggleRow(id)
}
}
}
@ -54,16 +62,14 @@
<div
on:click={select}
class="checkbox"
class:visible={selectionEnabled &&
(disableNumber || rowSelected || rowHovered || rowFocused)}
class:visible={disableNumber || rowSelected || rowHovered || rowFocused}
>
<Checkbox value={rowSelected} {disabled} />
</div>
{#if !disableNumber}
<div
class="number"
class:visible={!selectionEnabled ||
!(rowSelected || rowHovered || rowFocused)}
class:visible={!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>

View File

@ -18,7 +18,7 @@
isReordering,
isResizing,
sort,
visibleColumns,
scrollableColumns,
dispatch,
subscribe,
config,
@ -51,7 +51,7 @@
$: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $visibleColumns.length - 1
$: canMoveRight = orderable && idx < $scrollableColumns.length - 1
$: sortingLabels = getSortingLabels(column.schema?.type)
$: searchable = isColumnSearchable(column)
$: resetSearchValue(column.name)
@ -270,7 +270,7 @@
on:touchcancel={onMouseUp}
on:contextmenu={onContextMenu}
width={column.width}
left={column.left}
left={column.__left}
defaultHeight
center
>

View File

@ -1,35 +1,120 @@
<script>
import { Modal, ModalContent } from "@budibase/bbui"
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { parseCellID } from "../lib/utils"
import { sleep } from "../../../utils/utils"
const { selectedRows, rows, subscribe, notifications } = getContext("grid")
const {
selectedRows,
rows,
subscribe,
notifications,
menu,
selectedCellCount,
selectedRowCount,
selectedCells,
rowLookupMap,
config,
} = getContext("grid")
const duration = 260
let modal
let rowsModal
let cellsModal
let processing = false
let progressPercentage = 0
let promptQuantity = 0
$: selectedRowCount = Object.values($selectedRows).length
$: rowsToDelete = Object.entries($selectedRows)
.map(entry => $rows.find(x => x._id === entry[0]))
$: rowsToDelete = Object.keys($selectedRows)
.map(rowId => $rowLookupMap[rowId])
.filter(x => x != null)
// Deletion callback when confirmed
const performDeletion = async () => {
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete)
$notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
const handleBulkDeleteRequest = () => {
progressPercentage = 0
menu.actions.close()
if ($selectedRowCount && $config.canDeleteRows) {
if ($selectedRowCount === 1) {
bulkDeleteRows()
} else {
promptQuantity = $selectedRowCount
rowsModal?.show()
}
} else if ($selectedCellCount && $config.canEditRows) {
promptQuantity = $selectedCellCount
cellsModal?.show()
}
}
onMount(() => subscribe("request-bulk-delete", () => modal?.show()))
const bulkDeleteRows = async () => {
processing = true
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete)
// This is a real bulk delete endpoint so we don't need progress.
// We just animate it uo to 100 when we're done for consistency with other
// prompts.
progressPercentage = 100
await sleep(duration)
$notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
processing = false
}
const bulkDeleteCells = async () => {
processing = true
let changeMap = {}
for (let row of $selectedCells) {
for (let cellId of row) {
const { rowId, field } = parseCellID(cellId)
if (!changeMap[rowId]) {
changeMap[rowId] = {}
}
changeMap[rowId][field] = null
}
}
await rows.actions.bulkUpdate(changeMap, progress => {
progressPercentage = progress * 100
})
await sleep(duration)
processing = false
}
onMount(() => subscribe("request-bulk-delete", handleBulkDeleteRequest))
</script>
<Modal bind:this={modal}>
<Modal bind:this={rowsModal}>
<ModalContent
title="Delete rows"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performDeletion}
onConfirm={bulkDeleteRows}
size="M"
>
Are you sure you want to delete {selectedRowCount}
row{selectedRowCount === 1 ? "" : "s"}?
Are you sure you want to delete {promptQuantity} rows?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>
<Modal bind:this={cellsModal}>
<ModalContent
title="Delete cells"
confirmText="Continue"
cancelText="Cancel"
onConfirm={bulkDeleteCells}
size="M"
>
Are you sure you want to delete {promptQuantity} cells?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View File

@ -0,0 +1,79 @@
<script>
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { getCellID } from "../lib/utils"
import { sleep } from "../../../utils/utils"
const {
selectedRows,
rows,
subscribe,
selectedRowCount,
visibleColumns,
selectedCells,
rowLookupMap,
} = getContext("grid")
const duration = 260
let modal
let progressPercentage = 0
let processing = false
let promptQuantity = 0
// Deletion callback when confirmed
const performDuplication = async () => {
progressPercentage = 0
processing = true
// duplicate rows
const rowsToDuplicate = Object.keys($selectedRows).map(id => {
return $rowLookupMap[id]
})
const newRows = await rows.actions.bulkDuplicate(
rowsToDuplicate,
progress => {
progressPercentage = progress * 100
}
)
await sleep(duration)
// Select new cells to highlight them
if (newRows.length) {
const firstRow = newRows[0]
const lastRow = newRows[newRows.length - 1]
const firstCol = $visibleColumns[0]
const lastCol = $visibleColumns[$visibleColumns.length - 1]
const startCellId = getCellID(firstRow._id, firstCol.name)
const endCellId = getCellID(lastRow._id, lastCol.name)
selectedCells.actions.selectRange(startCellId, endCellId)
}
processing = false
}
const handleBulkDuplicateRequest = () => {
promptQuantity = $selectedRowCount
modal?.show()
}
onMount(() => subscribe("request-bulk-duplicate", handleBulkDuplicateRequest))
</script>
<Modal bind:this={modal}>
<ModalContent
title="Duplicate rows"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performDuplication}
size="M"
>
Are you sure you want to duplicate {promptQuantity} rows?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View File

@ -0,0 +1,67 @@
<script>
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { sleep } from "../../../utils/utils"
const { clipboard, subscribe, copyAllowed, pasteAllowed, selectedCellCount } =
getContext("grid")
const duration = 260
let modal
let progressPercentage = 0
let processing = false
const handleCopyRequest = () => {
if (!$copyAllowed) {
return
}
clipboard.actions.copy()
}
const handlePasteRequest = async () => {
progressPercentage = 0
if (!$pasteAllowed) {
return
}
// Prompt if paste will update multiple cells
const multiCellPaste = $selectedCellCount > 1
const prompt = $clipboard.multiCellCopy || multiCellPaste
if (prompt) {
modal?.show()
} else {
clipboard.actions.paste()
}
}
const performBulkPaste = async () => {
processing = true
await clipboard.actions.paste(progress => {
progressPercentage = progress * 100
})
await sleep(duration)
processing = false
}
onMount(() => subscribe("copy", handleCopyRequest))
onMount(() => subscribe("paste", handlePasteRequest))
</script>
<Modal bind:this={modal}>
<ModalContent
title="Confirm paste"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performBulkPaste}
size="M"
>
Are you sure you want to paste? This will update multiple values.
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View File

@ -7,14 +7,12 @@
export let allowViewReadonlyColumns = false
const { columns, datasource, stickyColumn, dispatch } = getContext("grid")
const { columns, datasource, dispatch } = getContext("grid")
let open = false
let anchor
$: allColumns = $stickyColumn ? [$stickyColumn, ...$columns] : $columns
$: restrictedColumns = allColumns.filter(col => !col.visible || col.readonly)
$: restrictedColumns = $columns.filter(col => !col.visible || col.readonly)
$: anyRestricted = restrictedColumns.length
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
@ -43,12 +41,9 @@
HIDDEN: "hidden",
}
$: displayColumns = allColumns.map(c => {
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const isDisplayColumn = $stickyColumn === c
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
@ -74,9 +69,9 @@
options.push({
icon: "VisibilityOff",
value: PERMISSION_OPTIONS.HIDDEN,
disabled: isDisplayColumn || isRequired,
disabled: c.primaryDisplay || isRequired,
tooltip:
(isDisplayColumn && "Display column cannot be hidden") ||
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})

View File

@ -8,14 +8,8 @@
SmallRowHeight,
} from "../lib/constants"
const {
stickyColumn,
columns,
rowHeight,
definition,
fixedRowHeight,
datasource,
} = getContext("grid")
const { columns, rowHeight, definition, fixedRowHeight, datasource } =
getContext("grid")
// Some constants for column width options
const smallColSize = 120
@ -42,10 +36,9 @@
let anchor
// Column width sizes
$: allCols = $columns.concat($stickyColumn ? [$stickyColumn] : [])
$: allSmall = allCols.every(col => col.width === smallColSize)
$: allMedium = allCols.every(col => col.width === mediumColSize)
$: allLarge = allCols.every(col => col.width === largeColSize)
$: allSmall = $columns.every(col => col.width === smallColSize)
$: allMedium = $columns.every(col => col.width === mediumColSize)
$: allLarge = $columns.every(col => col.width === largeColSize)
$: custom = !allSmall && !allMedium && !allLarge
$: columnSizeOptions = [
{
@ -80,7 +73,7 @@
size="M"
on:click={() => (open = !open)}
selected={open}
disabled={!allCols.length}
disabled={!$columns.length}
>
Size
</ActionButton>

View File

@ -3,34 +3,20 @@
import { ActionButton, Popover, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/shared-core"
const { sort, columns, stickyColumn } = getContext("grid")
const { sort, columns } = getContext("grid")
let open = false
let anchor
$: columnOptions = getColumnOptions($stickyColumn, $columns)
$: columnOptions = $columns
.map(col => ({
label: col.label || col.name,
value: col.name,
type: col.schema?.type,
}))
.filter(col => canBeSortColumn(col.type))
$: orderOptions = getOrderOptions($sort.column, columnOptions)
const getColumnOptions = (stickyColumn, columns) => {
let options = []
if (stickyColumn) {
options.push({
label: stickyColumn.label || stickyColumn.name,
value: stickyColumn.name,
type: stickyColumn.schema?.type,
})
}
options = [
...options,
...columns.map(col => ({
label: col.label || col.name,
value: col.name,
type: col.schema?.type,
})),
]
return options.filter(col => canBeSortColumn(col.type))
}
const getOrderOptions = (column, columnOptions) => {
const type = columnOptions.find(col => col.value === column)?.type
return [

View File

@ -13,8 +13,8 @@
rows,
focusedRow,
selectedRows,
visibleColumns,
scroll,
scrollableColumns,
scrollLeft,
isDragging,
buttonColumnWidth,
showVScrollbar,
@ -24,12 +24,13 @@
let container
$: buttons = $props.buttons?.slice(0, 3) || []
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)
$: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - $buttonColumnWidth, end)
$: columnEnd = columnsWidth - $scrollLeft - 1
$: gridEnd = $width - $buttonColumnWidth - 1
$: left = Math.min(columnEnd, gridEnd)
const handleClick = async (button, row) => {
await button.onClick?.(rows.actions.cleanRow(row))
@ -40,7 +41,7 @@
onMount(() => {
const observer = new ResizeObserver(entries => {
const width = entries?.[0]?.contentRect?.width ?? 0
buttonColumnWidth.set(width)
buttonColumnWidth.set(Math.floor(width) - 1)
})
observer.observe(container)
})
@ -51,6 +52,7 @@
class="button-column"
style="left:{left}px"
class:hidden={$buttonColumnWidth === 0}
class:right-border={left !== gridEnd}
>
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<GridScrollWrapper scrollVertically attachHandlers bind:ref={container}>
@ -150,4 +152,7 @@
.button-column :global(.cell) {
border-left: var(--cell-border);
}
.button-column:not(.right-border) :global(.cell) {
border-right-color: transparent;
}
</style>

View File

@ -7,6 +7,8 @@
import { createAPIClient } from "../../../api"
import { attachStores } from "../stores"
import BulkDeleteHandler from "../controls/BulkDeleteHandler.svelte"
import BulkDuplicationHandler from "../controls/BulkDuplicationHandler.svelte"
import ClipboardHandler from "../controls/ClipboardHandler.svelte"
import GridBody from "./GridBody.svelte"
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
import ReorderOverlay from "../overlays/ReorderOverlay.svelte"
@ -42,7 +44,6 @@
export let canDeleteRows = true
export let canEditColumns = true
export let canSaveSchema = true
export let canSelectRows = false
export let stripeRows = false
export let quiet = false
export let collaboration = true
@ -99,7 +100,6 @@
canDeleteRows,
canEditColumns,
canSaveSchema,
canSelectRows,
stripeRows,
quiet,
collaboration,
@ -209,9 +209,11 @@
<ProgressCircle />
</div>
{/if}
{#if $config.canDeleteRows}
<BulkDeleteHandler />
{#if $config.canAddRows}
<BulkDuplicationHandler />
{/if}
<BulkDeleteHandler />
<ClipboardHandler />
<KeyboardManager />
</div>

View File

@ -9,7 +9,7 @@
const {
bounds,
renderedRows,
visibleColumns,
scrollableColumns,
hoveredRowId,
dispatch,
isDragging,
@ -19,7 +19,7 @@
let body
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)

View File

@ -10,19 +10,23 @@
focusedCellId,
reorder,
selectedRows,
visibleColumns,
scrollableColumns,
hoveredRowId,
selectedCellMap,
focusedRow,
contentLines,
isDragging,
dispatch,
rows,
columnRenderMap,
userCellMap,
isSelectingCells,
selectedCellMap,
selectedCellCount,
} = getContext("grid")
$: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id
$: rowHovered =
$hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells)
$: rowFocused = $focusedRow?._id === row._id
$: reorderSource = $reorder.sourceColumn
</script>
@ -36,22 +40,24 @@
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
{#each $visibleColumns as column}
{#each $scrollableColumns as column}
{@const cellId = getCellID(row._id, column.name)}
<DataCell
{cellId}
{column}
{row}
{rowFocused}
{rowSelected}
cellSelected={$selectedCellMap[cellId]}
highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected}
rowIdx={row.__idx}
topRow={top}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
selectedUser={$userCellMap[cellId]}
width={column.width}
contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]}
isSelectingCells={$isSelectingCells}
/>
{/each}
</div>

View File

@ -5,7 +5,7 @@
const {
rowHeight,
scroll,
focusedCellId,
ui,
renderedRows,
maxScrollTop,
maxScrollLeft,
@ -13,6 +13,8 @@
hoveredRowId,
menu,
focusedCellAPI,
scrollTop,
scrollLeft,
} = getContext("grid")
export let scrollVertically = false
@ -24,11 +26,11 @@
let initialTouchX
let initialTouchY
$: style = generateStyle($scroll, $rowHeight)
$: style = generateStyle($scrollLeft, $scrollTop, $rowHeight)
const generateStyle = (scroll, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scroll.left : 0
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
const generateStyle = (scrollLeft, scrollTop, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scrollLeft : 0
const offsetY = scrollVertically ? -1 * (scrollTop % rowHeight) : 0
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}
@ -108,7 +110,7 @@
on:wheel={attachHandlers ? handleWheel : null}
on:touchstart={attachHandlers ? handleTouchStart : null}
on:touchmove={attachHandlers ? handleTouchMove : null}
on:click|self={() => ($focusedCellId = null)}
on:click|self={ui.actions.blur}
>
<div {style} class="inner" bind:this={ref}>
<slot />

View File

@ -5,14 +5,14 @@
import HeaderCell from "../cells/HeaderCell.svelte"
import { TempTooltip, TooltipType } from "@budibase/bbui"
const { visibleColumns, config, hasNonAutoColumn, datasource, loading } =
const { scrollableColumns, config, hasNonAutoColumn, datasource, loading } =
getContext("grid")
</script>
<div class="header">
<GridScrollWrapper scrollHorizontally>
<div class="row">
{#each $visibleColumns as column, idx}
{#each $scrollableColumns as column, idx}
<HeaderCell {column} {idx}>
<slot name="edit-column" />
</HeaderCell>

View File

@ -3,17 +3,23 @@
import { Icon } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
const { visibleColumns, scroll, width, subscribe, ui, keyboardBlocked } =
getContext("grid")
const {
scrollableColumns,
scrollLeft,
width,
subscribe,
ui,
keyboardBlocked,
} = getContext("grid")
let anchor
let isOpen = false
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)
$: end = columnsWidth - 1 - $scroll.left
$: end = columnsWidth - 1 - $scrollLeft
$: left = Math.min($width - 40, end)
$: keyboardBlocked.set(isOpen)
@ -43,7 +49,7 @@
{#if isOpen}
<GridPopover
{anchor}
align={$visibleColumns.length ? "right" : "left"}
align={$scrollableColumns.length ? "right" : "left"}
on:close={close}
maxHeight={null}
resizable

View File

@ -12,7 +12,7 @@
const {
hoveredRowId,
focusedCellId,
stickyColumn,
displayColumn,
scroll,
dispatch,
rows,
@ -20,7 +20,7 @@
datasource,
subscribe,
renderedRows,
visibleColumns,
scrollableColumns,
rowHeight,
hasNextPage,
maxScrollTop,
@ -31,6 +31,7 @@
filter,
inlineFilters,
columnRenderMap,
visibleColumns,
scrollTop,
} = getContext("grid")
@ -39,8 +40,8 @@
let newRow
let offset = 0
$: firstColumn = $stickyColumn || $visibleColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: firstColumn = $visibleColumns[0]
$: width = GutterWidth + ($displayColumn?.width || 0)
$: $datasource, (visible = false)
$: selectedRowCount = Object.values($selectedRows).length
$: hasNoRows = !$rows.length
@ -70,7 +71,10 @@
const newRowIndex = offset ? undefined : 0
let rowToCreate = { ...newRow }
delete rowToCreate._isNewRow
const savedRow = await rows.actions.addRow(rowToCreate, newRowIndex)
const savedRow = await rows.actions.addRow({
row: rowToCreate,
idx: newRowIndex,
})
if (savedRow) {
// Reset state
clear()
@ -167,7 +171,7 @@
class="new-row-fab"
on:click={() => dispatch("add-row-inline")}
transition:fade|local={{ duration: 130 }}
class:offset={!$stickyColumn}
class:offset={!$displayColumn}
>
<Icon name="Add" size="S" />
</div>
@ -191,19 +195,19 @@
<div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if}
</GutterCell>
{#if $stickyColumn}
{@const cellId = getCellID(NewRowID, $stickyColumn.name)}
{#if $displayColumn}
{@const cellId = getCellID(NewRowID, $displayColumn.name)}
<DataCell
{cellId}
rowFocused
column={$stickyColumn}
column={$displayColumn}
row={newRow}
focused={$focusedCellId === cellId}
width={$stickyColumn.width}
width={$displayColumn.width}
{updateValue}
topRow={offset === 0}
>
{#if $stickyColumn?.schema?.autocolumn}
{#if $displayColumn?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div>
{/if}
{#if isAdding}
@ -216,7 +220,7 @@
<div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row">
{#each $visibleColumns as column}
{#each $scrollableColumns as column}
{@const cellId = getCellID(NewRowID, column.name)}
<DataCell
{cellId}

View File

@ -13,22 +13,25 @@
const {
rows,
selectedRows,
stickyColumn,
displayColumn,
renderedRows,
focusedCellId,
hoveredRowId,
config,
selectedCellMap,
userCellMap,
focusedRow,
scrollLeft,
dispatch,
contentLines,
isDragging,
isSelectingCells,
selectedCellCount,
} = getContext("grid")
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).length
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: width = GutterWidth + ($displayColumn?.width || 0)
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
@ -57,8 +60,8 @@
rowSelected={selectedRowCount && selectedRowCount === rowCount}
disabled={!$renderedRows.length}
/>
{#if $stickyColumn}
<HeaderCell column={$stickyColumn} orderable={false} idx="sticky">
{#if $displayColumn}
<HeaderCell column={$displayColumn} orderable={false} idx="sticky">
<slot name="edit-column" />
</HeaderCell>
{/if}
@ -69,9 +72,11 @@
<GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const rowHovered =
$hoveredRowId === row._id &&
(!$selectedCellCount || !$isSelectingCells)}
{@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = getCellID(row._id, $stickyColumn?.name)}
{@const cellId = getCellID(row._id, $displayColumn?.name)}
<div
class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
@ -79,20 +84,22 @@
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn}
{#if $displayColumn}
<DataCell
{row}
{cellId}
{rowFocused}
selected={rowSelected}
{rowSelected}
cellSelected={$selectedCellMap[cellId]}
highlighted={rowHovered || rowFocused}
rowIdx={row.__idx}
topRow={idx === 0}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
width={$stickyColumn.width}
column={$stickyColumn}
selectedUser={$userCellMap[cellId]}
width={$displayColumn.width}
column={$displayColumn}
contentLines={$contentLines}
isSelectingCells={$isSelectingCells}
/>
{/if}
</div>
@ -107,9 +114,9 @@
<GutterCell rowHovered={$hoveredRowId === BlankRowID}>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
</GutterCell>
{#if $stickyColumn}
{#if $displayColumn}
<GridCell
width={$stickyColumn.width}
width={$displayColumn.width}
highlighted={$hoveredRowId === BlankRowID}
>
<KeyboardShortcut padded keybind="Ctrl+Enter" />

View File

@ -1,17 +1,17 @@
import { helpers } from "@budibase/shared-core"
import { TypeIconMap } from "../../../constants"
// we can't use "-" for joining the ID/field, as this can be present in the ID or column name
// using something very unusual to avoid this problem
// We can't use "-" as a separator as this can be present in the ID
// or column name, so we use something very unusual to avoid this problem
const JOINING_CHARACTER = "‽‽"
export const parseCellID = cellId => {
if (!cellId) {
return { id: undefined, field: undefined }
return { rowId: undefined, field: undefined }
}
const parts = cellId.split(JOINING_CHARACTER)
const field = parts.pop()
return { id: parts.join(JOINING_CHARACTER), field }
return { rowId: parts.join(JOINING_CHARACTER), field }
}
export const getCellID = (rowId, fieldName) => {

View File

@ -1,23 +1,26 @@
<script>
import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils"
import { NewRowID } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { NewRowID } from "../lib/constants"
const {
rows,
focusedCellId,
visibleColumns,
focusedRow,
stickyColumn,
rowLookupMap,
focusedCellAPI,
clipboard,
dispatch,
selectedRows,
selectedRowCount,
config,
menu,
gridFocused,
keyboardBlocked,
selectedCellCount,
selectedCells,
cellSelection,
columnLookupMap,
focusedRowId,
} = getContext("grid")
const ignoredOriginSelectors = [
@ -43,23 +46,51 @@
}
}
// Handle certain key presses regardless of selection state
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && $config.canAddRows) {
// Sugar for preventing default
const handle = fn => {
e.preventDefault()
dispatch("add-row-inline")
return
fn()
}
// If nothing selected avoid processing further key presses
// Handle certain key presses regardless of selection state
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "c":
return handle(() => dispatch("copy"))
case "v":
return handle(() => dispatch("paste"))
case "Enter":
return handle(() => {
if ($config.canAddRows) {
dispatch("add-row-inline")
}
})
}
}
// Handle certain key presses if we have cells selected
if ($selectedCellCount) {
switch (e.key) {
case "Escape":
return handle(selectedCells.actions.clear)
case "Delete":
case "Backspace":
return handle(() => dispatch("request-bulk-delete"))
}
}
// Handle certain key presses only if no cell focused
if (!$focusedCellId) {
if (e.key === "Tab" || e.key?.startsWith("Arrow")) {
e.preventDefault()
focusFirstCell()
handle(focusFirstCell)
} else if (e.key === "Delete" || e.key === "Backspace") {
if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete")
}
handle(() => {
if ($selectedRowCount && $config.canDeleteRows) {
dispatch("request-bulk-delete")
}
})
}
// Avoid processing anything else
return
}
@ -69,18 +100,19 @@
// By setting a tiny timeout here we can ensure that other listeners
// which depend on being able to read cell state on an escape keypress
// get a chance to observe the true state before we blur
if (api?.isActive()) {
setTimeout(api?.blur, 10)
} else {
$focusedCellId = null
}
menu.actions.close()
return
return handle(() => {
if (api?.isActive()) {
setTimeout(api?.blur, 10)
} else {
$focusedCellId = null
}
menu.actions.close()
})
} else if (e.key === "Tab") {
e.preventDefault()
api?.blur?.()
changeFocusedColumn(1)
return
return handle(() => {
api?.blur?.()
changeFocusedColumn(1)
})
}
// Pass the key event to the selected cell and let it decide whether to
@ -91,57 +123,33 @@
return
}
}
e.preventDefault()
// Handle the key ourselves
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "c":
clipboard.actions.copy()
break
case "v":
if (!api?.isReadonly()) {
clipboard.actions.paste()
}
break
case "Enter":
if ($config.canAddRows) {
dispatch("add-row-inline")
}
}
//
} else {
switch (e.key) {
case "ArrowLeft":
changeFocusedColumn(-1)
break
return handle(() => changeFocusedColumn(-1, e.shiftKey))
case "ArrowRight":
changeFocusedColumn(1)
break
return handle(() => changeFocusedColumn(1, e.shiftKey))
case "ArrowUp":
changeFocusedRow(-1)
break
return handle(() => changeFocusedRow(-1, e.shiftKey))
case "ArrowDown":
changeFocusedRow(1)
break
return handle(() => changeFocusedRow(1, e.shiftKey))
case "Delete":
case "Backspace":
if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete")
} else {
deleteSelectedCell()
}
break
return handle(() => {
if ($selectedRowCount && $config.canDeleteRows) {
dispatch("request-bulk-delete")
} else {
deleteSelectedCell()
}
})
case "Enter":
focusCell()
break
case " ":
case "Space":
if ($config.canDeleteRows) {
toggleSelectRow()
}
break
return handle(focusCell)
default:
startEnteringValue(e.key, e.which)
return handle(() => startEnteringValue(e.key, e.which))
}
}
}
@ -152,7 +160,7 @@
if (!firstRow) {
return
}
const firstColumn = $stickyColumn || $visibleColumns[0]
const firstColumn = $visibleColumns[0]
if (!firstColumn) {
return
}
@ -160,38 +168,87 @@
}
// Changes the focused cell by moving it left or right to a different column
const changeFocusedColumn = delta => {
if (!$focusedCellId) {
const changeFocusedColumn = (delta, shiftKey) => {
// Determine which cell we are working with
let sourceCellId = $focusedCellId
if (shiftKey && $selectedCellCount) {
sourceCellId = $cellSelection.targetCellId
}
if (!sourceCellId) {
return
}
const cols = $visibleColumns
const { id, field: columnName } = parseCellID($focusedCellId)
let newColumnName
if (columnName === $stickyColumn?.name) {
const index = delta - 1
newColumnName = cols[index]?.name
} else {
const index = cols.findIndex(col => col.name === columnName) + delta
if (index === -1) {
newColumnName = $stickyColumn?.name
} else {
newColumnName = cols[index]?.name
}
// Determine the new position for this cell
const { rowId, field } = parseCellID(sourceCellId)
const colIdx = $columnLookupMap[field].__idx
const nextColumn = $visibleColumns[colIdx + delta]
if (!nextColumn) {
return
}
if (newColumnName) {
$focusedCellId = getCellID(id, newColumnName)
const targetCellId = getCellID(rowId, nextColumn.name)
// Apply change
if (shiftKey) {
if ($selectedCellCount) {
// We have selected cells and still are holding shift - update selection
selectedCells.actions.updateTarget(targetCellId)
// Restore focused cell if this removes the selection
if (!$selectedCellCount) {
focusedCellId.set(targetCellId)
}
} else {
// We have no selection but are holding shift - select these cells
selectedCells.actions.selectRange(sourceCellId, targetCellId)
}
} else {
// We aren't holding shift - just focus this cell
focusedCellId.set(targetCellId)
}
}
// Changes the focused cell by moving it up or down to a new row
const changeFocusedRow = delta => {
if (!$focusedRow) {
const changeFocusedRow = (delta, shiftKey) => {
// Ignore for new row component
if ($focusedRowId === NewRowID) {
return
}
const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) {
const { field } = parseCellID($focusedCellId)
$focusedCellId = getCellID(newRow._id, field)
// Determine which cell we are working with
let sourceCellId = $focusedCellId
if (shiftKey && $selectedCellCount) {
sourceCellId = $cellSelection.targetCellId
}
if (!sourceCellId) {
return
}
// Determine the new position for this cell
const { rowId, field } = parseCellID(sourceCellId)
const rowIdx = $rowLookupMap[rowId].__idx
const newRow = $rows[rowIdx + delta]
if (!newRow) {
return
}
const targetCellId = getCellID(newRow._id, field)
// Apply change
if (shiftKey) {
if ($selectedCellCount) {
// We have selected cells and still are holding shift - update selection
selectedCells.actions.updateTarget(targetCellId)
// Restore focused cell if this removes the selection
if (!$selectedCellCount) {
focusedCellId.set(targetCellId)
}
} else {
// We have no selection but are holding shift - select these cells
selectedCells.actions.selectRange(sourceCellId, targetCellId)
}
} else {
// We aren't holding shift - just focus this cell
focusedCellId.set(targetCellId)
}
}
@ -234,14 +291,6 @@
}
}
const toggleSelectRow = () => {
const id = $focusedRow?._id
if (!id || id === NewRowID) {
return
}
selectedRows.actions.toggleRow(id)
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
return () => {

View File

@ -9,17 +9,17 @@
focusedRow,
menu,
rows,
columns,
focusedCellId,
stickyColumn,
config,
copiedCell,
clipboard,
dispatch,
focusedCellAPI,
focusedRowId,
notifications,
hasBudibaseIdentifiers,
selectedRowCount,
copyAllowed,
pasteAllowed,
selectedCellCount,
visibleColumns,
selectedCells,
} = getContext("grid")
let anchor
@ -32,17 +32,20 @@
}
const deleteRow = () => {
rows.actions.deleteRows([$focusedRow])
menu.actions.close()
rows.actions.deleteRows([$focusedRow])
$notifications.success("Deleted 1 row")
}
const duplicate = async () => {
const duplicateRow = async () => {
menu.actions.close()
const newRow = await rows.actions.duplicateRow($focusedRow)
if (newRow) {
const column = $stickyColumn?.name || $columns[0].name
$focusedCellId = getCellID(newRow._id, column)
const firstCol = $visibleColumns[0]
const lastCol = $visibleColumns[$visibleColumns.length - 1]
const startCellId = getCellID(newRow._id, firstCol.name)
const endCellId = getCellID(newRow._id, lastCol.name)
selectedCells.actions.selectRange(startCellId, endCellId)
}
}
@ -58,59 +61,107 @@
{#key style}
<GridPopover {anchor} on:close={menu.actions.close} maxHeight={null}>
<Menu>
<MenuItem
icon="Copy"
on:click={clipboard.actions.copy}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()}
on:click={clipboard.actions.paste}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Maximize"
disabled={isNewRow || !$config.canEditRows || !$config.canExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._id || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close}
>
Copy row _id
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._rev || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close}
>
Copy row _rev
</MenuItem>
<MenuItem
icon="Duplicate"
disabled={isNewRow || !$config.canAddRows}
on:click={duplicate}
>
Duplicate row
</MenuItem>
<MenuItem
icon="Delete"
disabled={isNewRow || !$config.canDeleteRows}
on:click={deleteRow}
>
Delete row
</MenuItem>
{#if $menu.multiRowMode}
<MenuItem
icon="Duplicate"
disabled={!$config.canAddRows || $selectedRowCount > 50}
on:click={() => dispatch("request-bulk-duplicate")}
on:click={menu.actions.close}
>
Duplicate {$selectedRowCount} rows
</MenuItem>
<MenuItem
icon="Delete"
disabled={!$config.canDeleteRows}
on:click={() => dispatch("request-bulk-delete")}
on:click={menu.actions.close}
>
Delete {$selectedRowCount} rows
</MenuItem>
{:else if $menu.multiCellMode}
<MenuItem
icon="Copy"
disabled={!$copyAllowed}
on:click={() => dispatch("copy")}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={!$pasteAllowed}
on:click={() => dispatch("paste")}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Delete"
disabled={!$config.canEditRows}
on:click={() => dispatch("request-bulk-delete")}
>
Delete {$selectedCellCount} cells
</MenuItem>
{:else}
<MenuItem
icon="Copy"
disabled={!$copyAllowed}
on:click={() => dispatch("copy")}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={!$pasteAllowed}
on:click={() => dispatch("paste")}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Maximize"
disabled={isNewRow ||
!$config.canEditRows ||
!$config.canExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._id || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close}
>
Copy row _id
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow ||
!$focusedRow?._rev ||
!$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close}
>
Copy row _rev
</MenuItem>
<MenuItem
icon="Duplicate"
disabled={isNewRow || !$config.canAddRows}
on:click={duplicateRow}
>
Duplicate row
</MenuItem>
<MenuItem
icon="Delete"
disabled={isNewRow || !$config.canDeleteRows}
on:click={deleteRow}
>
Delete row
</MenuItem>
{/if}
</Menu>
</GridPopover>
{/key}

View File

@ -1,37 +1,33 @@
<script>
import { getContext } from "svelte"
import GridScrollWrapper from "../layout/GridScrollWrapper.svelte"
import { DefaultRowHeight, GutterWidth } from "../lib/constants"
import { DefaultRowHeight } from "../lib/constants"
const {
isReordering,
reorder,
visibleColumns,
stickyColumn,
columnLookupMap,
rowHeight,
renderedRows,
scrollLeft,
stickyWidth,
} = getContext("grid")
$: targetColumn = $reorder.targetColumn
$: minLeft = GutterWidth + ($stickyColumn?.width || 0)
$: left = getLeft(targetColumn, $stickyColumn, $visibleColumns, $scrollLeft)
$: targetColumn = $columnLookupMap[$reorder.targetColumn]
$: insertAfter = $reorder.insertAfter
$: left = getLeft(targetColumn, insertAfter, $scrollLeft)
$: height = $rowHeight * $renderedRows.length + DefaultRowHeight
$: style = `left:${left}px; height:${height}px;`
$: visible = $isReordering && left >= minLeft
$: visible = $isReordering && left >= $stickyWidth
const getLeft = (targetColumn, stickyColumn, visibleColumns, scrollLeft) => {
let left = GutterWidth + (stickyColumn?.width || 0) - scrollLeft
// If this is not the sticky column, add additional left space
if (targetColumn !== stickyColumn?.name) {
const column = visibleColumns.find(x => x.name === targetColumn)
if (!column) {
return left
}
left += column.left + column.width
const getLeft = (targetColumn, insertAfter, scrollLeft) => {
if (!targetColumn) {
return 0
}
let left = targetColumn.__left - scrollLeft
if (insertAfter) {
left += targetColumn.width
}
return left
}
</script>

View File

@ -1,41 +1,28 @@
<script>
import { getContext } from "svelte"
import { GutterWidth } from "../lib/constants"
const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } =
const { resize, visibleColumns, isReordering, scrollLeft } =
getContext("grid")
$: offset = GutterWidth + ($stickyColumn?.width || 0)
$: activeColumn = $resize.column
const getStyle = (column, offset, scrollLeft) => {
const left = offset + column.left + column.width - scrollLeft
const getStyle = (column, scrollLeft) => {
let left = column.__left + column.width
if (!column.primaryDisplay) {
left -= scrollLeft
}
return `left:${left}px;`
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !$isReordering}
{#if $stickyColumn}
<div
class="resize-slider"
class:visible={activeColumn === $stickyColumn.name}
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
on:touchstart={e => resize.actions.startResizing($stickyColumn, e)}
on:dblclick={() => resize.actions.resetSize($stickyColumn)}
style="left:{GutterWidth + $stickyColumn.width}px;"
>
<div class="resize-indicator" />
</div>
{/if}
{#each $visibleColumns as column}
<div
class="resize-slider"
class:visible={activeColumn === column.name}
class:visible={$resize.column === column.name}
on:mousedown={e => resize.actions.startResizing(column, e)}
on:touchstart={e => resize.actions.startResizing(column, e)}
on:dblclick={() => resize.actions.resetSize(column)}
style={getStyle(column, offset, $scrollLeft)}
style={getStyle(column, $scrollLeft)}
>
<div class="resize-indicator" />
</div>

View File

@ -1,40 +1,237 @@
import { writable, get } from "svelte/store"
import { derived, writable, get } from "svelte/store"
import { Helpers } from "@budibase/bbui"
import { parseCellID, getCellID } from "../lib/utils"
import { NewRowID } from "../lib/constants"
export const createStores = () => {
const copiedCell = writable(null)
const clipboard = writable({
value: null,
multiCellCopy: false,
})
return {
clipboard,
}
}
export const deriveStores = context => {
const { clipboard, focusedCellAPI, selectedCellCount, config, focusedRowId } =
context
// Derive whether or not we're able to copy
const copyAllowed = derived(focusedCellAPI, $focusedCellAPI => {
return $focusedCellAPI != null
})
// Derive whether or not we're able to paste
const pasteAllowed = derived(
[clipboard, focusedCellAPI, selectedCellCount, config, focusedRowId],
([
$clipboard,
$focusedCellAPI,
$selectedCellCount,
$config,
$focusedRowId,
]) => {
if (
$clipboard.value == null ||
!$config.canEditRows ||
!$focusedCellAPI ||
$focusedRowId === NewRowID
) {
return false
}
// Prevent single-single pasting if the cell is readonly
const multiCellPaste = $selectedCellCount > 1
if (
!$clipboard.multiCellCopy &&
!multiCellPaste &&
$focusedCellAPI.isReadonly()
) {
return false
}
return true
}
)
return {
copiedCell,
copyAllowed,
pasteAllowed,
}
}
export const createActions = context => {
const { copiedCell, focusedCellAPI } = context
const {
clipboard,
focusedCellAPI,
copyAllowed,
pasteAllowed,
selectedCells,
selectedCellCount,
rowLookupMap,
rowChangeCache,
rows,
focusedCellId,
columnLookupMap,
visibleColumns,
} = context
// Copies the currently selected value (or values)
const copy = () => {
const value = get(focusedCellAPI)?.getValue()
copiedCell.set(value)
// Also copy a stringified version to the clipboard
let stringified = ""
if (value != null && value !== "") {
// Only conditionally stringify to avoid redundant quotes around text
stringified = typeof value === "object" ? JSON.stringify(value) : value
if (!get(copyAllowed)) {
return
}
const $selectedCells = get(selectedCells)
const $focusedCellAPI = get(focusedCellAPI)
const $selectedCellCount = get(selectedCellCount)
const multiCellCopy = $selectedCellCount > 1
// Multiple values to copy
if (multiCellCopy) {
const $rowLookupMap = get(rowLookupMap)
const $rowChangeCache = get(rowChangeCache)
// Extract value of each selected cell, accounting for the change cache
let value = []
for (let row of $selectedCells) {
const rowValues = []
for (let cellId of row) {
const { rowId, field } = parseCellID(cellId)
const row = {
...$rowLookupMap[rowId],
...$rowChangeCache[rowId],
}
rowValues.push(row[field])
}
value.push(rowValues)
}
// Update state
clipboard.set({
value,
multiCellCopy: true,
})
} else {
// Single value to copy
const value = $focusedCellAPI.getValue()
clipboard.set({
value,
multiCellCopy,
})
// Also copy a stringified version to the clipboard
let stringified = ""
if (value != null && value !== "") {
// Only conditionally stringify to avoid redundant quotes around text
stringified = typeof value === "object" ? JSON.stringify(value) : value
}
Helpers.copyToClipboard(stringified)
}
Helpers.copyToClipboard(stringified)
}
const paste = () => {
const $copiedCell = get(copiedCell)
const $focusedCellAPI = get(focusedCellAPI)
if ($copiedCell != null && $focusedCellAPI) {
$focusedCellAPI.setValue($copiedCell)
// Pastes the previously copied value(s) into the selected cell(s)
const paste = async progressCallback => {
if (!get(pasteAllowed)) {
return
}
const { value, multiCellCopy } = get(clipboard)
const multiCellPaste = get(selectedCellCount) > 1
// Choose paste strategy
if (multiCellCopy) {
if (multiCellPaste) {
// Multi to multi - try pasting into all selected cells
let newValue = value
// If we are pasting into more rows than we copied, but the number of
// columns match, then repeat the copied values as required
const $selectedCells = get(selectedCells)
const selectedRows = $selectedCells.length
const selectedColumns = $selectedCells[0].length
const copiedRows = value.length
const copiedColumns = value[0].length
if (selectedRows > copiedRows && selectedColumns === copiedColumns) {
newValue = []
for (let i = 0; i < selectedRows; i++) {
newValue.push(value[i % copiedRows])
}
}
// Paste the new value
await pasteIntoSelectedCells(newValue, progressCallback)
} else {
// Multi to single - expand to paste all values
// Get indices of focused cell
const $focusedCellId = get(focusedCellId)
const { rowId, field } = parseCellID($focusedCellId)
const $rowLookupMap = get(rowLookupMap)
const $columnLookupMap = get(columnLookupMap)
const rowIdx = $rowLookupMap[rowId].__idx
const colIdx = $columnLookupMap[field].__idx
// Get limits of how many rows and columns we're able to paste into
const $rows = get(rows)
const $visibleColumns = get(visibleColumns)
const colCount = $visibleColumns.length
const rowCount = $rows.length
const selectedRows = value.length
const selectedColumns = value[0].length
const rowExtent = Math.min(selectedRows, rowCount - rowIdx) - 1
const colExtent = Math.min(selectedColumns, colCount - colIdx) - 1
// Get the target cell ID (bottom right of our pastable extent)
const targetRowId = $rows[rowIdx + rowExtent]._id
const targetColName = $visibleColumns[colIdx + colExtent].name
const targetCellId = getCellID(targetRowId, targetColName)
// Paste into target cell range
if (targetCellId === $focusedCellId) {
// Single cell edge case
get(focusedCellAPI).setValue(value[0][0])
} else {
// Select the new cells to paste into, then paste
selectedCells.actions.selectRange($focusedCellId, targetCellId)
await pasteIntoSelectedCells(value, progressCallback)
}
}
} else {
if (multiCellPaste) {
// Single to multi - duplicate value to all selected cells
const newValue = get(selectedCells).map(row => row.map(() => value))
await pasteIntoSelectedCells(newValue, progressCallback)
} else {
// Single to single - just update the cell's value
get(focusedCellAPI).setValue(value)
}
}
}
// Paste the specified value into the currently selected cells
const pasteIntoSelectedCells = async (value, progressCallback) => {
const $selectedCells = get(selectedCells)
// Find the extent at which we can paste
const rowExtent = Math.min(value.length, $selectedCells.length)
const colExtent = Math.min(value[0].length, $selectedCells[0].length)
// Build change map
let changeMap = {}
for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) {
for (let colIdx = 0; colIdx < colExtent; colIdx++) {
const cellId = $selectedCells[rowIdx][colIdx]
const { rowId, field } = parseCellID(cellId)
if (!changeMap[rowId]) {
changeMap[rowId] = {}
}
changeMap[rowId][field] = value[rowIdx][colIdx]
}
}
await rows.actions.bulkUpdate(changeMap, progressCallback)
}
return {
clipboard: {
...clipboard,
actions: {
copy,
paste,

View File

@ -1,74 +1,73 @@
import { derived, get, writable } from "svelte/store"
import { GutterWidth, DefaultColumnWidth } from "../lib/constants"
import { DefaultColumnWidth, GutterWidth } from "../lib/constants"
export const createStores = () => {
const columns = writable([])
const stickyColumn = writable(null)
// Derive an enriched version of columns with left offsets and indexes
// automatically calculated
const enrichedColumns = derived(
columns,
$columns => {
let offset = 0
return $columns.map(column => {
const enriched = {
...column,
left: offset,
}
if (column.visible) {
offset += column.width
}
return enriched
})
},
[]
)
// Derived list of columns which have not been explicitly hidden
const visibleColumns = derived(
enrichedColumns,
$columns => {
return $columns.filter(col => col.visible)
},
[]
)
// Enrich columns with metadata about their display position
const enrichedColumns = derived(columns, $columns => {
let offset = GutterWidth
let idx = 0
return $columns.map(col => {
const enriched = {
...col,
__idx: idx,
__left: offset,
}
if (col.visible) {
idx++
offset += col.width
}
return enriched
})
})
return {
columns: {
...columns,
subscribe: enrichedColumns.subscribe,
},
stickyColumn,
visibleColumns,
}
}
export const deriveStores = context => {
const { columns, stickyColumn } = context
const { columns } = context
// Quick access to all columns
const allColumns = derived(
[columns, stickyColumn],
([$columns, $stickyColumn]) => {
let allCols = $columns || []
if ($stickyColumn) {
allCols = [...allCols, $stickyColumn]
}
return allCols
}
)
// Derive a lookup map for all columns by name
const columnLookupMap = derived(columns, $columns => {
let map = {}
$columns.forEach(column => {
map[column.name] = column
})
return map
})
// Derived list of columns which have not been explicitly hidden
const visibleColumns = derived(columns, $columns => {
return $columns.filter(col => col.visible)
})
// Split visible columns into their discrete types
const displayColumn = derived(visibleColumns, $visibleColumns => {
return $visibleColumns.find(col => col.primaryDisplay)
})
const scrollableColumns = derived(visibleColumns, $visibleColumns => {
return $visibleColumns.filter(col => !col.primaryDisplay)
})
// Derive if we have any normal columns
const hasNonAutoColumn = derived(allColumns, $allColumns => {
const normalCols = $allColumns.filter(column => {
const hasNonAutoColumn = derived(columns, $columns => {
const normalCols = $columns.filter(column => {
return !column.schema?.autocolumn
})
return normalCols.length > 0
})
return {
allColumns,
displayColumn,
columnLookupMap,
visibleColumns,
scrollableColumns,
hasNonAutoColumn,
}
}
@ -87,60 +86,57 @@ export const createActions = context => {
await datasource.actions.saveSchemaMutations()
}
// Checks if a column is readonly
const isReadonly = column => {
if (!column?.schema) {
return false
}
return (
column.schema.autocolumn ||
column.schema.disabled ||
column.schema.type === "formula" ||
column.schema.readonly
)
}
return {
columns: {
...columns,
actions: {
changeAllColumnWidths,
isReadonly,
},
},
}
}
export const initialise = context => {
const {
definition,
columns,
stickyColumn,
allColumns,
enrichedSchema,
compact,
} = context
const { definition, columns, displayColumn, enrichedSchema } = context
// Merge new schema fields with existing schema in order to preserve widths
const processColumns = $enrichedSchema => {
if (!$enrichedSchema) {
columns.set([])
stickyColumn.set(null)
return
}
const $definition = get(definition)
const $allColumns = get(allColumns)
const $stickyColumn = get(stickyColumn)
const $compact = get(compact)
const $columns = get(columns)
const $displayColumn = get(displayColumn)
// Find primary display
let primaryDisplay
const candidatePD = $definition.primaryDisplay || $stickyColumn?.name
const candidatePD = $definition.primaryDisplay || $displayColumn?.name
if (candidatePD && $enrichedSchema[candidatePD]) {
primaryDisplay = candidatePD
}
// Get field list
let fields = []
Object.keys($enrichedSchema).forEach(field => {
if ($compact || field !== primaryDisplay) {
fields.push(field)
}
})
// Update columns, removing extraneous columns and adding missing ones
columns.set(
fields
Object.keys($enrichedSchema)
.map(field => {
const fieldSchema = $enrichedSchema[field]
const oldColumn = $allColumns?.find(x => x.name === field)
return {
const oldColumn = $columns?.find(col => col.name === field)
let column = {
name: field,
label: fieldSchema.displayName || field,
schema: fieldSchema,
@ -148,19 +144,24 @@ export const initialise = context => {
visible: fieldSchema.visible ?? true,
readonly: fieldSchema.readonly,
order: fieldSchema.order ?? oldColumn?.order,
primaryDisplay: field === primaryDisplay,
}
// Override a few properties for primary display
if (field === primaryDisplay) {
column.visible = true
column.order = 0
column.primaryDisplay = true
}
return column
})
.sort((a, b) => {
// If we don't have a pinned column then primary display will be in
// the normal columns list, and should be first
// Display column should always come first
if (a.name === primaryDisplay) {
return -1
} else if (b.name === primaryDisplay) {
return 1
}
// Sort by order first
// Then sort by order
const orderA = a.order
const orderB = b.order
if (orderA != null && orderB != null) {
@ -180,29 +181,8 @@ export const initialise = context => {
return autoColA ? 1 : -1
})
)
// Update sticky column
if ($compact || !primaryDisplay) {
stickyColumn.set(null)
return
}
const stickySchema = $enrichedSchema[primaryDisplay]
const oldStickyColumn = $allColumns?.find(x => x.name === primaryDisplay)
stickyColumn.set({
name: primaryDisplay,
label: stickySchema.displayName || primaryDisplay,
schema: stickySchema,
width: stickySchema.width || oldStickyColumn?.width || DefaultColumnWidth,
visible: true,
order: 0,
left: GutterWidth,
primaryDisplay: true,
})
}
// Process columns when schema changes
enrichedSchema.subscribe(processColumns)
// Process columns when compact flag changes
compact.subscribe(() => processColumns(get(enrichedSchema)))
}

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store"
export const createActions = context => {
const { columns, stickyColumn, table, viewV2 } = context
const { columns, table, viewV2 } = context
const saveDefinition = async () => {
throw "This datasource does not support updating the definition"
@ -30,9 +30,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return $columns.some(col => col.name === name) || $sticky?.name === name
return get(columns).some(col => col.name === name)
}
return {

View File

@ -3,14 +3,17 @@ import { get } from "svelte/store"
const SuppressErrors = true
export const createActions = context => {
const { API, datasource, columns, stickyColumn } = context
const { API, datasource, columns } = context
const saveDefinition = async newDefinition => {
await API.saveTable(newDefinition)
}
const saveRow = async row => {
row.tableId = get(datasource)?.tableId
row = {
...row,
tableId: get(datasource)?.tableId,
}
return await API.saveRow(row, SuppressErrors)
}
@ -40,9 +43,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return $columns.some(col => col.name === name) || $sticky?.name === name
return get(columns).some(col => col.name === name)
}
return {

View File

@ -3,7 +3,7 @@ import { get } from "svelte/store"
const SuppressErrors = true
export const createActions = context => {
const { API, datasource, columns, stickyColumn } = context
const { API, datasource, columns } = context
const saveDefinition = async newDefinition => {
await API.viewV2.update(newDefinition)
@ -11,8 +11,11 @@ export const createActions = context => {
const saveRow = async row => {
const $datasource = get(datasource)
row.tableId = $datasource?.tableId
row._viewId = $datasource?.id
row = {
...row,
tableId: $datasource?.tableId,
_viewId: $datasource?.id,
}
return {
...(await API.saveRow(row, SuppressErrors)),
_viewId: row._viewId,
@ -37,12 +40,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return (
$columns.some(col => col.name === name && col.visible) ||
$sticky?.name === name
)
return get(columns).some(col => col.name === name && col.visible)
}
return {

View File

@ -25,23 +25,23 @@ const DependencyOrderedStores = [
Sort,
Filter,
Bounds,
Scroll,
Table,
ViewV2,
NonPlus,
Datasource,
Columns,
Scroll,
Validation,
Rows,
UI,
Validation,
Resize,
Viewport,
Reorder,
Users,
Menu,
Pagination,
Clipboard,
Config,
Clipboard,
Notifications,
Cache,
]

View File

@ -1,11 +1,13 @@
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import { parseCellID } from "../lib/utils"
export const createStores = () => {
const menu = writable({
x: 0,
y: 0,
left: 0,
top: 0,
visible: false,
selectedRow: null,
multiRowMode: false,
multiCellMode: false,
})
return {
menu,
@ -13,7 +15,15 @@ export const createStores = () => {
}
export const createActions = context => {
const { menu, focusedCellId, gridID } = context
const {
menu,
focusedCellId,
gridID,
selectedRows,
selectedRowCount,
selectedCellMap,
selectedCellCount,
} = context
const open = (cellId, e) => {
e.preventDefault()
@ -29,11 +39,35 @@ export const createActions = context => {
// Compute bounds of cell relative to outer data node
const targetBounds = e.target.getBoundingClientRect()
const dataBounds = dataNode.getBoundingClientRect()
focusedCellId.set(cellId)
// Check if there are multiple rows selected, and if this is one of them
let multiRowMode = false
if (get(selectedRowCount) > 1) {
const { rowId } = parseCellID(cellId)
if (get(selectedRows)[rowId]) {
multiRowMode = true
}
}
// Check if there are multiple cells selected, and if this is one of them
let multiCellMode = false
if (!multiRowMode && get(selectedCellCount) > 1) {
if (get(selectedCellMap)[cellId]) {
multiCellMode = true
}
}
// Only focus this cell if not in multi row mode
if (!multiRowMode && !multiCellMode) {
focusedCellId.set(cellId)
}
menu.set({
left: targetBounds.left - dataBounds.left + e.offsetX,
top: targetBounds.top - dataBounds.top + e.offsetY,
visible: true,
multiRowMode,
multiCellMode,
})
}

View File

@ -1,4 +1,4 @@
import { derived, get } from "svelte/store"
import { derived } from "svelte/store"
export const initialise = context => {
const { scrolledRowCount, rows, visualRowCapacity } = context
@ -15,8 +15,14 @@ export const initialise = context => {
)
// Fetch next page when fewer than 25 remaining rows to scroll
remainingRows.subscribe(remaining => {
if (remaining < 25 && get(rowCount)) {
const needsNewPage = derived(
[remainingRows, rowCount],
([$remainingRows, $rowCount]) => {
return $remainingRows < 25 && $rowCount
}
)
needsNewPage.subscribe($needsNewPage => {
if ($needsNewPage) {
rows.actions.loadNextPage()
}
})

View File

@ -4,10 +4,10 @@ import { parseEventLocation } from "../lib/utils"
const reorderInitialState = {
sourceColumn: null,
targetColumn: null,
insertAfter: false,
breakpoints: [],
gridLeft: 0,
width: 0,
latestX: 0,
increment: 0,
}
@ -28,38 +28,41 @@ export const createActions = context => {
const {
reorder,
columns,
visibleColumns,
columnLookupMap,
scrollableColumns,
scroll,
bounds,
stickyColumn,
maxScrollLeft,
width,
visibleColumns,
datasource,
stickyWidth,
width,
scrollLeft,
maxScrollLeft,
} = context
let latestX = 0
let autoScrollInterval
let isAutoScrolling
// Callback when dragging on a colum header and starting reordering
const startReordering = (column, e) => {
const $visibleColumns = get(visibleColumns)
const $scrollableColumns = get(scrollableColumns)
const $bounds = get(bounds)
const $stickyColumn = get(stickyColumn)
const $stickyWidth = get(stickyWidth)
// Generate new breakpoints for the current columns
let breakpoints = $visibleColumns.map(col => ({
x: col.left + col.width,
const breakpoints = $scrollableColumns.map(col => ({
x: col.__left - $stickyWidth,
column: col.name,
insertAfter: false,
}))
if ($stickyColumn) {
breakpoints.unshift({
x: 0,
column: $stickyColumn.name,
})
} else if (!$visibleColumns[0].primaryDisplay) {
breakpoints.unshift({
x: 0,
column: null,
// Add a very left breakpoint as well
const lastCol = $scrollableColumns[$scrollableColumns.length - 1]
if (lastCol) {
breakpoints.push({
x: lastCol.__left + lastCol.width - $stickyWidth,
column: lastCol.name,
insertAfter: true,
})
}
@ -87,24 +90,23 @@ export const createActions = context => {
const onReorderMouseMove = e => {
// Immediately handle the current position
const { x } = parseEventLocation(e)
reorder.update(state => ({
...state,
latestX: x,
}))
latestX = x
considerReorderPosition()
// Check if we need to start auto-scrolling
const $scrollLeft = get(scrollLeft)
const $maxScrollLeft = get(maxScrollLeft)
const $reorder = get(reorder)
const proximityCutoff = Math.min(140, get(width) / 6)
const speedFactor = 16
const rightProximity = Math.max(0, $reorder.gridLeft + $reorder.width - x)
const leftProximity = Math.max(0, x - $reorder.gridLeft)
if (rightProximity < proximityCutoff) {
if (rightProximity < proximityCutoff && $scrollLeft < $maxScrollLeft) {
const weight = proximityCutoff - rightProximity
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
startAutoScroll()
} else if (leftProximity < proximityCutoff) {
} else if (leftProximity < proximityCutoff && $scrollLeft > 0) {
const weight = -1 * (proximityCutoff - leftProximity)
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
@ -117,23 +119,28 @@ export const createActions = context => {
// Actual logic to consider the current position and determine the new order
const considerReorderPosition = () => {
const $reorder = get(reorder)
const $scroll = get(scroll)
const $scrollLeft = get(scrollLeft)
// Compute the closest breakpoint to the current position
let targetColumn
let breakpoint
let minDistance = Number.MAX_SAFE_INTEGER
const mouseX = $reorder.latestX - $reorder.gridLeft + $scroll.left
const mouseX = latestX - $reorder.gridLeft + $scrollLeft
$reorder.breakpoints.forEach(point => {
const distance = Math.abs(point.x - mouseX)
if (distance < minDistance) {
minDistance = distance
targetColumn = point.column
breakpoint = point
}
})
if (targetColumn !== $reorder.targetColumn) {
if (
breakpoint &&
(breakpoint.column !== $reorder.targetColumn ||
breakpoint.insertAfter !== $reorder.insertAfter)
) {
reorder.update(state => ({
...state,
targetColumn,
targetColumn: breakpoint.column,
insertAfter: breakpoint.insertAfter,
}))
}
}
@ -175,20 +182,29 @@ export const createActions = context => {
document.removeEventListener("touchcancel", stopReordering)
// Ensure there's actually a change before saving
const { sourceColumn, targetColumn } = get(reorder)
const { sourceColumn, targetColumn, insertAfter } = get(reorder)
reorder.set(reorderInitialState)
if (sourceColumn !== targetColumn) {
await moveColumn(sourceColumn, targetColumn)
await moveColumn({ sourceColumn, targetColumn, insertAfter })
}
}
// Moves a column after another columns.
// An undefined target column will move the source to index 0.
const moveColumn = async (sourceColumn, targetColumn) => {
let $columns = get(columns)
let sourceIdx = $columns.findIndex(x => x.name === sourceColumn)
let targetIdx = $columns.findIndex(x => x.name === targetColumn)
targetIdx++
const moveColumn = async ({
sourceColumn,
targetColumn,
insertAfter = false,
}) => {
// Find the indices in the overall columns array
const $columns = get(columns)
let sourceIdx = $columns.findIndex(col => col.name === sourceColumn)
let targetIdx = $columns.findIndex(col => col.name === targetColumn)
if (insertAfter) {
targetIdx++
}
// Reorder columns
columns.update(state => {
const removed = state.splice(sourceIdx, 1)
if (--targetIdx < sourceIdx) {
@ -209,18 +225,27 @@ export const createActions = context => {
// Moves a column one place left (as appears visually)
const moveColumnLeft = async column => {
const $visibleColumns = get(visibleColumns)
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
await moveColumn(column, $visibleColumns[sourceIdx - 2]?.name)
const $columnLookupMap = get(columnLookupMap)
const sourceIdx = $columnLookupMap[column].__idx
await moveColumn({
sourceColumn: column,
targetColumn: $visibleColumns[sourceIdx - 1]?.name,
})
}
// Moves a column one place right (as appears visually)
const moveColumnRight = async column => {
const $visibleColumns = get(visibleColumns)
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
const $columnLookupMap = get(columnLookupMap)
const sourceIdx = $columnLookupMap[column].__idx
if (sourceIdx === $visibleColumns.length - 1) {
return
}
await moveColumn(column, $visibleColumns[sourceIdx + 1]?.name)
await moveColumn({
sourceColumn: column,
targetColumn: $visibleColumns[sourceIdx + 1]?.name,
insertAfter: true,
})
}
return {

View File

@ -34,7 +34,7 @@ export const createActions = context => {
// Set initial store state
resize.set({
width: column.width,
left: column.left,
left: column.__left,
initialWidth: column.width,
initialMouseX: x,
column: column.name,

View File

@ -4,6 +4,7 @@ import { NewRowID, RowPageSize } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { tick } from "svelte"
import { Helpers } from "@budibase/bbui"
import { sleep } from "../../../utils/utils"
export const createStores = () => {
const rows = writable([])
@ -16,11 +17,23 @@ export const createStores = () => {
const error = writable(null)
const fetch = writable(null)
// Enrich rows with an index property and any pending changes
const enrichedRows = derived(
[rows, rowChangeCache],
([$rows, $rowChangeCache]) => {
return $rows.map((row, idx) => ({
...row,
...$rowChangeCache[row._id],
__idx: idx,
}))
}
)
// Generate a lookup map to quick find a row by ID
const rowLookupMap = derived(rows, $rows => {
const rowLookupMap = derived(enrichedRows, $enrichedRows => {
let map = {}
for (let i = 0; i < $rows.length; i++) {
map[$rows[i]._id] = i
for (let i = 0; i < $enrichedRows.length; i++) {
map[$enrichedRows[i]._id] = $enrichedRows[i]
}
return map
})
@ -35,18 +48,6 @@ export const createStores = () => {
}
})
// Enrich rows with an index property and any pending changes
const enrichedRows = derived(
[rows, rowChangeCache],
([$rows, $rowChangeCache]) => {
return $rows.map((row, idx) => ({
...row,
...$rowChangeCache[row._id],
__idx: idx,
}))
}
)
return {
rows: {
...rows,
@ -86,6 +87,7 @@ export const createActions = context => {
fetch,
hasBudibaseIdentifiers,
refreshing,
columnLookupMap,
} = context
const instanceLoaded = writable(false)
@ -188,12 +190,6 @@ export const createActions = context => {
fetch.set(newFetch)
})
// Gets a row by ID
const getRow = id => {
const index = get(rowLookupMap)[id]
return index >= 0 ? get(rows)[index] : null
}
// Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => {
@ -263,22 +259,15 @@ export const createActions = context => {
for (let column of missingColumns) {
get(notifications).error(`${column} is required but is missing`)
}
// Focus the first cell with an error
if (erroredColumns.length) {
focusedCellId.set(getCellID(rowId, erroredColumns[0]))
}
} else {
get(notifications).error(errorString || "An unknown error occurred")
}
}
// Adds a new row
const addRow = async (row, idx, bubble = false) => {
const addRow = async ({ row, idx, bubble = false, notify = true }) => {
try {
// Create row. Spread row so we can mutate and enrich safely.
let newRow = { ...row }
newRow = await datasource.actions.addRow(newRow)
const newRow = await datasource.actions.addRow(row)
// Update state
if (idx != null) {
@ -291,38 +280,94 @@ export const createActions = context => {
handleNewRows([newRow])
}
// Refresh row to ensure data is in the correct format
get(notifications).success("Row created successfully")
if (notify) {
get(notifications).success("Row created successfully")
}
return newRow
} catch (error) {
if (bubble) {
throw error
} else {
handleValidationError(NewRowID, error)
validation.actions.focusFirstRowError(NewRowID)
}
}
}
// Duplicates a row, inserting the duplicate row after the existing one
const duplicateRow = async row => {
let clone = { ...row }
let clone = cleanRow(row)
delete clone._id
delete clone._rev
delete clone.__idx
try {
return await addRow(clone, row.__idx + 1, true)
const duped = await addRow({
row: clone,
idx: row.__idx + 1,
bubble: true,
notify: false,
})
get(notifications).success("Duplicated 1 row")
return duped
} catch (error) {
handleValidationError(row._id, error)
validation.actions.focusFirstRowError(row._id)
}
}
// Duplicates multiple rows, inserting them after the last source row
const bulkDuplicate = async (rowsToDupe, progressCallback) => {
// Find index of last row
const $rowLookupMap = get(rowLookupMap)
const indices = rowsToDupe.map(row => $rowLookupMap[row._id]?.__idx)
const index = Math.max(...indices)
const count = rowsToDupe.length
// Clone and clean rows
const clones = rowsToDupe.map(row => {
let clone = cleanRow(row)
delete clone._id
delete clone._rev
return clone
})
// Create rows
let saved = []
let failed = 0
for (let i = 0; i < count; i++) {
try {
saved.push(await datasource.actions.addRow(clones[i]))
rowCacheMap[saved._id] = true
await sleep(50) // Small sleep to ensure we avoid rate limiting
} catch (error) {
failed++
console.error("Duplicating row failed", error)
}
progressCallback?.((i + 1) / count)
}
// Add to state
if (saved.length) {
rows.update(state => {
return state.toSpliced(index + 1, 0, ...saved)
})
}
// Notify user
if (failed) {
get(notifications).error(`Failed to duplicate ${failed} of ${count} rows`)
} else if (saved.length) {
get(notifications).success(`Duplicated ${saved.length} rows`)
}
return saved
}
// Replaces a row in state with the newly defined row, handling updates,
// addition and deletion
const replaceRow = (id, row) => {
// Get index of row to check if it exists
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[id]
const index = $rowLookupMap[id].__idx
// Process as either an update, addition or deletion
if (row) {
@ -371,10 +416,8 @@ export const createActions = context => {
// Patches a row with some changes in local state, and returns whether a
// valid pending change was made or not
const stashRowChanges = (rowId, changes) => {
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
const row = $rowLookupMap[rowId]
// Check this is a valid change
if (!row || !changesAreValid(row, changes)) {
@ -392,15 +435,20 @@ export const createActions = context => {
return true
}
// Saves any pending changes to a row
const applyRowChanges = async rowId => {
const $rows = get(rows)
// Saves any pending changes to a row, as well as any additional changes
// specified
const applyRowChanges = async ({
rowId,
changes = null,
updateState = true,
handleErrors = true,
}) => {
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
const row = $rowLookupMap[rowId]
if (row == null) {
return
}
let savedRow
// Save change
try {
@ -411,33 +459,38 @@ export const createActions = context => {
}))
// Update row
const changes = get(rowChangeCache)[rowId]
const newRow = { ...cleanRow(row), ...changes }
const saved = await datasource.actions.updateRow(newRow)
const stashedChanges = get(rowChangeCache)[rowId]
const newRow = { ...cleanRow(row), ...stashedChanges, ...changes }
savedRow = await datasource.actions.updateRow(newRow)
// Update row state after a successful change
if (saved?._id) {
rows.update(state => {
state[index] = saved
return state.slice()
})
} else if (saved?.id) {
if (savedRow?._id) {
if (updateState) {
rows.update(state => {
state[row.__idx] = savedRow
return state.slice()
})
}
} else if (savedRow?.id) {
// Handle users table edge case
await refreshRow(saved.id)
await refreshRow(savedRow.id)
}
// Wipe row change cache for any values which have been saved
const liveChanges = get(rowChangeCache)[rowId]
rowChangeCache.update(state => {
Object.keys(changes || {}).forEach(key => {
if (changes[key] === liveChanges?.[key]) {
Object.keys(stashedChanges || {}).forEach(key => {
if (stashedChanges[key] === liveChanges?.[key]) {
delete state[rowId][key]
}
})
return state
})
} catch (error) {
handleValidationError(rowId, error)
if (handleErrors) {
handleValidationError(rowId, error)
validation.actions.focusFirstRowError(rowId)
}
}
// Decrement change count for this row
@ -445,13 +498,82 @@ export const createActions = context => {
...state,
[rowId]: (state[rowId] || 1) - 1,
}))
return savedRow
}
// Updates a value of a row
const updateValue = async ({ rowId, column, value, apply = true }) => {
const success = stashRowChanges(rowId, { [column]: value })
if (success && apply) {
await applyRowChanges(rowId)
await applyRowChanges({ rowId })
}
}
const bulkUpdate = async (changeMap, progressCallback) => {
const rowIds = Object.keys(changeMap || {})
const count = rowIds.length
if (!count) {
return
}
// Update rows
const $columnLookupMap = get(columnLookupMap)
let updated = []
let failed = 0
for (let i = 0; i < count; i++) {
const rowId = rowIds[i]
let changes = changeMap[rowId] || {}
// Strip any readonly fields from the change set
for (let field of Object.keys(changes)) {
const column = $columnLookupMap[field]
if (columns.actions.isReadonly(column)) {
delete changes[field]
}
}
if (!Object.keys(changes).length) {
progressCallback?.((i + 1) / count)
continue
}
try {
const updatedRow = await applyRowChanges({
rowId,
changes: changeMap[rowId],
updateState: false,
handleErrors: false,
})
if (updatedRow) {
updated.push(updatedRow)
} else {
failed++
}
await sleep(50) // Small sleep to ensure we avoid rate limiting
} catch (error) {
failed++
console.error("Failed to update row", error)
}
progressCallback?.((i + 1) / count)
}
// Update state
if (updated.length) {
const $rowLookupMap = get(rowLookupMap)
rows.update(state => {
for (let row of updated) {
const index = $rowLookupMap[row._id].__idx
state[index] = row
}
return state.slice()
})
}
// Notify user
if (failed) {
const unit = `row${count === 1 ? "" : "s"}`
get(notifications).error(`Failed to update ${failed} of ${count} ${unit}`)
} else if (updated.length) {
const unit = `row${updated.length === 1 ? "" : "s"}`
get(notifications).success(`Updated ${updated.length} ${unit}`)
}
}
@ -516,14 +638,6 @@ export const createActions = context => {
get(fetch)?.nextPage()
}
// Checks if we have a row with a certain ID
const hasRow = id => {
if (id === NewRowID) {
return true
}
return get(rowLookupMap)[id] != null
}
// Cleans a row by removing any internal grid metadata from it.
// Call this before passing a row to any sort of external flow.
const cleanRow = row => {
@ -541,16 +655,16 @@ export const createActions = context => {
actions: {
addRow,
duplicateRow,
getRow,
bulkDuplicate,
updateValue,
applyRowChanges,
deleteRows,
hasRow,
loadNextPage,
refreshRow,
replaceRow,
refreshData,
cleanRow,
bulkUpdate,
},
},
}
@ -569,10 +683,12 @@ export const initialise = context => {
// Wipe the row change cache when changing row
previousFocusedRowId.subscribe(id => {
if (id && !get(inProgressChanges)[id]) {
rowChangeCache.update(state => {
delete state[id]
return state
})
if (Object.keys(get(rowChangeCache)[id] || {}).length) {
rowChangeCache.update(state => {
delete state[id]
return state
})
}
}
})
@ -581,12 +697,12 @@ export const initialise = context => {
if (!id) {
return
}
const { id: rowId, field } = parseCellID(id)
const { rowId, field } = parseCellID(id)
const hasChanges = field in (get(rowChangeCache)[rowId] || {})
const hasErrors = validation.actions.rowHasErrors(rowId)
const isSavingChanges = get(inProgressChanges)[rowId]
if (rowId && !hasErrors && hasChanges && !isSavingChanges) {
await rows.actions.applyRowChanges(rowId)
await rows.actions.applyRowChanges({ rowId })
}
})
}

View File

@ -16,8 +16,8 @@ export const createStores = () => {
})
// Derive height and width as primitives to avoid wasted computation
const scrollTop = derived(scroll, $scroll => $scroll.top, 0)
const scrollLeft = derived(scroll, $scroll => $scroll.left, 0)
const scrollTop = derived(scroll, $scroll => Math.round($scroll.top))
const scrollLeft = derived(scroll, $scroll => Math.round($scroll.left))
return {
scroll,
@ -30,7 +30,7 @@ export const deriveStores = context => {
const {
rows,
visibleColumns,
stickyColumn,
displayColumn,
rowHeight,
width,
height,
@ -38,31 +38,32 @@ export const deriveStores = context => {
} = context
// Memoize store primitives
const stickyColumnWidth = derived(stickyColumn, $col => $col?.width || 0, 0)
const stickyWidth = derived(displayColumn, $displayColumn => {
return ($displayColumn?.width || 0) + GutterWidth
})
// Derive horizontal limits
const contentWidth = derived(
[visibleColumns, stickyColumnWidth, buttonColumnWidth],
([$visibleColumns, $stickyColumnWidth, $buttonColumnWidth]) => {
let width = GutterWidth + $buttonColumnWidth + $stickyColumnWidth
[visibleColumns, buttonColumnWidth],
([$visibleColumns, $buttonColumnWidth]) => {
let width = GutterWidth + Math.max($buttonColumnWidth, HPadding)
$visibleColumns.forEach(col => {
width += col.width
})
return width + HPadding
},
0
return width
}
)
const screenWidth = derived(
[width, stickyColumnWidth],
([$width, $stickyColumnWidth]) => $width + GutterWidth + $stickyColumnWidth,
0
[width, stickyWidth],
([$width, $stickyWidth]) => {
return $width + $stickyWidth
}
)
const maxScrollLeft = derived(
[contentWidth, screenWidth],
([$contentWidth, $screenWidth]) => {
return Math.max($contentWidth - $screenWidth, 0)
},
0
return Math.round(Math.max($contentWidth - $screenWidth, 0))
}
)
const showHScrollbar = derived(
[contentWidth, screenWidth],
@ -80,13 +81,12 @@ export const deriveStores = context => {
height += ScrollBarSize * 2
}
return height
},
0
}
)
const maxScrollTop = derived(
[height, contentHeight],
([$height, $contentHeight]) => Math.max($contentHeight - $height, 0),
0
([$height, $contentHeight]) =>
Math.round(Math.max($contentHeight - $height, 0))
)
const showVScrollbar = derived(
[contentHeight, height],
@ -96,6 +96,7 @@ export const deriveStores = context => {
)
return {
stickyWidth,
contentHeight,
contentWidth,
screenWidth,
@ -113,12 +114,13 @@ export const initialise = context => {
scroll,
bounds,
rowHeight,
visibleColumns,
stickyWidth,
scrollTop,
maxScrollTop,
scrollLeft,
maxScrollLeft,
buttonColumnWidth,
columnLookupMap,
} = context
// Ensure scroll state never goes invalid, which can happen when changing
@ -186,15 +188,16 @@ export const initialise = context => {
// Ensure horizontal position is viewable
// Check horizontal position of columns next
const $visibleColumns = get(visibleColumns)
const { field: columnName } = parseCellID($focusedCellId)
const column = $visibleColumns.find(col => col.name === columnName)
if (!column) {
const { field } = parseCellID($focusedCellId)
const column = get(columnLookupMap)[field]
if (!column || column.primaryDisplay) {
return
}
// Ensure column is not cutoff on left edge
let delta = $scroll.left - column.left + FocusedCellMinOffset
const $stickyWidth = get(stickyWidth)
let delta =
$scroll.left - column.__left + FocusedCellMinOffset + $stickyWidth
if (delta > 0) {
scroll.update(state => ({
...state,
@ -205,10 +208,10 @@ export const initialise = context => {
// Ensure column is not cutoff on right edge
else {
const $buttonColumnWidth = get(buttonColumnWidth)
const rightEdge = column.left + column.width
const rightEdge = column.__left + column.width
const rightBound =
$bounds.width + $scroll.left - FocusedCellMinOffset - $buttonColumnWidth
delta = rightEdge - rightBound
delta = rightEdge - rightBound - $stickyWidth
if (delta > 0) {
scroll.update(state => ({
...state,

View File

@ -2,12 +2,11 @@ import { writable, get, derived } from "svelte/store"
import { tick } from "svelte"
import {
DefaultRowHeight,
GutterWidth,
LargeRowHeight,
MediumRowHeight,
NewRowID,
} from "../lib/constants"
import { parseCellID } from "../lib/utils"
import { getCellID, parseCellID } from "../lib/utils"
export const createStores = context => {
const { props } = context
@ -22,34 +21,15 @@ export const createStores = context => {
const keyboardBlocked = writable(false)
const isDragging = writable(false)
const buttonColumnWidth = writable(0)
// Derive the current focused row ID
const focusedRowId = derived(
focusedCellId,
$focusedCellId => {
return parseCellID($focusedCellId)?.id
},
null
)
// Toggles whether a certain row ID is selected or not
const toggleSelectedRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
const cellSelection = writable({
active: false,
sourceCellId: null,
targetCellId: null,
})
return {
focusedCellId,
focusedCellAPI,
focusedRowId,
previousFocusedRowId,
previousFocusedCellId,
hoveredRowId,
@ -58,35 +38,38 @@ export const createStores = context => {
keyboardBlocked,
isDragging,
buttonColumnWidth,
selectedRows: {
...selectedRows,
actions: {
toggleRow: toggleSelectedRow,
},
},
selectedRows,
cellSelection,
}
}
export const deriveStores = context => {
const { focusedCellId, rows, rowLookupMap, rowHeight, stickyColumn, width } =
context
const {
focusedCellId,
rows,
rowLookupMap,
rowHeight,
width,
selectedRows,
cellSelection,
columnLookupMap,
visibleColumns,
} = context
// Derive the current focused row ID
const focusedRowId = derived(focusedCellId, $focusedCellId => {
return parseCellID($focusedCellId).rowId
})
// Derive the row that contains the selected cell
const focusedRow = derived(
[focusedCellId, rowLookupMap, rows],
([$focusedCellId, $rowLookupMap, $rows]) => {
const rowId = parseCellID($focusedCellId)?.id
// Edge case for new rows
if (rowId === NewRowID) {
[focusedRowId, rowLookupMap],
([$focusedRowId, $rowLookupMap]) => {
if ($focusedRowId === NewRowID) {
return { _id: NewRowID }
}
// All normal rows
const index = $rowLookupMap[rowId]
return $rows[index]
},
null
return $rowLookupMap[$focusedRowId]
}
)
// Derive the amount of content lines to show in cells depending on row height
@ -100,24 +83,200 @@ export const deriveStores = context => {
})
// Derive whether we should use the compact UI, depending on width
const compact = derived([stickyColumn, width], ([$stickyColumn, $width]) => {
return ($stickyColumn?.width || 0) + $width + GutterWidth < 800
const compact = derived(width, $width => {
return $width < 600
})
// Derive we have any selected rows or not
const selectedRowCount = derived(selectedRows, $selectedRows => {
return Object.keys($selectedRows).length
})
// Derive whether or not we're actively selecting cells
const isSelectingCells = derived(cellSelection, $cellSelection => {
return $cellSelection.active
})
// Derive the full extent of all selected cells
const selectedCells = derived(
[cellSelection, rowLookupMap, columnLookupMap],
([$cellSelection, $rowLookupMap, $columnLookupMap]) => {
const { sourceCellId, targetCellId } = $cellSelection
if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) {
return []
}
const $rows = get(rows)
const $visibleColumns = get(visibleColumns)
// Get source and target row and column indices
const sourceInfo = parseCellID(sourceCellId)
const targetInfo = parseCellID(targetCellId)
if (sourceInfo.rowId === NewRowID) {
return []
}
// Row indices
const sourceRowIndex = $rowLookupMap[sourceInfo.rowId]?.__idx
const targetRowIndex = $rowLookupMap[targetInfo.rowId]?.__idx
if (sourceRowIndex == null || targetRowIndex == null) {
return []
}
const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex)
let upperRowIndex = Math.max(sourceRowIndex, targetRowIndex)
// Cap rows at 50
upperRowIndex = Math.min(upperRowIndex, lowerRowIndex + 49)
// Column indices
const sourceColIndex = $columnLookupMap[sourceInfo.field].__idx
const targetColIndex = $columnLookupMap[targetInfo.field].__idx
const lowerColIndex = Math.min(sourceColIndex, targetColIndex)
const upperColIndex = Math.max(sourceColIndex, targetColIndex)
// Build 2 dimensional array of all cells inside these bounds
let cells = []
let rowId, colName
for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) {
let rowCells = []
for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) {
rowId = $rows[rowIdx]._id
colName = $visibleColumns[colIdx].name
rowCells.push(getCellID(rowId, colName))
}
cells.push(rowCells)
}
return cells
}
)
// Derive a quick lookup map of the selected cells
const selectedCellMap = derived(selectedCells, $selectedCells => {
let map = {}
for (let row of $selectedCells) {
for (let cell of row) {
map[cell] = true
}
}
return map
})
// Derive the count of the selected cells
const selectedCellCount = derived(selectedCellMap, $selectedCellMap => {
return Object.keys($selectedCellMap).length
})
return {
focusedRowId,
focusedRow,
contentLines,
compact,
selectedRowCount,
isSelectingCells,
selectedCells,
selectedCellMap,
selectedCellCount,
}
}
export const createActions = context => {
const { focusedCellId, hoveredRowId } = context
const {
focusedCellId,
hoveredRowId,
selectedRows,
rowLookupMap,
rows,
selectedRowCount,
cellSelection,
selectedCells,
} = context
// Keep the last selected index to use with bulk selection
let lastSelectedIndex = null
// Callback when leaving the grid, deselecting all focussed or selected items
const blur = () => {
focusedCellId.set(null)
hoveredRowId.set(null)
clearCellSelection()
}
// Toggles whether a certain row ID is selected or not
const toggleSelectedRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
} else {
lastSelectedIndex = get(rowLookupMap)[id].__idx
}
return newState
})
}
const bulkSelectRows = id => {
if (!get(selectedRowCount)) {
toggleSelectedRow(id)
return
}
if (lastSelectedIndex == null) {
return
}
const thisIndex = get(rowLookupMap)[id].__idx
// Skip if indices are the same
if (lastSelectedIndex === thisIndex) {
return
}
const from = Math.min(lastSelectedIndex, thisIndex)
const to = Math.max(lastSelectedIndex, thisIndex)
const $rows = get(rows)
selectedRows.update(state => {
for (let i = from; i <= to; i++) {
state[$rows[i]._id] = true
}
return state
})
}
const startCellSelection = sourceCellId => {
cellSelection.set({
active: true,
sourceCellId,
targetCellId: sourceCellId,
})
}
const updateCellSelection = targetCellId => {
cellSelection.update(state => ({
...state,
targetCellId,
}))
}
const stopCellSelection = () => {
cellSelection.update(state => ({
...state,
active: false,
}))
}
const selectCellRange = (source, target) => {
cellSelection.set({
active: false,
sourceCellId: source,
targetCellId: target,
})
}
const clearCellSelection = () => {
cellSelection.set({
active: false,
sourceCellId: null,
targetCellId: null,
})
}
return {
@ -126,6 +285,23 @@ export const createActions = context => {
blur,
},
},
selectedRows: {
...selectedRows,
actions: {
toggleRow: toggleSelectedRow,
bulkSelectRows,
},
},
selectedCells: {
...selectedCells,
actions: {
startSelecting: startCellSelection,
updateTarget: updateCellSelection,
stopSelecting: stopCellSelection,
selectRange: selectCellRange,
clear: clearCellSelection,
},
},
}
}
@ -134,28 +310,32 @@ export const initialise = context => {
focusedRowId,
previousFocusedRowId,
previousFocusedCellId,
rows,
rowLookupMap,
focusedCellId,
selectedRows,
hoveredRowId,
definition,
rowHeight,
fixedRowHeight,
selectedRowCount,
menu,
selectedCellCount,
selectedCells,
cellSelection,
} = context
// Ensure we clear invalid rows from state if they disappear
rows.subscribe(async () => {
rowLookupMap.subscribe(async $rowLookupMap => {
// We tick here to ensure other derived stores have properly updated.
// We depend on the row lookup map which is a derived store,
await tick()
const $focusedCellId = get(focusedCellId)
const $focusedRowId = get(focusedRowId)
const $selectedRows = get(selectedRows)
const $hoveredRowId = get(hoveredRowId)
const hasRow = rows.actions.hasRow
const hasRow = id => $rowLookupMap[id] != null
// Check selected cell
const selectedRowId = parseCellID($focusedCellId)?.id
if (selectedRowId && !hasRow(selectedRowId)) {
// Check focused cell
if ($focusedRowId && !hasRow($focusedRowId)) {
focusedCellId.set(null)
}
@ -165,17 +345,19 @@ export const initialise = context => {
}
// Check selected rows
let newSelectedRows = { ...$selectedRows }
let selectedRowsNeedsUpdate = false
const selectedIds = Object.keys($selectedRows)
for (let i = 0; i < selectedIds.length; i++) {
if (!hasRow(selectedIds[i])) {
delete newSelectedRows[selectedIds[i]]
selectedRowsNeedsUpdate = true
if (selectedIds.length) {
let newSelectedRows = { ...$selectedRows }
let selectedRowsNeedsUpdate = false
for (let i = 0; i < selectedIds.length; i++) {
if (!hasRow(selectedIds[i])) {
delete newSelectedRows[selectedIds[i]]
selectedRowsNeedsUpdate = true
}
}
if (selectedRowsNeedsUpdate) {
selectedRows.set(newSelectedRows)
}
}
if (selectedRowsNeedsUpdate) {
selectedRows.set(newSelectedRows)
}
})
@ -186,18 +368,29 @@ export const initialise = context => {
lastFocusedRowId = id
})
// Remember the last focused cell ID so that we can store the previous one
let lastFocusedCellId = null
focusedCellId.subscribe(id => {
// Remember the last focused cell ID so that we can store the previous one
previousFocusedCellId.set(lastFocusedCellId)
lastFocusedCellId = id
})
// Remove hovered row when a cell is selected
focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) {
// Remove hovered row when a cell is selected
if (id && get(hoveredRowId)) {
hoveredRowId.set(null)
}
// Clear row selection when focusing a cell
if (id && get(selectedRowCount)) {
selectedRows.set({})
}
// Clear cell selection when focusing a cell
if (id && get(selectedCellCount)) {
selectedCells.actions.clear()
}
// Close the menu if it was open
menu.actions.close()
})
// Pull row height from table as long as we don't have a fixed height
@ -215,4 +408,36 @@ export const initialise = context => {
rowHeight.set(get(definition)?.rowHeight || DefaultRowHeight)
}
})
// Clear focused cell when selecting rows
selectedRowCount.subscribe(count => {
if (count) {
if (get(focusedCellId)) {
focusedCellId.set(null)
}
if (get(selectedCellCount)) {
selectedCells.actions.clear()
}
}
})
// Clear state when selecting cells
selectedCellCount.subscribe($selectedCellCount => {
if ($selectedCellCount) {
if (get(selectedRowCount)) {
selectedRows.set({})
}
}
})
// Ensure the source of cell selection is focused
cellSelection.subscribe(async ({ sourceCellId, targetCellId }) => {
if (
sourceCellId &&
sourceCellId !== targetCellId &&
get(focusedCellId) !== sourceCellId
) {
focusedCellId.set(sourceCellId)
}
})
}

View File

@ -25,7 +25,7 @@ export const deriveStores = context => {
// Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside cells extremely fast
const selectedCellMap = derived(
const userCellMap = derived(
[users, focusedCellId],
([$users, $focusedCellId]) => {
let map = {}
@ -40,7 +40,7 @@ export const deriveStores = context => {
)
return {
selectedCellMap,
userCellMap,
}
}

View File

@ -1,5 +1,5 @@
import { writable, get, derived } from "svelte/store"
import { getCellID, parseCellID } from "../lib/utils"
import { parseCellID } from "../lib/utils"
// Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into
@ -7,18 +7,38 @@ import { getCellID, parseCellID } from "../lib/utils"
export const createStores = () => {
const validation = writable({})
return {
validation,
}
}
export const deriveStores = context => {
const { validation } = context
// Derive which rows have errors so that we can use that info later
const rowErrorMap = derived(validation, $validation => {
const validationRowLookupMap = derived(validation, $validation => {
let map = {}
Object.entries($validation).forEach(([key, error]) => {
// Extract row ID from all errored cell IDs
if (error) {
map[parseCellID(key).id] = true
const { rowId } = parseCellID(key)
if (!map[rowId]) {
map[rowId] = []
}
map[rowId].push(key)
}
})
return map
})
return {
validationRowLookupMap,
}
}
export const createActions = context => {
const { validation, focusedCellId, validationRowLookupMap } = context
const setError = (cellId, error) => {
if (!cellId) {
return
@ -30,7 +50,15 @@ export const createStores = () => {
}
const rowHasErrors = rowId => {
return get(rowErrorMap)[rowId]
return get(validationRowLookupMap)[rowId]?.length > 0
}
const focusFirstRowError = rowId => {
const errorCells = get(validationRowLookupMap)[rowId]
const cellId = errorCells?.[0]
if (cellId) {
focusedCellId.set(cellId)
}
}
return {
@ -39,28 +67,27 @@ export const createStores = () => {
actions: {
setError,
rowHasErrors,
focusFirstRowError,
},
},
}
}
export const initialise = context => {
const { validation, previousFocusedRowId, columns, stickyColumn } = context
const { validation, previousFocusedRowId, validationRowLookupMap } = context
// Remove validation errors from previous focused row
// Remove validation errors when changing rows
previousFocusedRowId.subscribe(id => {
if (id) {
const $columns = get(columns)
const $stickyColumn = get(stickyColumn)
validation.update(state => {
$columns.forEach(column => {
state[getCellID(id, column.name)] = null
const errorCells = get(validationRowLookupMap)[id]
if (errorCells?.length) {
validation.update(state => {
for (let cellId of errorCells) {
delete state[cellId]
}
return state
})
if ($stickyColumn) {
state[getCellID(id, stickyColumn.name)] = null
}
return state
})
}
}
})
}

View File

@ -4,7 +4,7 @@ import { MinColumnWidth } from "../lib/constants"
export const deriveStores = context => {
const {
rowHeight,
visibleColumns,
scrollableColumns,
rows,
scrollTop,
scrollLeft,
@ -46,33 +46,31 @@ export const deriveStores = context => {
return Math.round($scrollLeft / interval) * interval
})
const columnRenderMap = derived(
[visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width]) => {
if (!$visibleColumns.length) {
[scrollableColumns, scrollLeftRounded, width],
([$scrollableColumns, $scrollLeft, $width]) => {
if (!$scrollableColumns.length) {
return {}
}
let startColIdx = 0
let rightEdge = $visibleColumns[0].width
let rightEdge = $scrollableColumns[0].width
while (
rightEdge < $scrollLeft &&
startColIdx < $visibleColumns.length - 1
startColIdx < $scrollableColumns.length - 1
) {
startColIdx++
rightEdge += $visibleColumns[startColIdx].width
rightEdge += $scrollableColumns[startColIdx].width
}
let endColIdx = startColIdx + 1
let leftEdge = rightEdge
while (
leftEdge < $width + $scrollLeft &&
endColIdx < $visibleColumns.length
endColIdx < $scrollableColumns.length
) {
leftEdge += $visibleColumns[endColIdx].width
leftEdge += $scrollableColumns[endColIdx].width
endColIdx++
}
// Only update the store if different
let next = {}
$visibleColumns
$scrollableColumns
.slice(Math.max(0, startColIdx), endColIdx)
.forEach(col => {
next[col.name] = true

View File

@ -1,20 +1,20 @@
.spectrum--midnight {
--hue: 220;
--sat: 10%;
--spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%);
--spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%);
--spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%);
--spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%);
--spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%);
--spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%);
--spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%);
--spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%);
--spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%);
--spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%);
--spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%);
--hue: 220;
--sat: 10%;
--spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%);
--spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%);
--spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%);
--spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%);
--spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%);
--spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%);
--spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%);
--spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%);
--spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%);
--spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%);
--spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%);
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.25) !important;
--spectrum-global-color-blue-100: rgba(35, 40, 50) !important;
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.25) !important;
--spectrum-global-color-blue-100: rgba(36, 44, 64) !important;
}

View File

@ -49,5 +49,5 @@
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.15) !important;
--spectrum-global-color-blue-100: rgb(56, 65, 84) !important;
--spectrum-global-color-blue-100: rgb(56, 65, 90) !important;
}

View File

@ -1,6 +1,8 @@
import { makePropSafe as safe } from "@budibase/string-templates"
import { Helpers } from "@budibase/bbui"
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
/**
* Utility to wrap an async function and ensure all invocations happen
* sequentially.