Merge pull request #10396 from Budibase/grid-tweaks

Grid tweaks
This commit is contained in:
Andrew Kingston 2023-04-24 16:53:46 +01:00 committed by GitHub
commit 98ca05d97f
34 changed files with 752 additions and 462 deletions

View File

@ -42,13 +42,14 @@
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<GridRelationshipButton />
{/if}
<GridImportButton disabled={isUsersTable} />
{#if isUsersTable}
<EditRolesButton />
{:else}
<GridImportButton />
{/if}
<GridExportButton />
<GridFilterButton />
<GridAddColumnModal />

View File

@ -16,7 +16,7 @@
</script>
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}>
Manage access
Access
</ActionButton>
<Modal bind:this={modal}>
<ManageAccessModal

View File

@ -11,7 +11,7 @@
</script>
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
Create view
Add view
</ActionButton>
<Modal bind:this={modal}>
<CreateViewModal />

View File

@ -72,6 +72,7 @@
api = {
focus: () => open(),
blur: () => close(),
isActive: () => isOpen,
onKeyDown,
}
})

View File

@ -50,6 +50,7 @@
const cellAPI = {
focus: () => api?.focus(),
blur: () => api?.blur(),
isActive: () => api?.isActive?.() ?? false,
onKeyDown: (...params) => api?.onKeyDown(...params),
isReadonly: () => readonly,
getType: () => column.schema.type,
@ -67,6 +68,7 @@
{rowIdx}
{focused}
{selectedUser}
{readonly}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}

View File

@ -8,6 +8,7 @@
export let rowIdx
export let defaultHeight = false
export let center = false
export let readonly = false
$: style = getStyle(width, selectedUser)
@ -27,6 +28,7 @@
class:focused
class:error
class:center
class:readonly
class:default-height={defaultHeight}
class:selected-other={selectedUser != null}
on:focus
@ -121,7 +123,8 @@
.cell:hover {
cursor: default;
}
.cell.highlighted:not(.focused) {
.cell.highlighted:not(.focused),
.cell.focused.readonly {
--cell-background: var(--cell-background-hover);
}
.cell.selected:not(.focused) {

View File

@ -0,0 +1,123 @@
<script>
import { GutterWidth } from "../lib/constants"
import { getContext } from "svelte"
import { Checkbox, Icon } from "@budibase/bbui"
import GridCell from "./GridCell.svelte"
import { createEventDispatcher } from "svelte"
export let row
export let rowFocused = false
export let rowHovered = false
export let rowSelected = false
export let disableExpand = false
export let disableNumber = false
export let defaultHeight = false
export let disabled = false
const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher()
const select = () => {
svelteDispatch("select")
const id = row?._id
if (id) {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
}
const expand = () => {
svelteDispatch("expand")
if (row) {
dispatch("edit-row", row)
}
}
</script>
<GridCell
width={GutterWidth}
highlighted={rowFocused || rowHovered}
selected={rowSelected}
{defaultHeight}
>
<div class="gutter">
{#if $$slots.default}
<slot />
{:else}
<div
on:click={select}
class="checkbox"
class:visible={$config.allowDeleteRows &&
(disableNumber || rowSelected || rowHovered || rowFocused)}
>
<Checkbox value={rowSelected} {disabled} />
</div>
{#if !disableNumber}
<div
class="number"
class:visible={!$config.allowDeleteRows ||
!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>
{/if}
{/if}
{#if $config.allowExpandRows}
<div
class="expand"
class:visible={!disableExpand && (rowFocused || rowHovered)}
>
<Icon name="Maximize" hoverable size="S" on:click={expand} />
</div>
{/if}
</div>
</GridCell>
<style>
.gutter {
flex: 1 1 auto;
display: grid;
align-items: center;
padding: var(--cell-padding);
grid-template-columns: 1fr auto;
gap: var(--cell-spacing);
}
.checkbox,
.number {
display: none;
flex-direction: row;
justify-content: center;
align-items: center;
}
.checkbox :global(.spectrum-Checkbox) {
min-height: 0;
height: 20px;
}
.checkbox :global(.spectrum-Checkbox-box) {
margin: 3px 0 0 0;
}
.number {
color: var(--spectrum-global-color-gray-500);
}
.checkbox.visible,
.number.visible {
display: flex;
}
.expand {
opacity: 0;
pointer-events: none;
margin-right: 4px;
}
.expand.visible {
opacity: 1;
pointer-events: all;
}
</style>

View File

@ -20,6 +20,7 @@
ui,
columns,
} = getContext("grid")
const bannedDisplayColumnTypes = [
"link",
"array",
@ -100,7 +101,7 @@
style="flex: 0 0 {column.width}px;"
bind:this={anchor}
class:disabled={$isReordering || $isResizing}
class:sorted={sortedBy}
class:sticky={idx === "sticky"}
>
<GridCell
on:mousedown={onMouseDown}
@ -194,6 +195,10 @@
.header-cell {
display: flex;
}
.header-cell:not(.sticky):hover,
.header-cell:not(.sticky) :global(.cell:hover) {
cursor: grab;
}
.header-cell.disabled {
pointer-events: none;
}
@ -202,9 +207,6 @@
gap: calc(2 * var(--cell-spacing));
background: var(--spectrum-global-color-gray-100);
}
.header-cell.sorted :global(.cell) {
background: var(--spectrum-global-color-gray-200);
}
.name {
flex: 1 1 auto;

View File

@ -31,7 +31,9 @@
isOpen = true
await tick()
textarea.focus()
textarea.setSelectionRange(0, 0)
if (value?.length > 100) {
textarea.setSelectionRange(0, 0)
}
}
const close = () => {
@ -43,6 +45,7 @@
api = {
focus: () => open(),
blur: () => close(),
isActive: () => isOpen,
onKeyDown,
}
})

View File

@ -73,6 +73,7 @@
api = {
focus: open,
blur: close,
isActive: () => isOpen,
onKeyDown,
}
})

View File

@ -235,6 +235,7 @@
api = {
focus: open,
blur: close,
isActive: () => isOpen,
onKeyDown,
}
})

View File

@ -33,6 +33,7 @@
api = {
focus: () => input?.focus(),
blur: () => input?.blur(),
isActive: () => active,
onKeyDown,
}
})

View File

@ -12,5 +12,5 @@
on:click={() => dispatch("add-column")}
disabled={!$config.allowAddColumns}
>
Create column
Add column
</ActionButton>

View File

@ -9,10 +9,10 @@
icon="TableRowAddBottom"
quiet
size="M"
on:click={() => dispatch("add-row")}
on:click={() => dispatch("add-row-inline")}
disabled={!loaded ||
!$config.allowAddRows ||
(!$columns.length && !$stickyColumn)}
>
Create row
Add row
</ActionButton>

View File

@ -0,0 +1,91 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui"
import { DefaultColumnWidth } from "../lib/constants"
const { stickyColumn, columns } = getContext("grid")
const smallSize = 120
const mediumSize = DefaultColumnWidth
const largeSize = DefaultColumnWidth * 1.5
let open = false
let anchor
$: allCols = $columns.concat($stickyColumn ? [$stickyColumn] : [])
$: allSmall = allCols.every(col => col.width === smallSize)
$: allMedium = allCols.every(col => col.width === mediumSize)
$: allLarge = allCols.every(col => col.width === largeSize)
$: custom = !allSmall && !allMedium && !allLarge
$: sizeOptions = [
{
label: "Small",
size: smallSize,
selected: allSmall,
},
{
label: "Medium",
size: mediumSize,
selected: allMedium,
},
{
label: "Large",
size: largeSize,
selected: allLarge,
},
]
const changeColumnWidth = async width => {
columns.update(state => {
state.forEach(column => {
column.width = width
})
return state
})
if ($stickyColumn) {
stickyColumn.update(state => ({
...state,
width,
}))
}
await columns.actions.saveChanges()
}
</script>
<div bind:this={anchor}>
<ActionButton
icon="MoveLeftRight"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
disabled={!allCols.length}
>
Width
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
{#each sizeOptions as option}
<ActionButton
quiet
on:click={() => changeColumnWidth(option.size)}
selected={option.selected}
>
{option.label}
</ActionButton>
{/each}
{#if custom}
<ActionButton selected={custom} quiet>Custom</ActionButton>
{/if}
</div>
</Popover>
<style>
.content {
padding: 12px;
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -1,13 +1,8 @@
<script>
import {
Modal,
ModalContent,
ActionButton,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
import { Modal, ModalContent, Button, notifications } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
const { selectedRows, rows, config } = getContext("grid")
const { selectedRows, rows, config, subscribe } = getContext("grid")
let modal
@ -28,18 +23,21 @@
await rows.actions.deleteRows(rowsToDelete)
notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
}
onMount(() => subscribe("request-bulk-delete", () => modal?.show()))
</script>
{#if selectedRowCount}
<div class="delete-button" data-ignore-click-outside="true">
<ActionButton
<Button
icon="Delete"
size="S"
size="M"
on:click={modal.show}
disabled={!$config.allowEditRows}
cta
>
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
</ActionButton>
</Button>
</div>
{/if}
@ -57,16 +55,12 @@
</Modal>
<style>
.delete-button :global(.spectrum-ActionButton:not(:disabled) *) {
color: var(--spectrum-global-color-red-400);
}
.delete-button :global(.spectrum-ActionButton:not(:disabled)) {
.delete-button :global(.spectrum-Button:not(:disabled)) {
background-color: var(--spectrum-global-color-red-400);
border-color: var(--spectrum-global-color-red-400);
}
/*.delete-button.disabled :global(.spectrum-ActionButton *) {*/
/* color: var(--spectrum-global-color-gray-600);*/
/*}*/
/*.delete-button.disabled :global(.spectrum-ActionButton) {*/
/* border-color: var(--spectrum-global-color-gray-400);*/
/*}*/
.delete-button :global(.spectrum-Button:not(:disabled):hover) {
background-color: var(--spectrum-global-color-red-500);
border-color: var(--spectrum-global-color-red-500);
}
</style>

View File

@ -48,7 +48,7 @@
selected={open || anyHidden}
disabled={!$columns.length}
>
Hide columns
Columns
</ActionButton>
</div>

View File

@ -36,13 +36,13 @@
<div bind:this={anchor}>
<ActionButton
icon="LineHeight"
icon="MoveUpDown"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
>
Row height
Height
</ActionButton>
</div>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui"
const { sort, visibleColumns, stickyColumn } = getContext("grid")
const { sort, columns, stickyColumn } = getContext("grid")
const orderOptions = [
{ label: "A-Z", value: "ascending" },
{ label: "Z-A", value: "descending" },
@ -11,15 +11,24 @@
let open = false
let anchor
$: columnOptions = getColumnOptions($stickyColumn, $visibleColumns)
$: checkValidSortColumn($sort.column, $stickyColumn, $visibleColumns)
$: columnOptions = getColumnOptions($stickyColumn, $columns)
$: checkValidSortColumn($sort.column, $stickyColumn, $columns)
const getColumnOptions = (stickyColumn, columns) => {
let options = []
if (stickyColumn) {
options.push(stickyColumn.name)
options.push({
label: stickyColumn.label || stickyColumn.name,
value: stickyColumn.name,
})
}
return [...options, ...columns.map(col => col.name)]
return [
...options,
...columns.map(col => ({
label: col.label || col.name,
value: col.name,
})),
]
}
const updateSortColumn = e => {
@ -37,13 +46,13 @@
}
// Ensure we never have a sort column selected that is not visible
const checkValidSortColumn = (sortColumn, stickyColumn, visibleColumns) => {
const checkValidSortColumn = (sortColumn, stickyColumn, columns) => {
if (!sortColumn) {
return
}
if (
sortColumn !== stickyColumn?.name &&
!visibleColumns.some(col => col.name === sortColumn)
!columns.some(col => col.name === sortColumn)
) {
if (stickyColumn) {
sort.update(state => ({
@ -53,7 +62,7 @@
} else {
sort.update(state => ({
...state,
column: visibleColumns[0]?.name,
column: columns[0]?.name,
}))
}
}
@ -66,7 +75,7 @@
quiet
size="M"
on:click={() => (open = !open)}
selected={open || $sort.column}
selected={open}
disabled={!columnOptions.length}
>
Sort

View File

@ -22,6 +22,8 @@
import HideColumnsButton from "../controls/HideColumnsButton.svelte"
import AddRowButton from "../controls/AddRowButton.svelte"
import RowHeightButton from "../controls/RowHeightButton.svelte"
import ColumnWidthButton from "../controls/ColumnWidthButton.svelte"
import NewRow from "./NewRow.svelte"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
@ -110,6 +112,7 @@
<AddRowButton />
<AddColumnButton />
<slot name="controls" />
<ColumnWidthButton />
<RowHeightButton />
<HideColumnsButton />
<SortButton />
@ -127,10 +130,11 @@
<HeaderRow />
<GridBody />
</div>
<BetaButton />
<NewRow />
<div class="overlays">
<ResizeOverlay />
<ReorderOverlay />
<BetaButton />
<ScrollOverlay />
<MenuOverlay />
</div>

View File

@ -2,11 +2,25 @@
import { getContext, onMount } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import GridRow from "./GridRow.svelte"
import { BlankRowID } from "../lib/constants"
const { bounds, renderedRows, rowVerticalInversionIndex } = getContext("grid")
const {
bounds,
renderedRows,
renderedColumns,
rowVerticalInversionIndex,
config,
hoveredRowId,
dispatch,
} = getContext("grid")
let body
$: renderColumnsWidth = $renderedColumns.reduce(
(total, col) => (total += col.width),
0
)
onMount(() => {
// Observe and record the height of the body
const observer = new ResizeObserver(() => {
@ -24,6 +38,16 @@
{#each $renderedRows as row, idx}
<GridRow {row} {idx} invertY={idx >= $rowVerticalInversionIndex} />
{/each}
{#if $config.allowAddRows && $renderedColumns.length}
<div
class="blank"
class:highlighted={$hoveredRowId === BlankRowID}
style="width:{renderColumnsWidth}px"
on:mouseenter={() => ($hoveredRowId = BlankRowID)}
on:mouseleave={() => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")}
/>
{/if}
</GridScrollWrapper>
</div>
@ -35,4 +59,15 @@
overflow: hidden;
flex: 1 1 auto;
}
.blank {
height: var(--row-height);
background: var(--cell-background);
border-bottom: var(--cell-border);
border-right: var(--cell-border);
position: absolute;
}
.blank.highlighted {
background: var(--cell-background-hover);
cursor: pointer;
}
</style>

View File

@ -29,10 +29,7 @@
// Handles a wheel even and updates the scroll offsets
const handleWheel = e => {
e.preventDefault()
const modifier = e.ctrlKey || e.metaKey
let x = modifier ? e.deltaY : e.deltaX
let y = modifier ? e.deltaX : e.deltaY
debouncedHandleWheel(x, y, e.clientY)
debouncedHandleWheel(e.deltaX, e.deltaY, e.clientY)
}
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
const { top, left } = $scroll

View File

@ -2,8 +2,17 @@
import { getContext } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import HeaderCell from "../cells/HeaderCell.svelte"
import { Icon } from "@budibase/bbui"
const { renderedColumns } = getContext("grid")
const { renderedColumns, dispatch, scroll, hiddenColumnsWidth, width } =
getContext("grid")
$: columnsWidth = $renderedColumns.reduce(
(total, col) => (total += col.width),
0
)
$: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left
$: left = Math.min($width - 40, end)
</script>
<div class="header">
@ -14,6 +23,13 @@
{/each}
</div>
</GridScrollWrapper>
<div
class="add"
style="left:{left}px"
on:click={() => dispatch("add-column")}
>
<Icon name="Add" />
</div>
</div>
<style>
@ -27,4 +43,20 @@
.row {
display: flex;
}
.add {
height: var(--default-row-height);
display: grid;
place-items: center;
width: 40px;
position: absolute;
top: 0;
border-left: var(--cell-border);
border-right: var(--cell-border);
border-bottom: var(--cell-border);
background: var(--spectrum-global-color-gray-100);
}
.add:hover {
background: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,265 @@
<script>
import { getContext, onDestroy, onMount, tick } from "svelte"
import { Icon, Button } from "@budibase/bbui"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import DataCell from "../cells/DataCell.svelte"
import { fade } from "svelte/transition"
import { GutterWidth } from "../lib/constants"
import { NewRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte"
const {
hoveredRowId,
focusedCellId,
stickyColumn,
scroll,
dispatch,
rows,
focusedCellAPI,
tableId,
subscribe,
renderedRows,
renderedColumns,
rowHeight,
hasNextPage,
maxScrollTop,
rowVerticalInversionIndex,
columnHorizontalInversionIndex,
} = getContext("grid")
let isAdding = false
let newRow = {}
let offset = 0
$: firstColumn = $stickyColumn || $renderedColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: $tableId, (isAdding = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
const shouldInvertY = (offset, inversionIndex, rows) => {
if (offset === 0) {
return false
}
return rows.length >= inversionIndex
}
const addRow = async () => {
// Blur the active cell and tick to let final value updates propagate
$focusedCellAPI?.blur()
await tick()
// Create row
const newRowIndex = offset ? undefined : 0
const savedRow = await rows.actions.addRow(newRow, newRowIndex)
if (savedRow) {
// Reset state
clear()
// Select the first cell if possible
if (firstColumn) {
$focusedCellId = `${savedRow._id}-${firstColumn.name}`
}
}
}
const clear = () => {
isAdding = false
$focusedCellId = null
$hoveredRowId = null
document.removeEventListener("keydown", handleKeyPress)
}
const startAdding = async () => {
if (isAdding) {
return
}
// If we have a next page of data then we aren't truly at the bottom, so we
// render the add row component at the top
if ($hasNextPage) {
offset = 0
}
// If we don't have a next page then we're at the bottom and can scroll to
// the max available offset
else {
scroll.update(state => ({
...state,
top: $maxScrollTop,
}))
offset = $renderedRows.length * $rowHeight - ($maxScrollTop % $rowHeight)
if ($renderedRows.length !== 0) {
offset -= 1
}
}
// Update state and select initial cell
newRow = {}
isAdding = true
$hoveredRowId = NewRowID
if (firstColumn) {
$focusedCellId = `${NewRowID}-${firstColumn.name}`
}
// Attach key listener
document.addEventListener("keydown", handleKeyPress)
}
const updateValue = (rowId, columnName, val) => {
newRow[columnName] = val
}
const addViaModal = () => {
clear()
dispatch("add-row")
}
const handleKeyPress = e => {
if (!isAdding) {
return
}
if (e.key === "Escape") {
// Only close the new row component if we aren't actively inside a cell
if (!$focusedCellAPI?.isActive()) {
e.preventDefault()
clear()
}
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
addRow()
}
}
onMount(() => subscribe("add-row-inline", startAdding))
onDestroy(() => {
document.removeEventListener("keydown", handleKeyPress)
})
</script>
<!-- Only show new row functionality if we have any columns -->
{#if isAdding}
<div
class="container"
class:floating={offset > 0}
style="--offset:{offset}px; --sticky-width:{width}px;"
>
<div class="underlay sticky" transition:fade={{ duration: 130 }} />
<div class="underlay" transition:fade={{ duration: 130 }} />
<div class="sticky-column" transition:fade={{ duration: 130 }}>
<GutterCell on:expand={addViaModal} rowHovered>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
</GutterCell>
{#if $stickyColumn}
{@const cellId = `${NewRowID}-${$stickyColumn.name}`}
<DataCell
{cellId}
rowFocused
column={$stickyColumn}
row={newRow}
focused={$focusedCellId === cellId}
width={$stickyColumn.width}
{updateValue}
rowIdx={0}
{invertY}
/>
{/if}
</div>
<div class="normal-columns" transition:fade={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally wheelInteractive>
<div class="row">
{#each $renderedColumns as column, columnIdx}
{@const cellId = `new-${column.name}`}
{#key cellId}
<DataCell
{cellId}
{column}
{updateValue}
rowFocused
row={newRow}
focused={$focusedCellId === cellId}
width={column.width}
rowIdx={0}
invertX={columnIdx >= $columnHorizontalInversionIndex}
{invertY}
/>
{/key}
{/each}
</div>
</GridScrollWrapper>
</div>
<div class="buttons" transition:fade={{ duration: 130 }}>
<Button size="M" cta on:click={addRow}>Save</Button>
<Button size="M" secondary newStyles on:click={clear}>Cancel</Button>
</div>
</div>
{/if}
<style>
.container {
position: absolute;
top: var(--default-row-height);
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: stretch;
}
.container :global(.cell) {
--cell-background: var(--spectrum-global-color-gray-75) !important;
}
.container.floating :global(.cell) {
height: calc(var(--row-height) + 1px);
border-top: var(--cell-border);
}
/* Underlay sits behind everything */
.underlay {
position: absolute;
content: "";
left: 0;
top: 0;
height: 100%;
width: 100%;
background: var(--cell-background);
opacity: 0.8;
}
.underlay.sticky {
z-index: 2;
width: var(--sticky-width);
}
/* Floating buttons which sit on top of the underlay but below the sticky column */
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
pointer-events: all;
z-index: 3;
position: absolute;
top: calc(var(--row-height) + var(--offset) + 24px);
left: var(--gutter-width);
}
/* Sticky column styles */
.sticky-column {
display: flex;
z-index: 4;
position: relative;
align-self: flex-start;
flex: 0 0 var(--sticky-width);
}
.sticky-column :global(.cell:not(:last-child)) {
border-right: none;
}
.sticky-column,
.normal-columns {
margin-top: var(--offset);
}
/* Normal column styles */
.row {
width: 0;
display: flex;
}
</style>

View File

@ -1,247 +0,0 @@
<script>
import GridCell from "../cells/GridCell.svelte"
import { getContext, onMount } from "svelte"
import { Icon, Button } from "@budibase/bbui"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import DataCell from "../cells/DataCell.svelte"
import { fly } from "svelte/transition"
import { GutterWidth } from "../lib/constants"
const {
hoveredRowId,
focusedCellId,
stickyColumn,
scroll,
config,
dispatch,
visibleColumns,
rows,
showHScrollbar,
tableId,
subscribe,
scrollLeft,
} = getContext("grid")
let isAdding = false
let newRow = {}
let touched = false
$: firstColumn = $stickyColumn || $visibleColumns[0]
$: rowHovered = $hoveredRowId === "new"
$: rowFocused = $focusedCellId?.startsWith("new-")
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: $tableId, (isAdding = false)
const addRow = async () => {
// Create row
const savedRow = await rows.actions.addRow(newRow, 0)
if (savedRow) {
// Select the first cell if possible
if (firstColumn) {
$focusedCellId = `${savedRow._id}-${firstColumn.name}`
}
// Reset state
isAdding = false
scroll.set({
left: 0,
top: 0,
})
}
}
const cancel = () => {
isAdding = false
}
const startAdding = () => {
newRow = {}
isAdding = true
if (firstColumn) {
$focusedCellId = `new-${firstColumn.name}`
}
}
const updateValue = (rowId, columnName, val) => {
touched = true
newRow[columnName] = val
}
const addViaModal = () => {
isAdding = false
dispatch("add-row")
}
onMount(() => subscribe("add-row-inline", startAdding))
</script>
<!-- Only show new row functionality if we have any columns -->
{#if isAdding}
<div class="container" transition:fly={{ y: 20, duration: 130 }}>
<div class="content" class:above-scrollbar={$showHScrollbar}>
<div
class="new-row"
on:mouseenter={() => ($hoveredRowId = "new")}
on:mouseleave={() => ($hoveredRowId = null)}
>
<div
class="sticky-column"
style="flex: 0 0 {width}px"
class:scrolled={$scrollLeft > 0}
>
<GridCell width={GutterWidth} {rowHovered} {rowFocused}>
<div class="gutter">
<div class="number">1</div>
{#if $config.allowExpandRows}
<Icon
name="Maximize"
size="S"
hoverable
on:click={addViaModal}
/>
{/if}
</div>
</GridCell>
{#if $stickyColumn}
{@const cellId = `new-${$stickyColumn.name}`}
<DataCell
{cellId}
column={$stickyColumn}
row={newRow}
{rowHovered}
focused={$focusedCellId === cellId}
{rowFocused}
width={$stickyColumn.width}
{updateValue}
rowIdx={0}
/>
{/if}
</div>
<GridScrollWrapper scrollHorizontally wheelInteractive>
<div class="row">
{#each $visibleColumns as column}
{@const cellId = `new-${column.name}`}
{#key cellId}
<DataCell
{cellId}
{column}
row={newRow}
{rowHovered}
focused={$focusedCellId === cellId}
{rowFocused}
width={column.width}
{updateValue}
rowIdx={0}
/>
{/key}
{/each}
</div>
</GridScrollWrapper>
</div>
</div>
<div class="buttons">
<Button size="M" cta on:click={addRow}>Save</Button>
<Button size="M" secondary newStyles on:click={cancel}>Cancel</Button>
</div>
</div>
{/if}
<style>
.container {
pointer-events: none;
position: absolute;
top: var(--row-height);
left: 0;
width: 100%;
padding-bottom: 800px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.container:before {
position: absolute;
content: "";
left: 0;
top: 0;
height: 100%;
width: 100%;
background: var(--cell-background);
opacity: 0.8;
z-index: -1;
}
.content {
pointer-events: all;
background: var(--background);
border-bottom: var(--cell-border);
}
.new-row {
display: flex;
bottom: 0;
left: 0;
width: 100%;
transition: margin-bottom 130ms ease-out;
}
.new-row :global(.cell) {
--cell-background: var(--background) !important;
border-bottom: none;
}
.sticky-column {
display: flex;
z-index: 1;
position: relative;
}
/* Don't show borders between cells in the sticky column */
.sticky-column :global(.cell:not(:last-child)) {
border-right: none;
}
.row {
width: 0;
display: flex;
}
/* Add shadow when scrolled */
.sticky-column.scrolled {
/*box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.1);*/
}
.sticky-column.scrolled:after {
content: "";
width: 10px;
height: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.05), transparent);
left: 100%;
top: 0;
position: absolute;
}
/* Styles for gutter */
.gutter {
flex: 1 1 auto;
display: grid;
align-items: center;
padding: var(--cell-padding);
grid-template-columns: 1fr auto;
gap: var(--cell-spacing);
}
/* Floating buttons */
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
margin: 24px 0 0 var(--gutter-width);
pointer-events: all;
align-self: flex-start;
}
.number {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: var(--spectrum-global-color-gray-500);
}
</style>

View File

@ -1,24 +1,26 @@
<script>
import { getContext } from "svelte"
import { Checkbox, Icon } from "@budibase/bbui"
import { Icon } from "@budibase/bbui"
import GridCell from "../cells/GridCell.svelte"
import DataCell from "../cells/DataCell.svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import HeaderCell from "../cells/HeaderCell.svelte"
import { GutterWidth } from "../lib/constants"
import { GutterWidth, BlankRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte"
const {
rows,
selectedRows,
stickyColumn,
renderedColumns,
renderedRows,
focusedCellId,
hoveredRowId,
config,
selectedCellMap,
focusedRow,
dispatch,
scrollLeft,
dispatch,
} = getContext("grid")
$: rowCount = $rows.length
@ -37,19 +39,6 @@
$selectedRows = allRows
}
}
const selectRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
</script>
<div
@ -58,26 +47,14 @@
class:scrolled={$scrollLeft > 0}
>
<div class="header row">
<GridCell width={GutterWidth} defaultHeight center>
<div class="gutter">
<div class="checkbox visible">
{#if $config.allowDeleteRows}
<div on:click={selectAll}>
<Checkbox
value={rowCount && selectedRowCount === rowCount}
disabled={!$renderedRows.length}
/>
</div>
{/if}
</div>
{#if $config.allowExpandRows}
<div class="expand">
<Icon name="Maximize" size="S" />
</div>
{/if}
</div>
</GridCell>
<GutterCell
disableExpand
disableNumber
on:select={selectAll}
defaultHeight
rowSelected={selectedRowCount && selectedRowCount === rowCount}
disabled={!$renderedRows.length}
/>
{#if $stickyColumn}
<HeaderCell column={$stickyColumn} orderable={false} idx="sticky" />
{/if}
@ -95,41 +72,7 @@
on:mouseenter={() => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)}
>
<GridCell
width={GutterWidth}
highlighted={rowFocused || rowHovered}
selected={rowSelected}
>
<div class="gutter">
<div
on:click={() => selectRow(row._id)}
class="checkbox"
class:visible={$config.allowDeleteRows &&
(rowSelected || rowHovered || rowFocused)}
>
<Checkbox value={rowSelected} />
</div>
<div
class="number"
class:visible={!$config.allowDeleteRows ||
!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>
{#if $config.allowExpandRows}
<div class="expand" class:visible={rowFocused || rowHovered}>
<Icon
name="Maximize"
hoverable
size="S"
on:click={() => {
dispatch("edit-row", row)
}}
/>
</div>
{/if}
</div>
</GridCell>
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn}
<DataCell
{row}
@ -146,6 +89,24 @@
{/if}
</div>
{/each}
{#if $config.allowAddRows && ($renderedColumns.length || $stickyColumn)}
<div
class="row new"
on:mouseenter={() => ($hoveredRowId = BlankRowID)}
on:mouseleave={() => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")}
>
<GutterCell disableExpand rowHovered={$hoveredRowId === BlankRowID}>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
</GutterCell>
{#if $stickyColumn}
<GridCell
width={$stickyColumn.width}
highlighted={$hoveredRowId === BlankRowID}
/>
{/if}
</div>
{/if}
</GridScrollWrapper>
</div>
</div>
@ -156,6 +117,7 @@
flex-direction: column;
position: relative;
z-index: 2;
background: var(--cell-background);
}
/* Add right border */
@ -203,43 +165,7 @@
position: relative;
flex: 1 1 auto;
}
/* Styles for gutter */
.gutter {
flex: 1 1 auto;
display: grid;
align-items: center;
padding: var(--cell-padding);
grid-template-columns: 1fr auto;
gap: var(--cell-spacing);
}
.checkbox,
.number {
display: none;
flex-direction: row;
justify-content: center;
align-items: center;
}
.checkbox :global(.spectrum-Checkbox) {
min-height: 0;
height: 20px;
}
.checkbox :global(.spectrum-Checkbox-box) {
margin: 3px 0 0 0;
}
.number {
color: var(--spectrum-global-color-gray-500);
}
.checkbox.visible,
.number.visible {
display: flex;
}
.expand {
opacity: 0;
pointer-events: none;
}
.expand.visible {
opacity: 1;
pointer-events: all;
.row.new :global(*:hover) {
cursor: pointer;
}
</style>

View File

@ -1,11 +1,14 @@
export const Padding = 276
export const Padding = 128
export const MaxCellRenderHeight = 252
export const MaxCellRenderWidthOverflow = 200
export const ScrollBarSize = 8
export const GutterWidth = 72
export const DefaultColumnWidth = 200
export const MinColumnWidth = 100
export const MinColumnWidth = 80
export const SmallRowHeight = 36
export const MediumRowHeight = 64
export const LargeRowHeight = 92
export const DefaultRowHeight = SmallRowHeight
export const NewRowID = "new"
export const BlankRowID = "blank"
export const RowPageSize = 100

View File

@ -15,7 +15,7 @@ const TypeIconMap = {
number: "123",
boolean: "Boolean",
attachment: "AppleFiles",
link: "Link",
link: "DataCorrelated",
formula: "Calculator",
json: "Brackets",
}

View File

@ -1,6 +1,7 @@
<script>
import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils"
import { NewRowID } from "../lib/constants"
const {
enrichedRows,
@ -10,15 +11,24 @@
stickyColumn,
focusedCellAPI,
clipboard,
dispatch,
selectedRows,
} = getContext("grid")
// Global key listener which intercepts all key events
const handleKeyDown = e => {
// If nothing selected avoid processing further key presses
if (!$focusedCellId) {
if (e.key === "Tab") {
if (e.key === "Tab" || e.key?.startsWith("Arrow")) {
e.preventDefault()
focusFirstCell()
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
dispatch("add-row-inline")
} else if (e.key === "Delete" || e.key === "Backspace") {
if (Object.keys($selectedRows).length) {
dispatch("request-bulk-delete")
}
}
return
}
@ -26,10 +36,19 @@
// Always intercept certain key presses
const api = $focusedCellAPI
if (e.key === "Escape") {
api?.blur?.()
// 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
}
return
} else if (e.key === "Tab") {
api?.blur?.()
changeFocusedColumn(1)
return
}
// Pass the key event to the selected cell and let it decide whether to
@ -54,8 +73,12 @@
clipboard.actions.copy()
break
case "v":
clipboard.actions.paste()
if (!api?.isReadonly()) {
clipboard.actions.paste()
}
break
case "Enter":
dispatch("add-row-inline")
}
} else {
switch (e.key) {
@ -73,11 +96,19 @@
break
case "Delete":
case "Backspace":
deleteSelectedCell()
if (Object.keys($selectedRows).length) {
dispatch("request-bulk-delete")
} else {
deleteSelectedCell()
}
break
case "Enter":
focusCell()
break
case " ":
case "Space":
toggleSelectRow()
break
default:
startEnteringValue(e.key, e.which)
}
@ -156,7 +187,7 @@
// Focuses the cell and starts entering a new value
const startEnteringValue = (key, keyCode) => {
if ($focusedCellAPI) {
if ($focusedCellAPI && !$focusedCellAPI.isReadonly()) {
const type = $focusedCellAPI.getType()
if (type === "number" && keyCodeIsNumber(keyCode)) {
$focusedCellAPI.setValue(parseInt(key))
@ -171,6 +202,17 @@
}
}
const toggleSelectRow = () => {
const id = $focusedRow?._id
if (!id || id === NewRowID) {
return
}
selectedRows.update(state => {
state[id] = !state[id]
return state
})
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
return () => {

View File

@ -13,6 +13,7 @@
copiedCell,
clipboard,
dispatch,
focusedCellAPI,
} = getContext("grid")
$: style = makeStyle($menu)
@ -49,7 +50,7 @@
</MenuItem>
<MenuItem
icon="Paste"
disabled={$copiedCell == null}
disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()}
on:click={clipboard.actions.paste}
on:click={menu.actions.close}
>

View File

@ -1,6 +1,7 @@
import { writable, derived, get } from "svelte/store"
import { fetchData } from "../../../fetch/fetchData"
import { notifications } from "@budibase/bbui"
import { NewRowID, RowPageSize } from "../lib/constants"
const initialSortState = {
column: null,
@ -16,6 +17,7 @@ export const createStores = () => {
const sort = writable(initialSortState)
const rowChangeCache = writable({})
const inProgressChanges = writable({})
const hasNextPage = writable(false)
// Generate a lookup map to quick find a row by ID
const rowLookupMap = derived(
@ -50,6 +52,7 @@ export const createStores = () => {
sort,
rowChangeCache,
inProgressChanges,
hasNextPage,
}
}
@ -70,6 +73,7 @@ export const deriveStores = context => {
rowChangeCache,
inProgressChanges,
previousFocusedRowId,
hasNextPage,
} = context
const instanceLoaded = writable(false)
const fetch = writable(null)
@ -114,7 +118,7 @@ export const deriveStores = context => {
filter: [],
sortColumn: initialSortState.column,
sortOrder: initialSortState.order,
limit: 100,
limit: RowPageSize,
paginate: true,
},
})
@ -122,6 +126,7 @@ export const deriveStores = context => {
// Subscribe to changes of this fetch model
unsubscribe = newFetch.subscribe($fetch => {
if ($fetch.loaded && !$fetch.loading) {
hasNextPage.set($fetch.hasNextPage)
const $instanceLoaded = get(instanceLoaded)
const resetRows = $fetch.resetKey !== lastResetKey
lastResetKey = $fetch.resetKey
@ -230,7 +235,7 @@ export const deriveStores = context => {
if (bubble) {
throw error
} else {
handleValidationError("new", error)
handleValidationError(NewRowID, error)
}
}
}

View File

@ -1,6 +1,6 @@
import { writable, derived, get } from "svelte/store"
import { tick } from "svelte"
import { Padding, GutterWidth, DefaultRowHeight } from "../lib/constants"
import { Padding, GutterWidth } from "../lib/constants"
export const createStores = () => {
const scroll = writable({
@ -29,7 +29,7 @@ export const deriveStores = context => {
// Derive vertical limits
const contentHeight = derived(
[rows, rowHeight],
([$rows, $rowHeight]) => $rows.length * $rowHeight + Padding,
([$rows, $rowHeight]) => ($rows.length + 1) * $rowHeight + Padding,
0
)
const maxScrollTop = derived(
@ -138,7 +138,7 @@ export const initialise = context => {
const $scroll = get(scroll)
const $bounds = get(bounds)
const $rowHeight = get(rowHeight)
const verticalOffset = DefaultRowHeight * 1.5
const verticalOffset = 60
// Ensure vertical position is viewable
if ($focusedRow) {

View File

@ -1,8 +1,10 @@
import { writable, get, derived } from "svelte/store"
import { tick } from "svelte"
import {
DefaultRowHeight,
LargeRowHeight,
MediumRowHeight,
NewRowID,
} from "../lib/constants"
export const createStores = () => {
@ -49,14 +51,14 @@ export const deriveStores = context => {
([$focusedCellId, $rowLookupMap, $enrichedRows]) => {
const rowId = $focusedCellId?.split("-")[0]
if (rowId === "new") {
// Edge case for new row
return { _id: rowId }
} else {
// All normal rows
const index = $rowLookupMap[rowId]
return $enrichedRows[index]
// Edge case for new rows
if (rowId === NewRowID) {
return { _id: NewRowID }
}
// All normal rows
const index = $rowLookupMap[rowId]
return $enrichedRows[index]
},
null
)
@ -101,7 +103,10 @@ export const initialise = context => {
} = context
// Ensure we clear invalid rows from state if they disappear
rows.subscribe(() => {
rows.subscribe(async () => {
// 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 $selectedRows = get(selectedRows)
const $hoveredRowId = get(hoveredRowId)
@ -140,20 +145,6 @@ export const initialise = context => {
lastFocusedRowId = id
})
// Reset selected rows when selected cell changes
focusedCellId.subscribe(id => {
if (id) {
selectedRows.set({})
}
})
// Unset selected cell when rows are selected
selectedRows.subscribe(rows => {
if (Object.keys(rows || {}).length) {
focusedCellId.set(null)
}
})
// Remove hovered row when a cell is selected
focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) {

View File

@ -2,6 +2,7 @@ import { derived, get } from "svelte/store"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
MinColumnWidth,
ScrollBarSize,
} from "../lib/constants"
@ -45,11 +46,16 @@ export const deriveStores = context => {
)
// Derive visible columns
const scrollLeftRounded = derived(scrollLeft, $scrollLeft => {
const interval = MinColumnWidth
return Math.round($scrollLeft / interval) * interval
})
const renderedColumns = derived(
[visibleColumns, scrollLeft, width],
([$visibleColumns, $scrollLeft, $width]) => {
[visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width], set) => {
if (!$visibleColumns.length) {
return []
set([])
return
}
let startColIdx = 0
let rightEdge = $visibleColumns[0].width
@ -69,19 +75,17 @@ export const deriveStores = context => {
leftEdge += $visibleColumns[endColIdx].width
endColIdx++
}
const nextRenderedColumns = $visibleColumns.slice(startColIdx, endColIdx)
// Cautiously shrink the number of rendered columns.
// This is to avoid rapidly shrinking and growing the visible column count
// which results in remounting cells
const currentCount = get(renderedColumns).length
if (currentCount === nextRenderedColumns.length + 1) {
return $visibleColumns.slice(startColIdx, endColIdx + 1)
} else {
return nextRenderedColumns
// Render an additional column on either side to account for
// debounce column updates based on scroll position
const next = $visibleColumns.slice(
Math.max(0, startColIdx - 1),
endColIdx + 1
)
const current = get(renderedColumns)
if (JSON.stringify(next) !== JSON.stringify(current)) {
set(next)
}
},
[]
}
)
const hiddenColumnsWidth = derived(