Merge pull request #14023 from Budibase/table-improvements-2
Multi-cell operations for tables
This commit is contained in:
commit
8633fad7f4
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -84,6 +84,8 @@
|
|||
showPopover={drawers.length === 0}
|
||||
clickOutsideOverride={drawers.length > 0}
|
||||
maxHeight={600}
|
||||
minWidth={360}
|
||||
maxWidth={360}
|
||||
offset={18}
|
||||
>
|
||||
<span class="popover-wrap">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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",
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue