Large refactors to row creation, naming and sheet APIs

This commit is contained in:
Andrew Kingston 2023-04-10 18:46:34 +01:00
parent da2023974e
commit 81a28eb4da
31 changed files with 376 additions and 508 deletions

View File

@ -84,7 +84,7 @@
"@spectrum-css/vars": "3.0.1",
"dayjs": "^1.10.4",
"easymde": "^2.16.1",
"svelte-flatpickr": "^3.2.3",
"svelte-flatpickr": "^3.3.2",
"svelte-portal": "^1.0.0"
},
"resolutions": {

View File

@ -2576,10 +2576,10 @@ supports-color@^7.0.0, supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
svelte-flatpickr@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-3.2.3.tgz#db5dd7ad832ef83262b45e09737955ad3d591fc8"
integrity sha512-PNkqK4Napx8nTvCwkaUXdnKo8dISThaxEOK+szTUXcY6H0dQM0TSyuoMaVWY2yX7pM+PN5cpCQCcVe8YvTRFSw==
svelte-flatpickr@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-3.3.2.tgz#f08bcde83d439cb30df6fd07b974d87371f130c1"
integrity sha512-VNJLYyLRDplI63oWX5hJylzAJc2VhTh3z9SNecfjtuPZmP6FZPpg9Fw7rXpkEV2DPovIWj2PtaVxB6Kp9r423w==
dependencies:
flatpickr "^4.5.2"

View File

@ -4,7 +4,7 @@
import { Dropzone, notifications } from "@budibase/bbui"
export let value
export let selected = false
export let focused = false
export let onChange
export let readonly = false
export let api
@ -15,9 +15,9 @@
let isOpen = false
$: editable = selected && !readonly
$: editable = focused && !readonly
$: {
if (!selected) {
if (!focused) {
close()
}
}

View File

@ -3,12 +3,12 @@
import { Checkbox } from "@budibase/bbui"
export let value
export let selected = false
export let focused = false
export let onChange
export let readonly = false
export let api
$: editable = selected && !readonly
$: editable = focused && !readonly
const handleChange = e => {
onChange(e.detail)

View File

@ -1,15 +1,17 @@
<script>
import { getContext, onMount } from "svelte"
import { getContext } from "svelte"
import SheetCell from "./SheetCell.svelte"
import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store"
const { rows, selectedCellId, menu, selectedCellAPI, config } =
const { rows, focusedCellId, menu, sheetAPI, config, validation } =
getContext("sheet")
export let rowSelected
export let rowHovered
export let rowFocused
export let rowIdx
export let selected
export let focused
export let selectedUser
export let reorderSource
export let reorderTarget
@ -19,8 +21,36 @@
export let updateRow = rows.actions.updateRow
export let invert = false
const emptyError = writable(null)
let api
let error
$: {
// Wipe error if row is unfocused
if (!rowFocused && $error) {
validation.actions.setError(cellId, null)
}
}
// Get the error for this cell if the row is focused
$: error = getErrorStore(rowFocused, cellId)
// Determine if the cell is editable
$: readonly = column.schema.autocolumn || (!$config.allowEditRows && row._id)
// Register this cell API if the row is focused
$: {
if (rowFocused) {
sheetAPI.actions.registerCellAPI(cellId, cellAPI)
}
}
const getErrorStore = (selected, cellId) => {
if (!selected) {
return emptyError
}
return derived(validation, $validation => $validation[cellId])
}
const cellAPI = {
focus: () => api?.focus(),
@ -29,76 +59,54 @@
isReadonly: () => readonly,
isRequired: () => !!column.schema.constraints?.presence,
validate: value => {
// Validate the current value if no new value is provided
if (value === undefined) {
value = row[column.name]
}
let newError = null
if (cellAPI.isReadonly() && !(value == null || value === "")) {
// Ensure cell isn't readonly
error = "Auto columns can't be edited"
newError = "Auto columns can't be edited"
} else if (cellAPI.isRequired() && (value == null || value === "")) {
// Sanity check required fields
error = "Required field"
newError = "Required field"
} else {
error = null
newError = null
}
return error
validation.actions.setError(cellId, newError)
return newError
},
updateValue: value => {
try {
cellAPI.validate(value)
if (!error) {
updateRow(row._id, column.name, value)
}
} catch (err) {
error = err
cellAPI.validate(value)
if (!$error) {
updateRow(row._id, column.name, value)
}
},
}
// Determine if the cell is editable
$: readonly = column.schema.autocolumn || (!$config.allowEditRows && row._id)
// Update selected cell API if selected
$: {
if (selected) {
selectedCellAPI.set(cellAPI)
} else if (error) {
// error = null
}
}
</script>
{#key error}
<SheetCell
{rowSelected}
{rowHovered}
{rowIdx}
{selected}
{selectedUser}
{reorderSource}
{reorderTarget}
{error}
on:click={() => selectedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width}
>
<svelte:component
this={getCellRenderer(column)}
bind:api
value={row[column.name]}
schema={column.schema}
{selected}
onChange={cellAPI.updateValue}
{readonly}
{invert}
placeholder="error"
/>
</SheetCell>
{/key}
<style>
.placeholder {
font-style: italic;
padding: var(--cell-padding);
}
</style>
<SheetCell
{rowSelected}
{rowHovered}
{rowFocused}
{rowIdx}
{focused}
{selectedUser}
{reorderSource}
{reorderTarget}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width}
>
<svelte:component
this={getCellRenderer(column)}
bind:api
value={row[column.name]}
schema={column.schema}
onChange={cellAPI.updateValue}
{focused}
{readonly}
{invert}
/>
</SheetCell>

View File

@ -5,7 +5,7 @@
export let value
export let schema
export let onChange
export let selected = false
export let focused = false
export let readonly = false
// adding the 0- will turn a string like 00:00:00 into a valid ISO
@ -18,7 +18,7 @@
: dateOnly
? "MMM D YYYY"
: "MMM D YYYY, HH:mm"
$: editable = selected && !readonly
$: editable = focused && !readonly
</script>
<div class="container">

View File

@ -181,6 +181,7 @@
.header-cell :global(.cell) {
padding: 0 var(--cell-padding);
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);

View File

@ -2,7 +2,7 @@
import { onMount, tick } from "svelte"
export let value
export let selected = false
export let focused = false
export let onChange
export let readonly = false
export let api
@ -11,9 +11,9 @@
let textarea
let isOpen = false
$: editable = selected && !readonly
$: editable = focused && !readonly
$: {
if (!selected) {
if (!focused) {
isOpen = false
}
}

View File

@ -6,7 +6,7 @@
export let value
export let schema
export let onChange
export let selected = false
export let focused = false
export let multi = false
export let readonly = false
export let api
@ -16,11 +16,11 @@
let focusedOptionIdx = null
$: options = schema?.constraints?.inclusion || []
$: editable = selected && !readonly
$: editable = focused && !readonly
$: values = Array.isArray(value) ? value : [value].filter(x => x != null)
$: {
// Close when deselected
if (!selected) {
if (!focused) {
close()
}
}
@ -180,7 +180,7 @@
left: 0;
display: flex;
flex-direction: column;
box-shadow: 0 0 8px 4px rgba(0, 0, 0, 0.15);
box-shadow: 4px 4px 10px 2px rgba(0, 0, 0, 0.1);
justify-content: flex-start;
align-items: stretch;
max-height: var(--max-cell-render-height);

View File

@ -7,7 +7,7 @@
export let value
export let api
export let readonly
export let selected
export let focused
export let schema
export let onChange
export let invert = false
@ -25,12 +25,12 @@
let results
$: oneRowOnly = schema?.relationshipType === "one-to-many"
$: editable = selected && !readonly
$: editable = focused && !readonly
$: results = getResults(searchResults, value)
$: lookupMap = buildLookupMap(value, isOpen)
$: search(searchString)
$: {
if (!selected) {
if (!focused) {
close()
}
}

View File

@ -1,7 +1,8 @@
<script>
export let rowSelected = false
export let rowHovered = false
export let selected = false
export let rowFocused = false
export let focused = false
export let reorderSource = false
export let reorderTarget = false
export let width = ""
@ -25,12 +26,13 @@
class="cell"
class:row-selected={rowSelected}
class:row-hovered={rowHovered}
class:selected
class:row-focused={rowFocused}
class:focused
class:selected-other={selectedUser != null}
class:reorder-source={reorderSource}
class:reorder-target={reorderTarget}
class:center
class:error={error && selected}
class:error
on:focus
on:mousedown
on:mouseup
@ -39,8 +41,14 @@
{style}
data-row={rowIdx}
>
{#if error}
<div class="label">
{error}
</div>
{/if}
<slot />
{#if selectedUser && !selected}
{#if selectedUser && !focused}
<div class="label">
{selectedUser.label}
</div>
@ -64,9 +72,9 @@
position: relative;
width: 0;
}
.cell.selected:after,
.cell.focused:after,
.cell.error:after,
.cell.selected-other:not(.selected):after {
.cell.selected-other:not(.focused):after {
content: " ";
position: absolute;
top: 0;
@ -78,28 +86,32 @@
border-radius: 2px;
box-sizing: border-box;
}
.cell.selected {
.cell:hover {
z-index: 1;
}
.cell.focused {
z-index: 2;
}
.cell.selected:after {
.cell.focused:after {
border-color: var(--spectrum-global-color-blue-400);
}
.cell.error:after {
border-color: var(--spectrum-global-color-red-400);
}
.cell.selected-other:not(.selected) {
.cell.selected-other:not(.focused) {
z-index: 1;
}
.cell.selected-other:not(.selected):after {
.cell.selected-other:not(.focused):after {
border-color: var(--spectrum-global-color-red-400);
}
.cell:not(.selected) {
.cell:not(.focused) {
user-select: none;
}
.cell:hover {
cursor: default;
}
.cell.row-selected {
.cell.row-selected,
.cell.row-focused {
--cell-background: var(--spectrum-global-color-gray-75);
}
.cell.row-hovered {
@ -126,10 +138,11 @@
.label {
position: absolute;
bottom: 100%;
left: 0;
margin: 0 0 -2px 0;
padding: 1px 4px 3px 4px;
background: var(--user-color);
border-radius: 2px 2px 0 0;
border-radius: 2px;
display: none;
color: white;
font-size: 12px;
@ -143,7 +156,7 @@
.cell[data-row="0"] .label {
bottom: auto;
top: 100%;
border-radius: 0 0 2px 2px;
border-radius: 0 2px 2px 2px;
padding: 2px 4px 2px 4px;
margin: -2px 0 0 0;
}
@ -152,6 +165,8 @@
}
.error .label {
background: var(--spectrum-global-color-red-400);
}
.error.focused .label {
display: block;
}
</style>

View File

@ -2,23 +2,23 @@
import { onMount } from "svelte"
export let value
export let selected = false
export let focused = false
export let onChange
export let type = "text"
export let readonly = false
export let api
let input
let focused = false
let active = false
$: editable = selected && !readonly
$: editable = focused && !readonly
const handleChange = e => {
onChange(e.target.value)
}
const onKeyDown = e => {
if (!focused) {
if (!active) {
return false
}
if (e.key === "Enter") {
@ -41,8 +41,8 @@
{#if editable}
<input
bind:this={input}
on:focus={() => (focused = true)}
on:blur={() => (focused = false)}
on:focus={() => (active = true)}
on:blur={() => (active = false)}
{type}
value={value || ""}
on:change={handleChange}

View File

@ -32,8 +32,8 @@
background: var(--background);
border-bottom: var(--cell-border);
position: relative;
z-index: 2;
height: var(--row-height);
z-index: 1;
}
.row {
display: flex;

View File

@ -1,246 +0,0 @@
<script>
import SheetCell from "../cells/SheetCell.svelte"
import { getContext } from "svelte"
import { Icon, Button } from "@budibase/bbui"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
import DataCell from "../cells/DataCell.svelte"
const {
renderedColumns,
hoveredRowId,
selectedCellId,
stickyColumn,
gutterWidth,
scroll,
config,
dispatch,
visibleColumns,
rows,
wheel,
showHScrollbar,
tableId,
} = getContext("sheet")
let isAdding = false
let newRow = {}
let touched = false
$: firstColumn = $stickyColumn || $visibleColumns[0]
$: rowHovered = $hoveredRowId === "new"
$: containsSelectedCell = $selectedCellId?.startsWith("new-")
$: width = gutterWidth + ($stickyColumn?.width || 0)
$: scrollLeft = $scroll.left
$: $tableId, (isAdding = false)
const addRow = async () => {
const savedRow = await rows.actions.addRow(newRow)
if (savedRow && firstColumn) {
$selectedCellId = `${savedRow._id}-${firstColumn.name}`
isAdding = false
}
}
const cancel = () => {
isAdding = false
}
const startAdding = () => {
newRow = {}
isAdding = true
}
const updateRow = (rowId, columnName, val) => {
touched = true
newRow[columnName] = val
}
const addViaModal = () => {
isAdding = false
dispatch("add-row")
}
</script>
<!-- Only show new row functionality if we have any columns -->
{#if firstColumn}
<div
class="add-button"
class:visible={!isAdding}
class:above-scrollbar={$showHScrollbar}
>
<Button size="M" cta icon="Add" on:click={startAdding}>Add row</Button>
</div>
<div class="container" class:visible={isAdding}>
<div class="buttons">
<Button size="M" cta on:click={addRow}>Save</Button>
<Button size="M" secondary newStyles on:click={cancel}>Cancel</Button>
</div>
<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}
>
<SheetCell
width={gutterWidth}
{rowHovered}
rowSelected={containsSelectedCell}
>
<div class="gutter">
{#if $config.allowExpandRows}
<Icon
name="Maximize"
size="S"
hoverable
on:click={addViaModal}
/>
{/if}
</div>
</SheetCell>
{#if $stickyColumn}
{@const cellId = `new-${$stickyColumn.name}`}
<DataCell
{cellId}
column={$stickyColumn}
row={newRow}
{rowHovered}
selected={$selectedCellId === cellId}
rowSelected={containsSelectedCell}
width={$stickyColumn.width}
{updateRow}
invert
/>
{/if}
</div>
<SheetScrollWrapper scrollVertically={false}>
<div class="row">
{#each $renderedColumns as column}
{@const cellId = `new-${column.name}`}
<DataCell
{cellId}
{column}
row={newRow}
{rowHovered}
selected={$selectedCellId === cellId}
rowSelected={containsSelectedCell}
width={column.width}
{updateRow}
invert
/>
{/each}
</div>
</SheetScrollWrapper>
</div>
</div>
</div>
{/if}
<style>
.add-button {
position: absolute;
left: 16px;
bottom: 16px;
z-index: 1;
transform: translateY(calc(16px + 100%));
transition: transform 130ms ease-out;
}
.add-button.above-scrollbar {
bottom: 32px;
}
.add-button.visible {
transform: translateY(0);
}
.container {
pointer-events: none;
position: absolute;
bottom: 0;
transform: translateY(100%);
z-index: 1;
transition: transform 130ms ease-out;
background: linear-gradient(
to bottom,
transparent,
var(--cell-background) 80%
);
width: 100%;
padding-top: 64px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.container.visible {
transform: translateY(0);
}
.content {
pointer-events: all;
background: var(--background);
border-top: var(--cell-border);
}
.content.above-scrollbar {
padding: 0 0 24px 0;
}
.new-row {
display: flex;
bottom: 0;
left: 0;
width: 100%;
transition: margin-bottom 130ms ease-out;
}
.new-row.visible {
margin-bottom: 0;
}
.new-row :global(.cell) {
--cell-background: var(--background) !important;
}
.sticky-column {
display: flex;
z-index: 1;
}
/* 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.scrolled :global(.cell:last-child:after) {
content: " ";
position: absolute;
width: 10px;
height: 100%;
left: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
}
/* Styles for gutter */
.gutter {
flex: 1 1 auto;
display: grid;
align-items: center;
padding: var(--cell-padding);
grid-template-columns: 1fr 16px;
gap: var(--cell-spacing);
}
/* Floating buttons */
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
margin: 0 0 16px 16px;
pointer-events: all;
align-self: flex-start;
}
</style>

View File

@ -4,11 +4,11 @@
import { Icon, Button } from "@budibase/bbui"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
import DataCell from "../cells/DataCell.svelte"
import { fly } from "svelte/transition"
const {
renderedColumns,
hoveredRowId,
selectedCellId,
focusedCellId,
stickyColumn,
gutterWidth,
scroll,
@ -19,7 +19,7 @@
showHScrollbar,
tableId,
subscribe,
selectedCellAPI,
sheetAPI,
} = getContext("sheet")
let isAdding = false
@ -28,33 +28,16 @@
$: firstColumn = $stickyColumn || $visibleColumns[0]
$: rowHovered = $hoveredRowId === "new"
$: containsSelectedCell = $selectedCellId?.startsWith("new-")
$: rowFocused = $focusedCellId?.startsWith("new-")
$: width = gutterWidth + ($stickyColumn?.width || 0)
$: scrollLeft = $scroll.left
$: $tableId, (isAdding = false)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const addRow = async () => {
// Validate new row
let allColumns = []
if ($stickyColumn) {
allColumns.push($stickyColumn)
}
allColumns = allColumns.concat($visibleColumns)
for (let col of allColumns) {
$selectedCellId = `new-${col.name}`
await tick()
const error = $selectedCellAPI.validate()
if (error) {
return
}
}
// Create row
const savedRow = await rows.actions.addRow(newRow, 0)
if (savedRow && firstColumn) {
$selectedCellId = `${savedRow._id}-${firstColumn.name}`
$focusedCellId = `${savedRow._id}-${firstColumn.name}`
isAdding = false
}
@ -70,14 +53,10 @@
}
const startAdding = () => {
scroll.set({
left: 0,
top: 0,
})
newRow = {}
isAdding = true
if (firstColumn) {
$selectedCellId = `new-${firstColumn.name}`
$focusedCellId = `new-${firstColumn.name}`
}
}
@ -95,8 +74,8 @@
</script>
<!-- Only show new row functionality if we have any columns -->
{#if firstColumn}
<div class="container" class:visible={isAdding}>
{#if isAdding}
<div class="container" transition:fly={{ y: 20, duration: 130 }}>
<div class="content" class:above-scrollbar={$showHScrollbar}>
<div
class="new-row"
@ -108,11 +87,7 @@
style="flex: 0 0 {width}px"
class:scrolled={scrollLeft > 0}
>
<SheetCell
width={gutterWidth}
{rowHovered}
rowSelected={containsSelectedCell}
>
<SheetCell width={gutterWidth} {rowHovered} {rowFocused}>
<div class="gutter">
<div class="number">1</div>
{#if $config.allowExpandRows}
@ -132,8 +107,8 @@
column={$stickyColumn}
row={newRow}
{rowHovered}
selected={$selectedCellId === cellId}
rowSelected={containsSelectedCell}
focused={$focusedCellId === cellId}
{rowFocused}
width={$stickyColumn.width}
{updateRow}
rowIdx={0}
@ -151,8 +126,8 @@
{column}
row={newRow}
{rowHovered}
selected={$selectedCellId === cellId}
rowSelected={containsSelectedCell}
focused={$focusedCellId === cellId}
{rowFocused}
width={column.width}
{updateRow}
rowIdx={0}
@ -176,22 +151,17 @@
position: absolute;
top: var(--row-height);
left: 0;
transform: translateY(-100%);
transition: transform 130ms ease-out;
background: linear-gradient(
to bottom,
var(--cell-background) 20%,
transparent 100%
);
width: 100%;
padding-bottom: 64px;
padding-bottom: 100px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.container.visible {
transform: translateY(0);
}
.content {
pointer-events: all;
background: var(--background);
@ -216,6 +186,7 @@
.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)) {
@ -228,13 +199,17 @@
}
/* Add shadow when scrolled */
.sticky.scrolled :global(.cell:last-child:after) {
content: " ";
position: absolute;
.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%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
top: 0;
position: absolute;
}
/* Styles for gutter */
@ -252,7 +227,7 @@
display: flex;
flex-direction: row;
gap: 8px;
margin: 16px 0 0 16px;
margin: 24px 0 0 16px;
pointer-events: all;
align-self: flex-start;
}

View File

@ -15,6 +15,8 @@
import { createMenuStores } from "../stores/menu"
import { createMaxScrollStores } from "../stores/max-scroll"
import { createPaginationStores } from "../stores/pagination"
import { createSheetAPIStores } from "../stores/sheet-api"
import { createValidationStores } from "../stores/validation"
import DeleteButton from "../controls/DeleteButton.svelte"
import SheetBody from "./SheetBody.svelte"
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
@ -63,11 +65,13 @@
tableId: tableIdStore,
}
context = { ...context, ...createEventManagers() }
context = { ...context, ...createValidationStores(context) }
context = { ...context, ...createBoundsStores(context) }
context = { ...context, ...createScrollStores(context) }
context = { ...context, ...createRowsStore(context) }
context = { ...context, ...createColumnsStores(context) }
context = { ...context, ...createUIStores(context) }
context = { ...context, ...createSheetAPIStores(context) }
context = { ...context, ...createResizeStores(context) }
context = { ...context, ...createViewportStores(context) }
context = { ...context, ...createMaxScrollStores(context) }
@ -75,7 +79,6 @@
context = { ...context, ...createUserStores(context) }
context = { ...context, ...createMenuStores(context) }
context = { ...context, ...createPaginationStores(context) }
context = { ...context, ...context }
// Reference some stores for local use
const { isResizing, isReordering, ui, loaded, rowHeight } = context

View File

@ -7,41 +7,47 @@
export let invert = false
const {
selectedCellId,
focusedCellId,
reorder,
selectedRows,
visibleColumns,
renderedColumns,
hoveredRowId,
selectedCellMap,
selectedCellRow,
focusedRow,
hiddenColumnsWidth,
} = getContext("sheet")
$: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id
$: containsSelectedCell = $selectedCellRow?._id === row._id
$: rowFocused = $focusedRow?._id === row._id
$: cols = rowFocused ? $visibleColumns : $renderedColumns
$: foo = `margin-left: ${-1 * $hiddenColumnsWidth}px;`
</script>
<div
class="row"
style={rowFocused ? foo : null}
on:focus
on:mouseenter={() => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)}
>
{#each $renderedColumns as column (column.name)}
{#each cols as column (column.name)}
{@const cellId = `${row._id}-${column.name}`}
<DataCell
rowSelected={rowSelected || containsSelectedCell}
{rowSelected}
{rowHovered}
rowIdx={idx}
selected={$selectedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
reorderSource={$reorder.sourceColumn === column.name}
reorderTarget={$reorder.targetColumn === column.name}
width={column.width}
{rowFocused}
{cellId}
{column}
{row}
{invert}
rowIdx={idx}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
reorderSource={$reorder.sourceColumn === column.name}
reorderTarget={$reorder.targetColumn === column.name}
width={column.width}
/>
{/each}
</div>
@ -50,5 +56,6 @@
.row {
width: 0;
display: flex;
height: var(--row-height);
}
</style>

View File

@ -5,14 +5,13 @@
const {
rowHeight,
scroll,
visibleColumns,
renderedColumns,
selectedCellId,
focusedCellId,
renderedRows,
maxScrollTop,
maxScrollLeft,
bounds,
hoveredRowId,
hiddenColumnsWidth,
} = getContext("sheet")
export let scrollVertically = true
@ -20,8 +19,7 @@
export let wheelInteractive = true
export let foo = false
$: hiddenWidths = calculateHiddenWidths($renderedColumns)
$: style = generateStyle($scroll, $rowHeight, hiddenWidths, foo)
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth, foo)
const generateStyle = (scroll, rowHeight, hiddenWidths, foo) => {
let offsetX, offsetY
@ -35,20 +33,6 @@
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}
// Calculates with total width of all columns currently not rendered
const calculateHiddenWidths = renderedColumns => {
const idx = $visibleColumns.findIndex(
col => col.name === renderedColumns[0]?.name
)
let width = 0
if (idx > 0) {
for (let i = 0; i < idx; i++) {
width += $visibleColumns[i].width
}
}
return width
}
// Handles a wheel even and updates the scroll offsets
const handleWheel = e => {
e.preventDefault()
@ -84,7 +68,7 @@
<div
class="outer"
on:wheel={wheelInteractive ? handleWheel : null}
on:click|self={() => ($selectedCellId = null)}
on:click|self={() => ($focusedCellId = null)}
>
<div {style}>
<slot />

View File

@ -11,13 +11,13 @@
selectedRows,
stickyColumn,
renderedRows,
selectedCellId,
focusedCellId,
hoveredRowId,
scroll,
reorder,
config,
selectedCellMap,
selectedCellRow,
focusedRow,
gutterWidth,
dispatch,
} = getContext("sheet")
@ -90,7 +90,7 @@
{#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const containsSelectedRow = $selectedCellRow?._id === row._id}
{@const rowFocused = $focusedRow?._id === row._id}
<div
class="row"
on:mouseenter={() => ($hoveredRowId = row._id)}
@ -98,7 +98,8 @@
>
<SheetCell
width={gutterWidth}
rowSelected={rowSelected || containsSelectedRow}
{rowSelected}
{rowFocused}
{rowHovered}
>
<div class="gutter">
@ -106,22 +107,19 @@
on:click={() => selectRow(row._id)}
class="checkbox"
class:visible={$config.allowSelectRows &&
(rowSelected || rowHovered || containsSelectedRow)}
(rowSelected || rowHovered || rowFocused)}
>
<Checkbox value={rowSelected} />
</div>
<div
class="number"
class:visible={!$config.allowSelectRows ||
!(rowSelected || rowHovered || containsSelectedRow)}
!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>
{#if $config.allowExpandRows}
<div
class="expand"
class:visible={containsSelectedRow || rowHovered}
>
<div class="expand" class:visible={rowFocused || rowHovered}>
<Icon
name="Maximize"
hoverable
@ -138,10 +136,11 @@
{#if $stickyColumn}
{@const cellId = `${row._id}-${$stickyColumn.name}`}
<DataCell
rowSelected={rowSelected || containsSelectedRow}
{rowSelected}
{rowHovered}
{rowFocused}
rowIdx={idx}
selected={$selectedCellId === cellId}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
width={$stickyColumn.width}
reorderTarget={$reorder.targetColumn === $stickyColumn.name}
@ -162,12 +161,22 @@
flex-direction: column;
position: relative;
border-right: var(--cell-border);
z-index: 3;
z-index: 2;
}
/* Add shadow when scrolled */
/*Add shadow when scrolled */
.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;
/*z-index: 1;*/
}
.sticky-column.scrolled {
box-shadow: 0 0 8px -2px rgba(0, 0, 0, 0.2);
/*box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.1);*/
}
/* Don't show borders between cells in the sticky column */

View File

@ -2,7 +2,7 @@ import { get } from "svelte/store"
import { io } from "socket.io-client"
export const createWebsocket = context => {
const { rows, tableId, users, userId, selectedCellId } = context
const { rows, tableId, users, userId, focusedCellId } = context
// Determine connection info
const tls = location.protocol === "https:"
@ -55,8 +55,8 @@ export const createWebsocket = context => {
tableId.subscribe(connectToDataspace)
// Notify selected cell changes
selectedCellId.subscribe($selectedCellId => {
socket.emit("select-cell", $selectedCellId)
focusedCellId.subscribe($focusedCellId => {
socket.emit("select-cell", $focusedCellId)
})
return () => socket?.disconnect()

View File

@ -5,16 +5,16 @@
const {
rows,
selectedCellId,
focusedCellId,
visibleColumns,
selectedCellRow,
focusedRow,
stickyColumn,
selectedCellAPI,
} = getContext("sheet")
const handleKeyDown = e => {
// If nothing selected avoid processing further key presses
if (!$selectedCellId) {
if (!$focusedCellId) {
if (e.key === "Tab") {
selectFirstCell()
}
@ -72,15 +72,15 @@
if (!firstColumn) {
return
}
selectedCellId.set(`${firstRow._id}-${firstColumn.name}`)
focusedCellId.set(`${firstRow._id}-${firstColumn.name}`)
}
const changeSelectedColumn = delta => {
if (!$selectedCellId) {
if (!$focusedCellId) {
return
}
const cols = $visibleColumns
const split = $selectedCellId.split("-")
const split = $focusedCellId.split("-")
const columnName = split[1]
let newColumnName
if (columnName === $stickyColumn?.name) {
@ -95,24 +95,24 @@
}
}
if (newColumnName) {
$selectedCellId = `${split[0]}-${newColumnName}`
$focusedCellId = `${split[0]}-${newColumnName}`
}
}
const changeSelectedRow = delta => {
if (!$selectedCellRow) {
if (!$focusedRow) {
return
}
const newRow = $rows[$selectedCellRow.__idx + delta]
const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) {
const split = $selectedCellId.split("-")
$selectedCellId = `${newRow._id}-${split[1]}`
const split = $focusedCellId.split("-")
$focusedCellId = `${newRow._id}-${split[1]}`
}
}
// Debounce to avoid holding down delete and spamming requests
const deleteSelectedCell = debounce(() => {
if (!$selectedCellId) {
if (!$focusedCellId) {
return
}
$selectedCellAPI.updateValue(null)

View File

@ -3,11 +3,11 @@
import { getContext } from "svelte"
const {
selectedCellRow,
focusedRow,
menu,
rows,
columns,
selectedCellId,
focusedCellId,
stickyColumn,
config,
} = getContext("sheet")
@ -19,20 +19,20 @@
}
const deleteRow = () => {
rows.actions.deleteRows([$selectedCellRow])
rows.actions.deleteRows([$focusedRow])
menu.actions.close()
notifications.success("Deleted 1 row")
}
const duplicate = async () => {
let clone = { ...$selectedCellRow }
let clone = { ...$focusedRow }
delete clone._id
delete clone._rev
delete clone.__idx
const newRow = await rows.actions.addRow(clone, $selectedCellRow.__idx + 1)
const newRow = await rows.actions.addRow(clone, $focusedRow.__idx + 1)
if (newRow) {
const column = $stickyColumn?.name || $columns[0].name
$selectedCellId = `${newRow._id}-${column}`
$focusedCellId = `${newRow._id}-${column}`
menu.actions.close()
}
}

View File

@ -25,7 +25,7 @@
{#if !$isReordering}
{#if $stickyColumn}
<div
class="resize-slider sticky"
class="resize-slider"
class:visible={column === $stickyColumn.name}
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
on:dblclick={() => resize.actions.resetSize($stickyColumn)}
@ -62,9 +62,6 @@
cursor: col-resize;
opacity: 1;
}
.resize-slider.sticky {
z-index: 3;
}
.resize-indicator {
margin-left: -1px;
width: 2px;

View File

@ -9,8 +9,8 @@ export const createMaxScrollStores = context => {
bounds,
rowHeight,
scroll,
selectedCellRow,
selectedCellId,
focusedRow,
focusedCellId,
gutterWidth,
} = context
const padding = 264
@ -89,18 +89,18 @@ export const createMaxScrollStores = context => {
})
// Ensure the selected cell is visible
selectedCellId.subscribe(async $selectedCellId => {
focusedCellId.subscribe(async $focusedCellId => {
await tick()
const $selectedCellRow = get(selectedCellRow)
const $focusedRow = get(focusedRow)
const $scroll = get(scroll)
const $bounds = get(bounds)
const $rowHeight = get(rowHeight)
const verticalOffset = $rowHeight * 1.5
// Ensure vertical position is viewable
if ($selectedCellRow) {
if ($focusedRow) {
// Ensure row is not below bottom of screen
const rowYPos = $selectedCellRow.__idx * $rowHeight
const rowYPos = $focusedRow.__idx * $rowHeight
const bottomCutoff =
$scroll.top + $bounds.height - $rowHeight - verticalOffset
let delta = rowYPos - bottomCutoff
@ -126,7 +126,7 @@ export const createMaxScrollStores = context => {
// Ensure horizontal position is viewable
// Check horizontal position of columns next
const $visibleColumns = get(visibleColumns)
const columnName = $selectedCellId?.split("-")[1]
const columnName = $focusedCellId?.split("-")[1]
const column = $visibleColumns.find(col => col.name === columnName)
const horizontalOffset = 24
if (!column) {

View File

@ -1,7 +1,7 @@
import { writable, get } from "svelte/store"
export const createMenuStores = context => {
const { bounds, selectedCellId, stickyColumn, rowHeight } = context
const { bounds, focusedCellId, stickyColumn, rowHeight } = context
const menu = writable({
x: 0,
y: 0,
@ -14,7 +14,7 @@ export const createMenuStores = context => {
const $stickyColumn = get(stickyColumn)
const $rowHeight = get(rowHeight)
e.preventDefault()
selectedCellId.set(cellId)
focusedCellId.set(cellId)
menu.set({
left: e.clientX - $bounds.left + 44 + ($stickyColumn?.width || 0),
top: e.clientY - $bounds.top + $rowHeight + 4,

View File

@ -3,7 +3,7 @@ import { fetchData } from "../../../fetch/fetchData"
import { notifications } from "@budibase/bbui"
export const createRowsStore = context => {
const { tableId, API, scroll } = context
const { tableId, API, scroll, validation } = context
const rows = writable([])
const table = writable(null)
const filter = writable([])
@ -120,6 +120,21 @@ export const createRowsStore = context => {
return index >= 0 ? get(enrichedRows)[index] : null
}
// Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => {
if (error?.json?.validationErrors) {
for (let column of Object.keys(error.json.validationErrors)) {
validation.actions.setError(
`${rowId}-${column}`,
`${column} ${error.json.validationErrors[column]}`
)
}
} else {
notifications.error(`Error saving row: ${error?.message}`)
}
}
// Adds a new row
const addRow = async (row, idx) => {
try {
@ -139,7 +154,7 @@ export const createRowsStore = context => {
notifications.success("Row created successfully")
return newRow
} catch (error) {
notifications.error(`Error adding row: ${error?.message}`)
handleValidationError("new", error)
}
}
@ -208,7 +223,7 @@ export const createRowsStore = context => {
try {
await API.saveRow(newRow)
} catch (error) {
notifications.error(`Error saving row: ${error?.message}`)
handleValidationError(newRow._id, error)
// Revert change
rows.update(state => {

View File

@ -0,0 +1,66 @@
import { derived, get, writable } from "svelte/store"
export const createSheetAPIStores = context => {
const { focusedCellId } = context
const cellAPIs = writable({})
const registerCellAPI = (cellId, api) => {
// Ignore registration if cell is not selected
const [rowId, column] = cellId.split("-")
if (rowId !== "new" && !get(focusedCellId)?.startsWith(rowId)) {
return
}
// Store API
cellAPIs.update(state => ({
...state,
[column]: api,
}))
}
const getCellAPI = column => {
return get(cellAPIs)[column]
}
// Derive the selected cell API
const selectedCellAPI = derived(
[cellAPIs, focusedCellId],
([$apis, $focusedCellId]) => {
if (!$focusedCellId) {
return null
}
const [, column] = $focusedCellId.split("-")
return $apis[column]
},
null
)
const focusedRowAPI = derived(cellAPIs, $apis => {
return {
validate: () => {
let errors = null
for (let [column, api] of Object.entries($apis || {})) {
const error = api.validate()
if (error) {
errors = {
...errors,
[column]: error,
}
}
}
return errors
},
}
})
return {
selectedCellAPI,
focusedRowAPI,
sheetAPI: {
actions: {
registerCellAPI,
getCellAPI,
},
},
}
}

View File

@ -2,17 +2,16 @@ import { writable, get, derived } from "svelte/store"
export const createUIStores = context => {
const { rows, rowLookupMap } = context
const selectedCellId = writable(null)
const focusedCellId = writable(null)
const selectedRows = writable({})
const hoveredRowId = writable(null)
const selectedCellAPI = writable(null)
const rowHeight = writable(36)
// Derive the row that contains the selected cell
const selectedCellRow = derived(
[selectedCellId, rowLookupMap, rows],
([$selectedCellId, $rowLookupMap, $rows]) => {
const rowId = $selectedCellId?.split("-")[0]
const focusedRow = derived(
[focusedCellId, rowLookupMap, rows],
([$focusedCellId, $rowLookupMap, $rows]) => {
const rowId = $focusedCellId?.split("-")[0]
const index = $rowLookupMap[rowId]
return $rows[index]
},
@ -21,15 +20,15 @@ export const createUIStores = context => {
// Ensure we clear invalid rows from state if they disappear
rows.subscribe(() => {
const $selectedCellId = get(selectedCellId)
const $focusedCellId = get(focusedCellId)
const $selectedRows = get(selectedRows)
const $hoveredRowId = get(hoveredRowId)
const hasRow = rows.actions.hasRow
// Check selected cell
const selectedRowId = $selectedCellId?.split("-")[0]
const selectedRowId = $focusedCellId?.split("-")[0]
if (selectedRowId && !hasRow(selectedRowId)) {
selectedCellId.set(null)
focusedCellId.set(null)
}
// Check hovered row
@ -53,7 +52,7 @@ export const createUIStores = context => {
})
// Reset selected rows when selected cell changes
selectedCellId.subscribe(id => {
focusedCellId.subscribe(id => {
if (id) {
selectedRows.set({})
}
@ -62,37 +61,29 @@ export const createUIStores = context => {
// Unset selected cell when rows are selected
selectedRows.subscribe(rows => {
if (Object.keys(rows || {}).length) {
selectedCellId.set(null)
focusedCellId.set(null)
}
})
// Callback when leaving the sheet, deselecting all focussed or selected items
const blur = () => {
selectedCellId.set(null)
focusedCellId.set(null)
selectedRows.set({})
hoveredRowId.set(null)
}
// Remove selected cell API when no selected cell is present
selectedCellId.subscribe(cell => {
if (!cell && get(selectedCellAPI)) {
selectedCellAPI.set(null)
}
})
// Remove hovered row when a cell is selected
selectedCellId.subscribe(cell => {
focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) {
hoveredRowId.set(null)
}
})
return {
selectedCellId,
focusedCellId,
selectedRows,
hoveredRowId,
selectedCellRow,
selectedCellAPI,
focusedRow,
rowHeight,
ui: {
actions: {

View File

@ -62,8 +62,8 @@ export const createUserStores = () => {
([$enrichedUsers, $userId]) => {
let map = {}
$enrichedUsers.forEach(user => {
if (user.selectedCellId && user.id !== $userId) {
map[user.selectedCellId] = user
if (user.focusedCellId && user.id !== $userId) {
map[user.focusedCellId] = user
}
})
return map

View File

@ -0,0 +1,25 @@
import { writable, get } from "svelte/store"
export const createValidationStores = () => {
const validation = writable({})
return {
validation: {
subscribe: validation.subscribe,
actions: {
setError: (cellId, error) => {
if (!cellId) {
return
}
validation.update(state => ({
...state,
[cellId]: error,
}))
},
getError: cellId => {
return get(validation)[cellId]
},
},
},
}
}

View File

@ -77,10 +77,28 @@ export const createViewportStores = context => {
[]
)
const hiddenColumnsWidth = derived(
[renderedColumns, visibleColumns],
([$renderedColumns, $visibleColumns]) => {
const idx = $visibleColumns.findIndex(
col => col.name === $renderedColumns[0]?.name
)
let width = 0
if (idx > 0) {
for (let i = 0; i < idx; i++) {
width += $visibleColumns[i].width
}
}
return width
},
0
)
return {
scrolledRowCount,
visualRowCapacity,
renderedRows,
renderedColumns,
hiddenColumnsWidth,
}
}