diff --git a/packages/frontend-core/src/components/sheet/cells/DataCell.svelte b/packages/frontend-core/src/components/sheet/cells/DataCell.svelte index 35143ce732..0f846d2b2f 100644 --- a/packages/frontend-core/src/components/sheet/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/sheet/cells/DataCell.svelte @@ -4,7 +4,7 @@ import { getCellRenderer } from "../lib/renderers" import { derived, writable } from "svelte/store" - const { rows, focusedCellId, menu, sheetAPI, config, validation } = + const { rows, focusedCellId, focusedCellAPI, menu, config, validation } = getContext("sheet") export let rowSelected @@ -25,13 +25,6 @@ let api - $: { - // 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) @@ -40,8 +33,8 @@ // Register this cell API if the row is focused $: { - if (rowFocused) { - sheetAPI.actions.registerCellAPI(cellId, cellAPI) + if (focused) { + focusedCellAPI.set(cellAPI) } } @@ -57,30 +50,9 @@ blur: () => api?.blur(), onKeyDown: (...params) => api?.onKeyDown(...params), 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 - newError = "Auto columns can't be edited" - } else if (cellAPI.isRequired() && (value == null || value === "")) { - // Sanity check required fields - newError = "Required field" - } else { - newError = null - } - validation.actions.setError(cellId, newError) - return newError - }, updateValue: value => { - cellAPI.validate(value) - if (!$error) { - updateRow(row._id, column.name, value) - } + validation.actions.setError(cellId, null) + updateRow(row._id, column.name, value) }, } diff --git a/packages/frontend-core/src/components/sheet/controls/AddColumnButton.svelte b/packages/frontend-core/src/components/sheet/controls/AddColumnButton.svelte new file mode 100644 index 0000000000..56cec9126f --- /dev/null +++ b/packages/frontend-core/src/components/sheet/controls/AddColumnButton.svelte @@ -0,0 +1,16 @@ + + + dispatch("add-column")} + disabled={!$config.allowAddColumns} +> + Create column + diff --git a/packages/frontend-core/src/components/sheet/controls/AddRowButton.svelte b/packages/frontend-core/src/components/sheet/controls/AddRowButton.svelte index f961cabc19..41b1ff402d 100644 --- a/packages/frontend-core/src/components/sheet/controls/AddRowButton.svelte +++ b/packages/frontend-core/src/components/sheet/controls/AddRowButton.svelte @@ -2,15 +2,15 @@ import { ActionButton } from "@budibase/bbui" import { getContext } from "svelte" - const { dispatch, columns, stickyColumn } = getContext("sheet") + const { dispatch, columns, stickyColumn, config } = getContext("sheet") dispatch("add-row-inline")} - disabled={!$columns.length && !$stickyColumn} + on:click={() => dispatch("add-row")} + disabled={!$config.allowAddRows || (!$columns.length && !$stickyColumn)} > Create row diff --git a/packages/frontend-core/src/components/sheet/layout/HeaderRow.svelte b/packages/frontend-core/src/components/sheet/layout/HeaderRow.svelte index efbf5cfdbe..e28c0eb545 100644 --- a/packages/frontend-core/src/components/sheet/layout/HeaderRow.svelte +++ b/packages/frontend-core/src/components/sheet/layout/HeaderRow.svelte @@ -2,14 +2,8 @@ import { getContext } from "svelte" import SheetScrollWrapper from "./SheetScrollWrapper.svelte" import HeaderCell from "../cells/HeaderCell.svelte" - import { Icon } from "@budibase/bbui" - const { renderedColumns, dispatch, config, ui } = getContext("sheet") - - const addColumn = () => { - ui.actions.blur() - dispatch("add-column") - } + const { renderedColumns } = getContext("sheet")
@@ -20,11 +14,6 @@ {/each}
- {#if $config.allowAddColumns} -
- -
- {/if} diff --git a/packages/frontend-core/src/components/sheet/layout/NewRowTop.svelte b/packages/frontend-core/src/components/sheet/layout/NewRowTop.svelte index 4bd5c3f946..3b332f2b0b 100644 --- a/packages/frontend-core/src/components/sheet/layout/NewRowTop.svelte +++ b/packages/frontend-core/src/components/sheet/layout/NewRowTop.svelte @@ -1,7 +1,7 @@ - - - - - diff --git a/packages/frontend-core/src/components/sheet/layout/StickyColumn.svelte b/packages/frontend-core/src/components/sheet/layout/StickyColumn.svelte index 83a2552806..5ab946ad3f 100644 --- a/packages/frontend-core/src/components/sheet/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/sheet/layout/StickyColumn.svelte @@ -20,9 +20,9 @@ focusedRow, gutterWidth, dispatch, + scrollLeft, } = getContext("sheet") - $: scrollLeft = $scroll.left $: rowCount = $rows.length $: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length $: width = gutterWidth + ($stickyColumn?.width || 0) @@ -91,6 +91,7 @@ {@const rowSelected = !!$selectedRows[row._id]} {@const rowHovered = $hoveredRowId === row._id} {@const rowFocused = $focusedRow?._id === row._id} + {@const cellId = `${row._id}-${$stickyColumn.name}`}
($hoveredRowId = row._id)} @@ -132,9 +133,7 @@ {/if}
- {#if $stickyColumn} - {@const cellId = `${row._id}-${$stickyColumn.name}`} import { getContext, onMount } from "svelte" import { debounce } from "../../../utils/utils" - import { notifications } from "@budibase/bbui" const { rows, @@ -9,7 +8,7 @@ visibleColumns, focusedRow, stickyColumn, - selectedCellAPI, + focusedCellAPI, } = getContext("sheet") const handleKeyDown = e => { @@ -22,7 +21,7 @@ } // Always intercept certain key presses - const api = $selectedCellAPI + const api = $focusedCellAPI if (e.key === "Escape") { api?.blur?.() } else if (e.key === "Tab") { @@ -112,17 +111,17 @@ // Debounce to avoid holding down delete and spamming requests const deleteSelectedCell = debounce(() => { - if (!$focusedCellId) { + if ($focusedCellAPI?.isReadonly()) { return } - $selectedCellAPI.updateValue(null) + $focusedCellAPI.updateValue(null) }, 100) const focusSelectedCell = () => { - if ($selectedCellAPI?.isReadonly()) { + if ($focusedCellAPI?.isReadonly()) { return } - $selectedCellAPI?.focus?.() + $focusedCellAPI?.focus?.() } onMount(() => { diff --git a/packages/frontend-core/src/components/sheet/overlays/ResizeOverlay.svelte b/packages/frontend-core/src/components/sheet/overlays/ResizeOverlay.svelte index 20d6ecd177..fb7397d09e 100644 --- a/packages/frontend-core/src/components/sheet/overlays/ResizeOverlay.svelte +++ b/packages/frontend-core/src/components/sheet/overlays/ResizeOverlay.svelte @@ -9,9 +9,9 @@ stickyColumn, isReordering, gutterWidth, + scrollLeft, } = getContext("sheet") - $: scrollLeft = $scroll.left $: cutoff = scrollLeft + gutterWidth + ($columns[0]?.width || 0) $: offset = gutterWidth + ($stickyColumn?.width || 0) $: column = $resize.column diff --git a/packages/frontend-core/src/components/sheet/overlays/ScrollOverlay.svelte b/packages/frontend-core/src/components/sheet/overlays/ScrollOverlay.svelte index 5478ec7c63..30726292c4 100644 --- a/packages/frontend-core/src/components/sheet/overlays/ScrollOverlay.svelte +++ b/packages/frontend-core/src/components/sheet/overlays/ScrollOverlay.svelte @@ -4,7 +4,6 @@ const { scroll, - bounds, rowHeight, contentHeight, maxScrollTop, @@ -13,6 +12,9 @@ screenWidth, showHScrollbar, showVScrollbar, + scrollLeft, + scrollTop, + height, } = getContext("sheet") // Bar config @@ -22,32 +24,27 @@ let initialMouse let initialScroll - // Memoize store primitives to reduce reactive statement invalidations - $: scrollTop = $scroll.top - $: scrollLeft = $scroll.left - $: height = $bounds.height - $: width = $bounds.width - // Calculate V scrollbar size and offset // Terminology is the same for both axes: // renderX - the space available to render the bar in, edge to edge // availX - the space available to render the bar in, until the edge - $: renderHeight = height - 2 * barOffset - $: barHeight = Math.max(50, (height / $contentHeight) * renderHeight) + $: renderHeight = $height - 2 * barOffset + $: barHeight = Math.max(50, ($height / $contentHeight) * renderHeight) $: availHeight = renderHeight - barHeight - $: barTop = barOffset + $rowHeight + availHeight * (scrollTop / $maxScrollTop) + $: barTop = + barOffset + $rowHeight + availHeight * ($scrollTop / $maxScrollTop) // Calculate H scrollbar size and offset $: renderWidth = $screenWidth - 2 * barOffset $: barWidth = Math.max(50, ($screenWidth / $contentWidth) * renderWidth) $: availWidth = renderWidth - barWidth - $: barLeft = barOffset + availWidth * (scrollLeft / $maxScrollLeft) + $: barLeft = barOffset + availWidth * ($scrollLeft / $maxScrollLeft) // V scrollbar drag handlers const startVDragging = e => { e.preventDefault() initialMouse = e.clientY - initialScroll = scrollTop + initialScroll = $scrollTop document.addEventListener("mousemove", moveVDragging) document.addEventListener("mouseup", stopVDragging) } @@ -69,7 +66,7 @@ const startHDragging = e => { e.preventDefault() initialMouse = e.clientX - initialScroll = scrollLeft + initialScroll = $scrollLeft document.addEventListener("mousemove", moveHDragging) document.addEventListener("mouseup", stopHDragging) } diff --git a/packages/frontend-core/src/components/sheet/stores/bounds.js b/packages/frontend-core/src/components/sheet/stores/bounds.js index 37a16e7362..c0939f7389 100644 --- a/packages/frontend-core/src/components/sheet/stores/bounds.js +++ b/packages/frontend-core/src/components/sheet/stores/bounds.js @@ -1,11 +1,16 @@ -import { writable } from "svelte/store" +import { derived, writable } from "svelte/store" -export const createBoundsStores = () => { +export const createStores = () => { const bounds = writable({ left: 0, top: 0, width: 0, height: 0, }) - return { bounds } + + // Derive height and width as primitives to avoid wasted computation + const width = derived(bounds, $bounds => $bounds.width, 0) + const height = derived(bounds, $bounds => $bounds.height, 0) + + return { bounds, height, width } } diff --git a/packages/frontend-core/src/components/sheet/stores/columns.js b/packages/frontend-core/src/components/sheet/stores/columns.js index 47cc56e012..7a3131012a 100644 --- a/packages/frontend-core/src/components/sheet/stores/columns.js +++ b/packages/frontend-core/src/components/sheet/stores/columns.js @@ -2,8 +2,7 @@ import { derived, get, writable } from "svelte/store" export const DefaultColumnWidth = 200 -export const createColumnsStores = context => { - const { table, gutterWidth } = context +export const createStores = () => { const columns = writable([]) const stickyColumn = writable(null) @@ -36,6 +35,19 @@ export const createColumnsStores = context => { [] ) + return { + columns: { + ...columns, + subscribe: enrichedColumns.subscribe, + }, + stickyColumn, + visibleColumns, + } +} + +export const deriveStores = context => { + const { table, gutterWidth, columns, stickyColumn } = context + // Merge new schema fields with existing schema in order to preserve widths table.subscribe($table => { const schema = $table?.schema @@ -109,12 +121,5 @@ export const createColumnsStores = context => { }) }) - return { - columns: { - ...columns, - subscribe: enrichedColumns.subscribe, - }, - stickyColumn, - visibleColumns, - } + return null } diff --git a/packages/frontend-core/src/components/sheet/stores/index.js b/packages/frontend-core/src/components/sheet/stores/index.js new file mode 100644 index 0000000000..2c88d685e3 --- /dev/null +++ b/packages/frontend-core/src/components/sheet/stores/index.js @@ -0,0 +1,43 @@ +import * as Bounds from "./bounds" +import * as Columns from "./columns" +import * as Menu from "./menu" +import * as Pagination from "./pagination" +import * as Reorder from "./reorder" +import * as Resize from "./resize" +import * as Rows from "./rows" +import * as Scroll from "./scroll" +import * as UI from "./ui" +import * as Users from "./users" +import * as Validation from "./validation" +import * as Viewport from "./viewport" + +const DependencyOrderedStores = [ + Bounds, + Scroll, + Rows, + Columns, + UI, + Validation, + Resize, + Viewport, + Reorder, + Users, + Menu, + Pagination, +] + +export const createStores = context => { + let stores = {} + + // Atomic store creation + for (let store of DependencyOrderedStores) { + stores = { ...stores, ...store.createStores?.({ ...context, ...stores }) } + } + + // Derived store creation + for (let store of DependencyOrderedStores) { + stores = { ...stores, ...store.deriveStores?.({ ...context, ...stores }) } + } + + return stores +} diff --git a/packages/frontend-core/src/components/sheet/stores/max-scroll.js b/packages/frontend-core/src/components/sheet/stores/max-scroll.js deleted file mode 100644 index 28759d0a77..0000000000 --- a/packages/frontend-core/src/components/sheet/stores/max-scroll.js +++ /dev/null @@ -1,182 +0,0 @@ -import { derived, get } from "svelte/store" -import { tick } from "svelte" - -export const createMaxScrollStores = context => { - const { - rows, - visibleColumns, - stickyColumn, - bounds, - rowHeight, - scroll, - focusedRow, - focusedCellId, - gutterWidth, - } = context - const padding = 264 - - // Memoize store primitives - const scrollTop = derived(scroll, $scroll => $scroll.top, 0) - const scrollLeft = derived(scroll, $scroll => $scroll.left, 0) - const stickyColumnWidth = derived(stickyColumn, $col => $col?.width || 0, 0) - - // Derive vertical limits - const height = derived(bounds, $bounds => $bounds.height, 0) - const width = derived(bounds, $bounds => $bounds.width, 0) - const contentHeight = derived( - [rows, rowHeight], - ([$rows, $rowHeight]) => $rows.length * $rowHeight + padding, - 0 - ) - const maxScrollTop = derived( - [height, contentHeight], - ([$height, $contentHeight]) => Math.max($contentHeight - $height, 0), - 0 - ) - - // Derive horizontal limits - const contentWidth = derived( - [visibleColumns, stickyColumnWidth], - ([$visibleColumns, $stickyColumnWidth]) => { - let width = gutterWidth + padding + $stickyColumnWidth - $visibleColumns.forEach(col => { - width += col.width - }) - return width - }, - 0 - ) - const screenWidth = derived( - [width, stickyColumnWidth], - ([$width, $stickyColumnWidth]) => $width + gutterWidth + $stickyColumnWidth, - 0 - ) - const maxScrollLeft = derived( - [contentWidth, screenWidth], - ([$contentWidth, $screenWidth]) => { - return Math.max($contentWidth - $screenWidth, 0) - }, - 0 - ) - - // Ensure scroll state never goes invalid, which can happen when changing - // rows or tables - const overscrollTop = derived( - [scrollTop, maxScrollTop], - ([$scrollTop, $maxScrollTop]) => $scrollTop > $maxScrollTop, - false - ) - const overscrollLeft = derived( - [scrollLeft, maxScrollLeft], - ([$scrollLeft, $maxScrollLeft]) => $scrollLeft > $maxScrollLeft, - false - ) - overscrollTop.subscribe(overscroll => { - if (overscroll) { - scroll.update(state => ({ - ...state, - top: get(maxScrollTop), - })) - } - }) - overscrollLeft.subscribe(overscroll => { - if (overscroll) { - scroll.update(state => ({ - ...state, - left: get(maxScrollLeft), - })) - } - }) - - // Ensure the selected cell is visible - focusedCellId.subscribe(async $focusedCellId => { - await tick() - 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 ($focusedRow) { - // Ensure row is not below bottom of screen - const rowYPos = $focusedRow.__idx * $rowHeight - const bottomCutoff = - $scroll.top + $bounds.height - $rowHeight - verticalOffset - let delta = rowYPos - bottomCutoff - if (delta > 0) { - scroll.update(state => ({ - ...state, - top: state.top + delta, - })) - } - - // Ensure row is not above top of screen - else { - const delta = $scroll.top - rowYPos + verticalOffset - if (delta > 0) { - scroll.update(state => ({ - ...state, - top: Math.max(0, state.top - delta), - })) - } - } - } - - // Ensure horizontal position is viewable - // Check horizontal position of columns next - const $visibleColumns = get(visibleColumns) - const columnName = $focusedCellId?.split("-")[1] - const column = $visibleColumns.find(col => col.name === columnName) - const horizontalOffset = 24 - if (!column) { - return - } - - // Ensure column is not cutoff on left edge - let delta = $scroll.left - column.left + horizontalOffset - if (delta > 0) { - scroll.update(state => ({ - ...state, - left: Math.max(0, state.left - delta), - })) - } - - // Ensure column is not cutoff on right edge - else { - const rightEdge = column.left + column.width - const rightBound = $bounds.width + $scroll.left - horizontalOffset - delta = rightEdge - rightBound - if (delta > 0) { - scroll.update(state => ({ - ...state, - left: state.left + delta, - })) - } - } - }) - - // Derive whether to show scrollbars or not - const showVScrollbar = derived( - [contentHeight, height], - ([$contentHeight, $height]) => { - return $contentHeight > $height - } - ) - const showHScrollbar = derived( - [contentWidth, screenWidth], - ([$contentWidth, $screenWidth]) => { - return $contentWidth > $screenWidth - } - ) - - return { - contentHeight, - contentWidth, - screenWidth, - maxScrollTop, - maxScrollLeft, - showHScrollbar, - showVScrollbar, - } -} diff --git a/packages/frontend-core/src/components/sheet/stores/menu.js b/packages/frontend-core/src/components/sheet/stores/menu.js index 0b5dfc0641..d350137287 100644 --- a/packages/frontend-core/src/components/sheet/stores/menu.js +++ b/packages/frontend-core/src/components/sheet/stores/menu.js @@ -1,13 +1,19 @@ import { writable, get } from "svelte/store" -export const createMenuStores = context => { - const { bounds, focusedCellId, stickyColumn, rowHeight } = context +export const createStores = () => { const menu = writable({ x: 0, y: 0, visible: false, selectedRow: null, }) + return { + menu, + } +} + +export const deriveStores = context => { + const { menu, bounds, focusedCellId, stickyColumn, rowHeight } = context const open = (cellId, e) => { const $bounds = get(bounds) diff --git a/packages/frontend-core/src/components/sheet/stores/pagination.js b/packages/frontend-core/src/components/sheet/stores/pagination.js index 24bd10f19e..ad50f1fe8d 100644 --- a/packages/frontend-core/src/components/sheet/stores/pagination.js +++ b/packages/frontend-core/src/components/sheet/stores/pagination.js @@ -1,6 +1,6 @@ import { derived } from "svelte/store" -export const createPaginationStores = context => { +export const deriveStores = context => { const { scrolledRowCount, rows, visualRowCapacity } = context // Derive how many rows we have in total diff --git a/packages/frontend-core/src/components/sheet/stores/reorder.js b/packages/frontend-core/src/components/sheet/stores/reorder.js index 2200707682..525a895063 100644 --- a/packages/frontend-core/src/components/sheet/stores/reorder.js +++ b/packages/frontend-core/src/components/sheet/stores/reorder.js @@ -1,21 +1,29 @@ import { get, writable, derived } from "svelte/store" -export const createReorderStores = context => { - const { columns, scroll, bounds, stickyColumn, ui } = context - const reorderInitialState = { - sourceColumn: null, - targetColumn: null, - breakpoints: [], - initialMouseX: null, - scrollLeft: 0, - sheetLeft: 0, - } +const reorderInitialState = { + sourceColumn: null, + targetColumn: null, + breakpoints: [], + initialMouseX: null, + scrollLeft: 0, + sheetLeft: 0, +} + +export const createStores = () => { const reorder = writable(reorderInitialState) const isReordering = derived( reorder, $reorder => !!$reorder.sourceColumn, false ) + return { + reorder, + isReordering, + } +} + +export const deriveStores = context => { + const { reorder, columns, scroll, bounds, stickyColumn, ui } = context // Callback when dragging on a colum header and starting reordering const startReordering = (column, e) => { @@ -142,6 +150,5 @@ export const createReorderStores = context => { moveColumnRight, }, }, - isReordering, } } diff --git a/packages/frontend-core/src/components/sheet/stores/resize.js b/packages/frontend-core/src/components/sheet/stores/resize.js index 50d544a1ee..5e3ec4a0e8 100644 --- a/packages/frontend-core/src/components/sheet/stores/resize.js +++ b/packages/frontend-core/src/components/sheet/stores/resize.js @@ -3,18 +3,26 @@ import { DefaultColumnWidth } from "./columns" export const MinColumnWidth = 100 -export const createResizeStores = context => { - const { columns, stickyColumn, ui } = context - const initialState = { - initialMouseX: null, - initialWidth: null, - column: null, - columnIdx: null, - width: 0, - left: 0, - } +const initialState = { + initialMouseX: null, + initialWidth: null, + column: null, + columnIdx: null, + width: 0, + left: 0, +} + +export const createStores = () => { const resize = writable(initialState) const isResizing = derived(resize, $resize => $resize.column != null, false) + return { + resize, + isResizing, + } +} + +export const deriveStores = context => { + const { resize, columns, stickyColumn, ui } = context // Starts resizing a certain column const startResizing = (column, e) => { @@ -105,6 +113,5 @@ export const createResizeStores = context => { resetSize, }, }, - isResizing, } } diff --git a/packages/frontend-core/src/components/sheet/stores/rows.js b/packages/frontend-core/src/components/sheet/stores/rows.js index df301c45c4..ff83b5ce8a 100644 --- a/packages/frontend-core/src/components/sheet/stores/rows.js +++ b/packages/frontend-core/src/components/sheet/stores/rows.js @@ -2,18 +2,16 @@ import { writable, derived, get } from "svelte/store" import { fetchData } from "../../../fetch/fetchData" import { notifications } from "@budibase/bbui" -export const createRowsStore = context => { - const { tableId, API, scroll, validation } = context +const initialSortState = { + column: null, + order: "ascending", +} + +export const createStores = () => { const rows = writable([]) const table = writable(null) const filter = writable([]) const loaded = writable(false) - const instanceLoaded = writable(false) - const fetch = writable(null) - const initialSortState = { - column: null, - order: "ascending", - } const sort = writable(initialSortState) // Enrich rows with an index property @@ -41,6 +39,37 @@ export const createRowsStore = context => { {} ) + return { + rows: { + ...rows, + subscribe: enrichedRows.subscribe, + }, + rowLookupMap, + table, + filter, + loaded, + sort, + } +} + +export const deriveStores = context => { + const { + rows, + rowLookupMap, + table, + filter, + loaded, + sort, + tableId, + API, + scroll, + validation, + focusedCellId, + columns, + } = context + const instanceLoaded = writable(false) + const fetch = writable(null) + // Local cache of row IDs to speed up checking if a row exists let rowCacheMap = {} @@ -117,19 +146,32 @@ export const createRowsStore = context => { // Gets a row by ID const getRow = id => { const index = get(rowLookupMap)[id] - return index >= 0 ? get(enrichedRows)[index] : null + 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) => { if (error?.json?.validationErrors) { - for (let column of Object.keys(error.json.validationErrors)) { + const keys = Object.keys(error.json.validationErrors) + const $columns = get(columns) + for (let column of keys) { validation.actions.setError( `${rowId}-${column}`, `${column} ${error.json.validationErrors[column]}` ) + + // Ensure the column is visible + const index = $columns.findIndex(x => x.name === column) + if (index !== -1 && !$columns[index].visible) { + columns.update(state => { + state[index].visible = true + return state.slice() + }) + } } + // Focus the first cell with an error + focusedCellId.set(`${rowId}-${keys[0]}`) } else { notifications.error(`Error saving row: ${error?.message}`) } @@ -299,7 +341,6 @@ export const createRowsStore = context => { return { rows: { ...rows, - subscribe: enrichedRows.subscribe, actions: { addRow, getRow, @@ -312,10 +353,5 @@ export const createRowsStore = context => { refreshTableDefinition, }, }, - rowLookupMap, - table, - sort, - filter, - loaded, } } diff --git a/packages/frontend-core/src/components/sheet/stores/scroll.js b/packages/frontend-core/src/components/sheet/stores/scroll.js index e0f33c7d87..ea80760420 100644 --- a/packages/frontend-core/src/components/sheet/stores/scroll.js +++ b/packages/frontend-core/src/components/sheet/stores/scroll.js @@ -1,11 +1,199 @@ -import { writable } from "svelte/store" +import { writable, derived, get } from "svelte/store" +import { tick } from "svelte" -export const createScrollStores = () => { +export const createStores = () => { const scroll = writable({ left: 0, top: 0, }) + + // 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) + return { scroll, + scrollTop, + scrollLeft, + } +} + +export const deriveStores = context => { + const { + scroll, + rows, + visibleColumns, + stickyColumn, + bounds, + rowHeight, + focusedRow, + focusedCellId, + gutterWidth, + } = context + const padding = 264 + + // Memoize store primitives + const scrollTop = derived(scroll, $scroll => $scroll.top, 0) + const scrollLeft = derived(scroll, $scroll => $scroll.left, 0) + const stickyColumnWidth = derived(stickyColumn, $col => $col?.width || 0, 0) + + // Derive vertical limits + const height = derived(bounds, $bounds => $bounds.height, 0) + const width = derived(bounds, $bounds => $bounds.width, 0) + const contentHeight = derived( + [rows, rowHeight], + ([$rows, $rowHeight]) => $rows.length * $rowHeight + padding, + 0 + ) + const maxScrollTop = derived( + [height, contentHeight], + ([$height, $contentHeight]) => Math.max($contentHeight - $height, 0), + 0 + ) + + // Derive horizontal limits + const contentWidth = derived( + [visibleColumns, stickyColumnWidth], + ([$visibleColumns, $stickyColumnWidth]) => { + let width = gutterWidth + padding + $stickyColumnWidth + $visibleColumns.forEach(col => { + width += col.width + }) + return width + }, + 0 + ) + const screenWidth = derived( + [width, stickyColumnWidth], + ([$width, $stickyColumnWidth]) => $width + gutterWidth + $stickyColumnWidth, + 0 + ) + const maxScrollLeft = derived( + [contentWidth, screenWidth], + ([$contentWidth, $screenWidth]) => { + return Math.max($contentWidth - $screenWidth, 0) + }, + 0 + ) + + // Ensure scroll state never goes invalid, which can happen when changing + // rows or tables + const overscrollTop = derived( + [scrollTop, maxScrollTop], + ([$scrollTop, $maxScrollTop]) => $scrollTop > $maxScrollTop, + false + ) + const overscrollLeft = derived( + [scrollLeft, maxScrollLeft], + ([$scrollLeft, $maxScrollLeft]) => $scrollLeft > $maxScrollLeft, + false + ) + overscrollTop.subscribe(overscroll => { + if (overscroll) { + scroll.update(state => ({ + ...state, + top: get(maxScrollTop), + })) + } + }) + overscrollLeft.subscribe(overscroll => { + if (overscroll) { + scroll.update(state => ({ + ...state, + left: get(maxScrollLeft), + })) + } + }) + + // Ensure the selected cell is visible + focusedCellId.subscribe(async $focusedCellId => { + await tick() + 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 ($focusedRow) { + // Ensure row is not below bottom of screen + const rowYPos = $focusedRow.__idx * $rowHeight + const bottomCutoff = + $scroll.top + $bounds.height - $rowHeight - verticalOffset + let delta = rowYPos - bottomCutoff + if (delta > 0) { + scroll.update(state => ({ + ...state, + top: state.top + delta, + })) + } + + // Ensure row is not above top of screen + else { + const delta = $scroll.top - rowYPos + verticalOffset + if (delta > 0) { + scroll.update(state => ({ + ...state, + top: Math.max(0, state.top - delta), + })) + } + } + } + + // Ensure horizontal position is viewable + // Check horizontal position of columns next + const $visibleColumns = get(visibleColumns) + const columnName = $focusedCellId?.split("-")[1] + const column = $visibleColumns.find(col => col.name === columnName) + const horizontalOffset = 24 + if (!column) { + return + } + + // Ensure column is not cutoff on left edge + let delta = $scroll.left - column.left + horizontalOffset + if (delta > 0) { + scroll.update(state => ({ + ...state, + left: Math.max(0, state.left - delta), + })) + } + + // Ensure column is not cutoff on right edge + else { + const rightEdge = column.left + column.width + const rightBound = $bounds.width + $scroll.left - horizontalOffset + delta = rightEdge - rightBound + if (delta > 0) { + scroll.update(state => ({ + ...state, + left: state.left + delta, + })) + } + } + }) + + // Derive whether to show scrollbars or not + const showVScrollbar = derived( + [contentHeight, height], + ([$contentHeight, $height]) => { + return $contentHeight > $height + } + ) + const showHScrollbar = derived( + [contentWidth, screenWidth], + ([$contentWidth, $screenWidth]) => { + return $contentWidth > $screenWidth + } + ) + + return { + contentHeight, + contentWidth, + screenWidth, + maxScrollTop, + maxScrollLeft, + showHScrollbar, + showVScrollbar, } } diff --git a/packages/frontend-core/src/components/sheet/stores/sheet-api.js b/packages/frontend-core/src/components/sheet/stores/sheet-api.js deleted file mode 100644 index 45d30f9452..0000000000 --- a/packages/frontend-core/src/components/sheet/stores/sheet-api.js +++ /dev/null @@ -1,66 +0,0 @@ -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, - }, - }, - } -} diff --git a/packages/frontend-core/src/components/sheet/stores/ui.js b/packages/frontend-core/src/components/sheet/stores/ui.js index e600a83aa3..75ebeb8735 100644 --- a/packages/frontend-core/src/components/sheet/stores/ui.js +++ b/packages/frontend-core/src/components/sheet/stores/ui.js @@ -1,19 +1,38 @@ import { writable, get, derived } from "svelte/store" -export const createUIStores = context => { - const { rows, rowLookupMap } = context +export const createStores = () => { const focusedCellId = writable(null) + const focusedCellAPI = writable(null) const selectedRows = writable({}) const hoveredRowId = writable(null) const rowHeight = writable(36) + return { + focusedCellId, + focusedCellAPI, + selectedRows, + hoveredRowId, + rowHeight, + } +} + +export const deriveStores = context => { + const { focusedCellId, selectedRows, hoveredRowId, rows, rowLookupMap } = + context // Derive the row that contains the selected cell const focusedRow = derived( [focusedCellId, rowLookupMap, rows], ([$focusedCellId, $rowLookupMap, $rows]) => { const rowId = $focusedCellId?.split("-")[0] - const index = $rowLookupMap[rowId] - return $rows[index] + + if (rowId === "new") { + // Edge case for new row + return { _id: rowId } + } else { + // All normal rows + const index = $rowLookupMap[rowId] + return $rows[index] + } }, null ) @@ -80,11 +99,7 @@ export const createUIStores = context => { }) return { - focusedCellId, - selectedRows, - hoveredRowId, focusedRow, - rowHeight, ui: { actions: { blur, diff --git a/packages/frontend-core/src/components/sheet/stores/users.js b/packages/frontend-core/src/components/sheet/stores/users.js index 5a2f74e67e..4f89bb9a5b 100644 --- a/packages/frontend-core/src/components/sheet/stores/users.js +++ b/packages/frontend-core/src/components/sheet/stores/users.js @@ -1,6 +1,6 @@ import { writable, get, derived } from "svelte/store" -export const createUserStores = () => { +export const createStores = () => { const users = writable([]) const userId = writable(null) @@ -55,10 +55,22 @@ export const createUserStores = () => { [] ) + return { + users: { + ...users, + subscribe: enrichedUsers.subscribe, + }, + userId, + } +} + +export const deriveStores = context => { + const { users, userId } = context + // Generate a lookup map of cell ID to the user that has it selected, to make // lookups inside sheet cells extremely fast const selectedCellMap = derived( - [enrichedUsers, userId], + [users, userId], ([$enrichedUsers, $userId]) => { let map = {} $enrichedUsers.forEach(user => { @@ -92,15 +104,12 @@ export const createUserStores = () => { return { users: { - ...enrichedUsers, - set: users.set, - update: users.update, + ...users, actions: { updateUser, removeUser, }, }, selectedCellMap, - userId, } } diff --git a/packages/frontend-core/src/components/sheet/stores/validation.js b/packages/frontend-core/src/components/sheet/stores/validation.js index d3217038a1..afa2ae3033 100644 --- a/packages/frontend-core/src/components/sheet/stores/validation.js +++ b/packages/frontend-core/src/components/sheet/stores/validation.js @@ -1,25 +1,52 @@ -import { writable, get } from "svelte/store" +import { writable, get, derived } from "svelte/store" -export const createValidationStores = () => { +export const createStores = () => { const validation = writable({}) + const setError = (cellId, error) => { + if (!cellId) { + return + } + validation.update(state => ({ + ...state, + [cellId]: error, + })) + } + return { validation: { - subscribe: validation.subscribe, + ...validation, actions: { - setError: (cellId, error) => { - if (!cellId) { - return - } - validation.update(state => ({ - ...state, - [cellId]: error, - })) - }, - getError: cellId => { - return get(validation)[cellId] - }, + setError, }, }, } } + +export const deriveStores = context => { + const { validation, focusedRow, columns, stickyColumn } = context + const focusedRowId = derived(focusedRow, $focusedRow => $focusedRow?._id) + + // Store the row ID that was previously focused, so we can remove errors from + // it when we focus a new row + let previousFocusedRowId = null + focusedRowId.subscribe(id => { + // Remove validation errors from previous focused row + if (previousFocusedRowId) { + const $columns = get(columns) + const $stickyColumn = get(stickyColumn) + validation.update(state => { + $columns.forEach(column => { + state[`${previousFocusedRowId}-${column.name}`] = null + }) + if ($stickyColumn) { + state[`${previousFocusedRowId}-${$stickyColumn.name}`] = null + } + return state + }) + } + + // Store row ID + previousFocusedRowId = id + }) +} diff --git a/packages/frontend-core/src/components/sheet/stores/viewport.js b/packages/frontend-core/src/components/sheet/stores/viewport.js index d2f29dd558..679e32ad29 100644 --- a/packages/frontend-core/src/components/sheet/stores/viewport.js +++ b/packages/frontend-core/src/components/sheet/stores/viewport.js @@ -1,13 +1,15 @@ import { derived, get } from "svelte/store" -export const createViewportStores = context => { - const { rowHeight, visibleColumns, rows, scroll, bounds } = context - const scrollTop = derived(scroll, $scroll => $scroll.top, 0) - const scrollLeft = derived(scroll, $scroll => $scroll.left, 0) - - // Derive height and width as primitives to avoid wasted computation - const width = derived(bounds, $bounds => $bounds.width, 0) - const height = derived(bounds, $bounds => $bounds.height, 0) +export const deriveStores = context => { + const { + rowHeight, + visibleColumns, + rows, + scrollTop, + scrollLeft, + width, + height, + } = context // Derive visible rows // Split into multiple stores containing primitives to optimise invalidation