Merge pull request #10962 from Budibase/grid-block

Grid block
This commit is contained in:
Andrew Kingston 2023-06-26 11:32:04 +01:00 committed by GitHub
commit 325818748e
60 changed files with 899 additions and 399 deletions

View File

@ -44,13 +44,15 @@
<Grid <Grid
{API} {API}
tableId={id} tableId={id}
tableType={$tables.selected?.type}
allowAddRows={!isUsersTable} allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable} allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false} showAvatars={false}
on:updatetable={handleGridTableUpdate} on:updatetable={handleGridTableUpdate}
> >
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} {#if isInternal}
<GridCreateViewButton /> <GridCreateViewButton />
@ -65,7 +67,6 @@
<GridImportButton /> <GridImportButton />
{/if} {/if}
<GridExportButton /> <GridExportButton />
<GridFilterButton />
<GridAddColumnModal /> <GridAddColumnModal />
<GridEditColumnModal /> <GridEditColumnModal />
{#if isUsersTable} {#if isUsersTable}

View File

@ -14,6 +14,12 @@
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(filters)
const getText = filters => {
const count = filters?.length
return count ? `Filter (${count})` : "Filter"
}
</script> </script>
<ActionButton <ActionButton
@ -23,7 +29,7 @@
on:click={modal.show} on:click={modal.show}
selected={tempValue?.length > 0} selected={tempValue?.length > 0}
> >
Filter {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent <ModalContent

View File

@ -4,6 +4,9 @@
const { columns, tableId, filter, table } = getContext("grid") const { columns, tableId, filter, table } = getContext("grid")
// Wipe filter whenever table ID changes to avoid using stale filters
$: $tableId, filter.set([])
const onFilter = e => { const onFilter = e => {
filter.set(e.detail || []) filter.set(e.detail || [])
} }

View File

@ -4,12 +4,12 @@
export let disabled = false export let disabled = false
const { rows, tableId, tableType } = getContext("grid") const { rows, tableId, table } = getContext("grid")
</script> </script>
<ImportButton <ImportButton
{disabled} {disabled}
tableId={$tableId} tableId={$tableId}
{tableType} tableType={$table?.type}
on:importrows={rows.actions.refreshData} on:importrows={rows.actions.refreshData}
/> />

View File

@ -14,6 +14,7 @@
export let tableId export let tableId
export let tableType export let tableType
let rows = [] let rows = []
let allValid = false let allValid = false
let displayColumn = null let displayColumn = null

View File

@ -2,9 +2,4 @@
import ColumnEditor from "./ColumnEditor.svelte" import ColumnEditor from "./ColumnEditor.svelte"
</script> </script>
<ColumnEditor <ColumnEditor {...$$props} on:change allowCellEditing={false} />
{...$$props}
on:change
allowCellEditing={false}
subject="Dynamic Filter"
/>

View File

@ -142,10 +142,10 @@
<div class="column"> <div class="column">
<div class="wide"> <div class="wide">
<Body size="S"> <Body size="S">
By default, all table columns will automatically be shown. By default, all columns will automatically be shown.
<br /> <br />
You can manually control which columns are included in your table, You can manually control which columns are included by adding them
and their appearance, by adding them below. below.
</Body> </Body>
</div> </div>
</div> </div>

View File

@ -13,7 +13,6 @@
export let componentInstance export let componentInstance
export let value = [] export let value = []
export let allowCellEditing = true export let allowCellEditing = true
export let subject = "Table"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -75,11 +74,10 @@
} }
</script> </script>
<ActionButton on:click={open}>Configure columns</ActionButton> <div class="column-editor">
<Drawer bind:this={drawer} title="{subject} Columns"> <ActionButton on:click={open}>Configure columns</ActionButton>
<svelte:fragment slot="description"> </div>
Configure the columns in your {subject.toLowerCase()}. <Drawer bind:this={drawer} title="Columns">
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button> <Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer <ColumnDrawer
slot="body" slot="body"
@ -89,3 +87,9 @@
{allowCellEditing} {allowCellEditing}
/> />
</Drawer> </Drawer>
<style>
.column-editor :global(.spectrum-ActionButton) {
width: 100%;
}
</style>

View File

@ -1,7 +1,7 @@
<script> <script>
import { Button, ActionButton, Drawer } from "@budibase/bbui" import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte" import ColumnDrawer from "./ColumnEditor/ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
getDatasourceForProvider, getDatasourceForProvider,

View File

@ -20,15 +20,26 @@
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource)?.schema $: schema = getSchemaForDatasource($currentAsset, datasource)?.schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(value)
async function saveFilter() { async function saveFilter() {
dispatch("change", tempValue) dispatch("change", tempValue)
notifications.success("Filters saved") notifications.success("Filters saved")
drawer.hide() drawer.hide()
} }
const getText = filters => {
if (!filters?.length) {
return "No filters set"
} else {
return `${filters.length} filter${filters.length === 1 ? "" : "s"} set`
}
}
</script> </script>
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <div class="filter-editor">
<ActionButton on:click={drawer.show}>{text}</ActionButton>
</div>
<Drawer bind:this={drawer} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button> <Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<FilterDrawer <FilterDrawer
@ -40,3 +51,9 @@
on:change={e => (tempValue = e.detail)} on:change={e => (tempValue = e.detail)}
/> />
</Drawer> </Drawer>
<style>
.filter-editor :global(.spectrum-ActionButton) {
width: 100%;
}
</style>

View File

@ -3,6 +3,7 @@
"name": "Blocks", "name": "Blocks",
"icon": "Article", "icon": "Article",
"children": [ "children": [
"gridblock",
"tableblock", "tableblock",
"cardsblock", "cardsblock",
"repeaterblock", "repeaterblock",

View File

@ -5230,5 +5230,91 @@
"type": "schema", "type": "schema",
"suffix": "repeater" "suffix": "repeater"
} }
},
"gridblock": {
"name": "Grid block",
"icon": "Table",
"styles": ["size"],
"size": {
"width": 600,
"height": 400
},
"info": "Grid Blocks are only compatible with internal or SQL tables",
"settings": [
{
"type": "table",
"label": "Table",
"key": "table",
"required": true
},
{
"type": "columns/basic",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
},
{
"type": "filter",
"label": "Filtering",
"key": "initialFilter"
},
{
"type": "field/sortable",
"label": "Sort column",
"key": "initialSortColumn",
"placeholder": "Default"
},
{
"type": "select",
"label": "Sort order",
"key": "initialSortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Ascending"
},
{
"type": "select",
"label": "Row height",
"key": "initialRowHeight",
"placeholder": "Default",
"options": [
{
"label": "Small",
"value": 36
},
{
"label": "Medium",
"value": 64
},
{
"label": "Large",
"value": 92
}
]
},
{
"type": "boolean",
"label": "Add rows",
"key": "allowAddRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "Edit rows",
"key": "allowEditRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "Delete rows",
"key": "allowDeleteRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "High contrast",
"key": "stripeRows",
"defaultValue": false
}
]
} }
} }

View File

@ -35,7 +35,8 @@ export const API = createAPIClient({
// We could also log these to sentry. // We could also log these to sentry.
// Or we could check error.status and redirect to login on a 403 etc. // Or we could check error.status and redirect to login on a 403 etc.
onError: error => { onError: error => {
const { status, method, url, message, handled } = error || {} const { status, method, url, message, handled, suppressErrors } =
error || {}
const ignoreErrorUrls = [ const ignoreErrorUrls = [
"bbtel", "bbtel",
"/api/global/self", "/api/global/self",
@ -49,7 +50,7 @@ export const API = createAPIClient({
} }
// Notify all errors // Notify all errors
if (message) { if (message && !suppressErrors) {
// Don't notify if the URL contains the word analytics as it may be // Don't notify if the URL contains the word analytics as it may be
// blocked by browser extensions // blocked by browser extensions
let ignore = false let ignore = false

View File

@ -0,0 +1,71 @@
<script>
// NOTE: this is not a block - it's just named as such to avoid confusing users,
// because it functions similarly to one
import { getContext } from "svelte"
import { Grid } from "@budibase/frontend-core"
export let table
export let allowAddRows = true
export let allowEditRows = true
export let allowDeleteRows = true
export let stripeRows = false
export let initialFilter = null
export let initialSortColumn = null
export let initialSortOrder = null
export let initialRowHeight = null
export let columns = null
const component = getContext("component")
const { styleable, API, builderStore } = getContext("sdk")
$: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns)
const getSchemaOverrides = columns => {
let overrides = {}
columns?.forEach(column => {
overrides[column.name] = {
displayName: column.displayName || column.name,
}
})
return overrides
}
</script>
<div
use:styleable={$component.styles}
class:in-builder={$builderStore.inBuilder}
>
<Grid
tableId={table?.tableId}
{API}
{allowAddRows}
{allowEditRows}
{allowDeleteRows}
{stripeRows}
{initialFilter}
{initialSortColumn}
{initialSortOrder}
{initialRowHeight}
{columnWhitelist}
{schemaOverrides}
showControls={false}
allowExpandRows={false}
allowSchemaChanges={false}
/>
</div>
<style>
div {
display: flex;
flex-direction: column;
align-items: stretch;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
overflow: hidden;
min-height: 410px;
}
div.in-builder :global(*) {
pointer-events: none;
}
</style>

View File

@ -36,6 +36,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte" export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte" export { default as sidepanel } from "./SidePanel.svelte"
export { default as gridblock } from "./GridBlock.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"

View File

@ -75,7 +75,11 @@ export const createAPIClient = config => {
let cache = {} let cache = {}
// Generates an error object from an API response // Generates an error object from an API response
const makeErrorFromResponse = async (response, method) => { const makeErrorFromResponse = async (
response,
method,
suppressErrors = false
) => {
// Try to read a message from the error // Try to read a message from the error
let message = response.statusText let message = response.statusText
let json = null let json = null
@ -96,6 +100,7 @@ export const createAPIClient = config => {
url: response.url, url: response.url,
method, method,
handled: true, handled: true,
suppressErrors,
} }
} }
@ -119,6 +124,7 @@ export const createAPIClient = config => {
json = true, json = true,
external = false, external = false,
parseResponse, parseResponse,
suppressErrors = false,
}) => { }) => {
// Ensure we don't do JSON processing if sending a GET request // Ensure we don't do JSON processing if sending a GET request
json = json && method !== "GET" json = json && method !== "GET"
@ -174,7 +180,7 @@ export const createAPIClient = config => {
} }
} else { } else {
delete cache[url] delete cache[url]
throw await makeErrorFromResponse(response, method) throw await makeErrorFromResponse(response, method, suppressErrors)
} }
} }
@ -228,6 +234,14 @@ export const createAPIClient = config => {
invalidateCache: () => { invalidateCache: () => {
cache = {} cache = {}
}, },
// Generic utility to extract the current app ID. Assumes that any client
// that exists in an app context will be attaching our app ID header.
getAppID: () => {
let headers = {}
config?.attachHeaders(headers)
return headers?.["x-budibase-app-id"]
},
} }
// Attach all endpoints // Attach all endpoints

View File

@ -16,14 +16,16 @@ export const buildRowEndpoints = API => ({
/** /**
* Creates or updates a row in a table. * Creates or updates a row in a table.
* @param row the row to save * @param row the row to save
* @param suppressErrors whether or not to suppress error notifications
*/ */
saveRow: async row => { saveRow: async (row, suppressErrors = false) => {
if (!row?.tableId) { if (!row?.tableId) {
return return
} }
return await API.post({ return await API.post({
url: `/api/${row.tableId}/rows`, url: `/api/${row.tableId}/rows`,
body: row, body: row,
suppressErrors,
}) })
}, },

View File

@ -138,10 +138,12 @@
top: 100%; top: 100%;
left: 0; left: 0;
width: 320px; width: 320px;
background: var(--background); background: var(--grid-background-alt);
border: var(--cell-border); border: var(--cell-border);
padding: var(--cell-padding); padding: var(--cell-padding);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15); box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
} }
.dropzone.invertX { .dropzone.invertX {
left: auto; left: auto;

View File

@ -132,7 +132,7 @@
--cell-color: var(--user-color); --cell-color: var(--user-color);
} }
.cell.focused { .cell.focused {
--cell-color: var(--spectrum-global-color-blue-400); --cell-color: var(--accent-color);
} }
.cell.error { .cell.error {
--cell-color: var(--spectrum-global-color-red-500); --cell-color: var(--spectrum-global-color-red-500);

View File

@ -9,7 +9,7 @@
export let rowFocused = false export let rowFocused = false
export let rowHovered = false export let rowHovered = false
export let rowSelected = false export let rowSelected = false
export let disableExpand = false export let expandable = false
export let disableNumber = false export let disableNumber = false
export let defaultHeight = false export let defaultHeight = false
export let disabled = false export let disabled = false
@ -24,13 +24,6 @@
selectedRows.actions.toggleRow(id) selectedRows.actions.toggleRow(id)
} }
} }
const expand = () => {
svelteDispatch("expand")
if (row) {
dispatch("edit-row", row)
}
}
</script> </script>
<GridCell <GridCell
@ -70,12 +63,14 @@
color="var(--spectrum-global-color-red-400)" color="var(--spectrum-global-color-red-400)"
/> />
</div> </div>
{:else if $config.allowExpandRows} {:else}
<div <div class="expand" class:visible={$config.allowExpandRows && expandable}>
class="expand" <Icon
class:visible={!disableExpand && (rowFocused || rowHovered)} size="S"
> name="Maximize"
<Icon name="Maximize" hoverable size="S" on:click={expand} /> hoverable
on:click={() => svelteDispatch("expand")}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -163,7 +163,7 @@
<MenuItem <MenuItem
icon="Edit" icon="Edit"
on:click={editColumn} on:click={editColumn}
disabled={!$config.allowEditColumns || column.schema.disabled} disabled={!$config.allowSchemaChanges || column.schema.disabled}
> >
Edit column Edit column
</MenuItem> </MenuItem>
@ -171,7 +171,7 @@
icon="Label" icon="Label"
on:click={makeDisplayColumn} on:click={makeDisplayColumn}
disabled={idx === "sticky" || disabled={idx === "sticky" ||
!$config.allowEditColumns || !$config.allowSchemaChanges ||
bannedDisplayColumnTypes.includes(column.schema.type)} bannedDisplayColumnTypes.includes(column.schema.type)}
> >
Use as display column Use as display column
@ -197,10 +197,12 @@
Move right Move right
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={idx === "sticky"} disabled={idx === "sticky" || !$config.showControls}
icon="VisibilityOff" icon="VisibilityOff"
on:click={hideColumn}>Hide column</MenuItem on:click={hideColumn}
> >
Hide column
</MenuItem>
</Menu> </Menu>
</Popover> </Popover>
@ -218,7 +220,7 @@
.header-cell :global(.cell) { .header-cell :global(.cell) {
padding: 0 var(--cell-padding); padding: 0 var(--cell-padding);
gap: calc(2 * var(--cell-spacing)); gap: calc(2 * var(--cell-spacing));
background: var(--spectrum-global-color-gray-100); background: var(--grid-background-alt);
} }
.name { .name {

View File

@ -102,7 +102,7 @@
top: 0; top: 0;
left: 0; left: 0;
width: calc(100% + var(--max-cell-render-width-overflow)); width: calc(100% + var(--max-cell-render-width-overflow));
height: var(--max-cell-render-height); height: calc(var(--row-height) + var(--max-cell-render-height));
z-index: 1; z-index: 1;
border-radius: 2px; border-radius: 2px;
resize: none; resize: none;

View File

@ -132,10 +132,7 @@
{option} {option}
</div> </div>
{#if values.includes(option)} {#if values.includes(option)}
<Icon <Icon name="Checkmark" color="var(--accent-color)" />
name="Checkmark"
color="var(--spectrum-global-color-blue-400)"
/>
{/if} {/if}
</div> </div>
{/each} {/each}
@ -223,6 +220,8 @@
overflow-y: auto; overflow-y: auto;
border: var(--cell-border); border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15); box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
} }
.options.invertX { .options.invertX {
left: auto; left: auto;
@ -240,7 +239,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--cell-spacing); gap: var(--cell-spacing);
background-color: var(--background); background-color: var(--grid-background-alt);
} }
.option:hover, .option:hover,
.option.focused { .option.focused {

View File

@ -42,6 +42,8 @@
let candidateIndex let candidateIndex
let lastSearchId let lastSearchId
let searching = false let searching = false
let valuesHeight = 0
let container
$: oneRowOnly = schema?.relationshipType === "one-to-many" $: oneRowOnly = schema?.relationshipType === "one-to-many"
$: editable = focused && !readonly $: editable = focused && !readonly
@ -138,6 +140,7 @@
const open = async () => { const open = async () => {
isOpen = true isOpen = true
valuesHeight = container.getBoundingClientRect().height
// Find the primary display for the related table // Find the primary display for the related table
if (!primaryDisplay) { if (!primaryDisplay) {
@ -242,8 +245,14 @@
}) })
</script> </script>
<div class="wrapper" class:editable class:focused style="--color:{color};"> <div
<div class="container"> class="wrapper"
class:editable
class:focused
class:invertY
style="--color:{color};"
>
<div class="container" bind:this={container}>
<div <div
class="values" class="values"
class:wrap={editable || contentLines > 1} class:wrap={editable || contentLines > 1}
@ -290,6 +299,7 @@
class:invertY class:invertY
on:wheel|stopPropagation on:wheel|stopPropagation
use:clickOutside={close} use:clickOutside={close}
style="--values-height:{valuesHeight}px;"
> >
<div class="search"> <div class="search">
<Input <Input
@ -319,11 +329,7 @@
</span> </span>
</div> </div>
{#if isRowSelected(row)} {#if isRowSelected(row)}
<Icon <Icon size="S" name="Checkmark" color="var(--accent-color)" />
size="S"
name="Checkmark"
color="var(--spectrum-global-color-blue-400)"
/>
{/if} {/if}
</div> </div>
{/each} {/each}
@ -340,7 +346,7 @@
min-height: var(--row-height); min-height: var(--row-height);
max-height: var(--row-height); max-height: var(--row-height);
overflow: hidden; overflow: hidden;
--max-relationship-height: 120px; --max-relationship-height: 96px;
} }
.wrapper.focused { .wrapper.focused {
position: absolute; position: absolute;
@ -352,6 +358,10 @@
max-height: none; max-height: none;
overflow: visible; overflow: visible;
} }
.wrapper.invertY {
top: auto;
bottom: 0;
}
.container { .container {
min-height: var(--row-height); min-height: var(--row-height);
@ -450,16 +460,17 @@
left: 0; left: 0;
width: 100%; width: 100%;
max-height: calc( max-height: calc(
var(--max-cell-render-height) + var(--row-height) - var(--max-cell-render-height) + var(--row-height) - var(--values-height)
var(--max-relationship-height)
); );
background: var(--background); background: var(--grid-background-alt);
border: var(--cell-border); border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15); box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
padding: 0 0 8px 0; padding: 0 0 8px 0;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
} }
.dropdown.invertY { .dropdown.invertY {
transform: translateY(-100%); transform: translateY(-100%);

View File

@ -10,7 +10,7 @@
quiet quiet
size="M" size="M"
on:click={() => dispatch("add-column")} on:click={() => dispatch("add-column")}
disabled={!$config.allowAddColumns} disabled={!$config.allowSchemaChanges}
> >
Add column Add column
</ActionButton> </ActionButton>

View File

@ -6,15 +6,9 @@
let modal let modal
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length $: selectedRowCount = Object.values($selectedRows).length
$: rowsToDelete = Object.entries($selectedRows) $: rowsToDelete = Object.entries($selectedRows)
.map(entry => { .map(entry => $rows.find(x => x._id === entry[0]))
if (entry[1] === true) {
return $rows.find(x => x._id === entry[0])
} else {
return null
}
})
.filter(x => x != null) .filter(x => x != null)
// Deletion callback when confirmed // Deletion callback when confirmed

View File

@ -1,92 +0,0 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui"
import { DefaultColumnWidth } from "../lib/constants"
const { stickyColumn, columns, compact } = 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}
tooltip={$compact ? "Width" : null}
>
{$compact ? "" : "Width"}
</ActionButton>
</div>
<Popover bind:open {anchor} align={$compact ? "right" : "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

@ -3,12 +3,13 @@
import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui" import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
const { columns, stickyColumn, compact } = getContext("grid") const { columns, stickyColumn } = getContext("grid")
let open = false let open = false
let anchor let anchor
$: anyHidden = $columns.some(col => !col.visible) $: anyHidden = $columns.some(col => !col.visible)
$: text = getText($columns)
const toggleVisibility = (column, visible) => { const toggleVisibility = (column, visible) => {
columns.update(state => { columns.update(state => {
@ -38,6 +39,11 @@
}) })
columns.actions.saveChanges() columns.actions.saveChanges()
} }
const getText = columns => {
const hidden = columns.filter(col => !col.visible).length
return hidden ? `Hide columns (${hidden})` : "Hide columns"
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -48,13 +54,12 @@
on:click={() => (open = !open)} on:click={() => (open = !open)}
selected={open || anyHidden} selected={open || anyHidden}
disabled={!$columns.length} disabled={!$columns.length}
tooltip={$compact ? "Columns" : ""}
> >
{$compact ? "" : "Columns"} {text}
</ActionButton> </ActionButton>
</div> </div>
<Popover bind:open {anchor} align={$compact ? "right" : "left"}> <Popover bind:open {anchor} align="left">
<div class="content"> <div class="content">
<div class="columns"> <div class="columns">
{#if $stickyColumn} {#if $stickyColumn}

View File

@ -1,71 +0,0 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui"
import {
LargeRowHeight,
MediumRowHeight,
SmallRowHeight,
} from "../lib/constants"
const { rowHeight, columns, table, compact } = getContext("grid")
const sizeOptions = [
{
label: "Small",
size: SmallRowHeight,
},
{
label: "Medium",
size: MediumRowHeight,
},
{
label: "Large",
size: LargeRowHeight,
},
]
let open = false
let anchor
const changeRowHeight = height => {
columns.actions.saveTable({
...$table,
rowHeight: height,
})
}
</script>
<div bind:this={anchor}>
<ActionButton
icon="MoveUpDown"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
tooltip={$compact ? "Height" : null}
>
{$compact ? "" : "Height"}
</ActionButton>
</div>
<Popover bind:open {anchor} align={$compact ? "right" : "left"}>
<div class="content">
{#each sizeOptions as option}
<ActionButton
quiet
selected={$rowHeight === option.size}
on:click={() => changeRowHeight(option.size)}
>
{option.label}
</ActionButton>
{/each}
</div>
</Popover>
<style>
.content {
padding: 12px;
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -0,0 +1,135 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover, Label } from "@budibase/bbui"
import {
DefaultColumnWidth,
LargeRowHeight,
MediumRowHeight,
SmallRowHeight,
} from "../lib/constants"
const { stickyColumn, columns, rowHeight, table } = getContext("grid")
// Some constants for column width options
const smallColSize = 120
const mediumColSize = DefaultColumnWidth
const largeColSize = DefaultColumnWidth * 1.5
// Row height sizes
const rowSizeOptions = [
{
label: "Small",
size: SmallRowHeight,
},
{
label: "Medium",
size: MediumRowHeight,
},
{
label: "Large",
size: LargeRowHeight,
},
]
let open = false
let anchor
// Column width sizes
$: allCols = $columns.concat($stickyColumn ? [$stickyColumn] : [])
$: allSmall = allCols.every(col => col.width === smallColSize)
$: allMedium = allCols.every(col => col.width === mediumColSize)
$: allLarge = allCols.every(col => col.width === largeColSize)
$: custom = !allSmall && !allMedium && !allLarge
$: columnSizeOptions = [
{
label: "Small",
size: smallColSize,
selected: allSmall,
},
{
label: "Medium",
size: mediumColSize,
selected: allMedium,
},
{
label: "Large",
size: largeColSize,
selected: allLarge,
},
]
const changeRowHeight = height => {
columns.actions.saveTable({
...$table,
rowHeight: height,
})
}
</script>
<div bind:this={anchor}>
<ActionButton
icon="MoveUpDown"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
disabled={!allCols.length}
>
Size
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
<div class="size">
<Label>Row height</Label>
<div class="options">
{#each rowSizeOptions as option}
<ActionButton
quiet
selected={$rowHeight === option.size}
on:click={() => changeRowHeight(option.size)}
>
{option.label}
</ActionButton>
{/each}
</div>
</div>
<div class="size">
<Label>Column width</Label>
<div class="options">
{#each columnSizeOptions as option}
<ActionButton
quiet
on:click={() => columns.actions.changeAllColumnWidths(option.size)}
selected={option.selected}
>
{option.label}
</ActionButton>
{/each}
{#if custom}
<ActionButton selected={custom} quiet>Custom</ActionButton>
{/if}
</div>
</div>
</div>
</Popover>
<style>
.content {
padding: 12px;
}
.size {
display: flex;
flex-direction: column;
gap: 8px;
}
.size:first-child {
margin-bottom: 16px;
}
.options {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui" import { ActionButton, Popover, Select } from "@budibase/bbui"
const { sort, columns, stickyColumn, compact } = getContext("grid") const { sort, columns, stickyColumn } = getContext("grid")
let open = false let open = false
let anchor let anchor
@ -90,13 +90,12 @@
on:click={() => (open = !open)} on:click={() => (open = !open)}
selected={open} selected={open}
disabled={!columnOptions.length} disabled={!columnOptions.length}
tooltip={$compact ? "Sort" : ""}
> >
{$compact ? "" : "Sort"} Sort
</ActionButton> </ActionButton>
</div> </div>
<Popover bind:open {anchor} align={$compact ? "right" : "left"}> <Popover bind:open {anchor} align="left">
<div class="content"> <div class="content">
<Select <Select
placeholder={null} placeholder={null}

View File

@ -1,6 +1,5 @@
<script> <script>
import { setContext, onMount } from "svelte" import { setContext, onMount } from "svelte"
import { writable } from "svelte/store"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui" import { clickOutside, ProgressCircle } from "@budibase/bbui"
import { createEventManagers } from "../lib/events" import { createEventManagers } from "../lib/events"
@ -17,11 +16,8 @@
import UserAvatars from "./UserAvatars.svelte" import UserAvatars from "./UserAvatars.svelte"
import KeyboardManager from "../overlays/KeyboardManager.svelte" import KeyboardManager from "../overlays/KeyboardManager.svelte"
import SortButton from "../controls/SortButton.svelte" import SortButton from "../controls/SortButton.svelte"
import AddColumnButton from "../controls/AddColumnButton.svelte"
import HideColumnsButton from "../controls/HideColumnsButton.svelte" import HideColumnsButton from "../controls/HideColumnsButton.svelte"
import AddRowButton from "../controls/AddRowButton.svelte" import SizeButton from "../controls/SizeButton.svelte"
import RowHeightButton from "../controls/RowHeightButton.svelte"
import ColumnWidthButton from "../controls/ColumnWidthButton.svelte"
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket" import { createGridWebsocket } from "../lib/websocket"
import { import {
@ -33,48 +29,37 @@
export let API = null export let API = null
export let tableId = null export let tableId = null
export let tableType = null
export let schemaOverrides = null export let schemaOverrides = null
export let columnWhitelist = null
export let allowAddRows = true export let allowAddRows = true
export let allowAddColumns = true
export let allowEditColumns = true
export let allowExpandRows = true export let allowExpandRows = true
export let allowEditRows = true export let allowEditRows = true
export let allowDeleteRows = true export let allowDeleteRows = true
export let allowSchemaChanges = true
export let stripeRows = false export let stripeRows = false
export let collaboration = true export let collaboration = true
export let showAvatars = true export let showAvatars = true
export let showControls = true
export let initialFilter = null
export let initialSortColumn = null
export let initialSortOrder = null
export let initialRowHeight = null
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const rand = Math.random() const rand = Math.random()
// State stores
const tableIdStore = writable(tableId)
const schemaOverridesStore = writable(schemaOverrides)
const config = writable({
allowAddRows,
allowAddColumns,
allowEditColumns,
allowExpandRows,
allowEditRows,
allowDeleteRows,
stripeRows,
})
// Build up context // Build up context
let context = { let context = {
API: API || createAPIClient(), API: API || createAPIClient(),
rand, rand,
config, props: $$props,
tableId: tableIdStore,
tableType,
schemaOverrides: schemaOverridesStore,
} }
context = { ...context, ...createEventManagers() } context = { ...context, ...createEventManagers() }
context = attachStores(context) context = attachStores(context)
// Reference some stores for local use // Reference some stores for local use
const { const {
config,
isResizing, isResizing,
isReordering, isReordering,
ui, ui,
@ -82,19 +67,27 @@
loading, loading,
rowHeight, rowHeight,
contentLines, contentLines,
gridFocused,
} = context } = context
// Keep stores up to date // Keep config store up to date with props
$: tableIdStore.set(tableId)
$: schemaOverridesStore.set(schemaOverrides)
$: config.set({ $: config.set({
tableId,
schemaOverrides,
columnWhitelist,
allowAddRows, allowAddRows,
allowAddColumns,
allowEditColumns,
allowExpandRows, allowExpandRows,
allowEditRows, allowEditRows,
allowDeleteRows, allowDeleteRows,
allowSchemaChanges,
stripeRows, stripeRows,
collaboration,
showAvatars,
showControls,
initialFilter,
initialSortColumn,
initialSortOrder,
initialRowHeight,
}) })
// Set context for children to consume // Set context for children to consume
@ -116,25 +109,27 @@
id="grid-{rand}" id="grid-{rand}"
class:is-resizing={$isResizing} class:is-resizing={$isResizing}
class:is-reordering={$isReordering} class:is-reordering={$isReordering}
class:stripe={$config.stripeRows} class:stripe={stripeRows}
on:mouseenter={() => gridFocused.set(true)}
on:mouseleave={() => gridFocused.set(false)}
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};" style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};"
> >
<div class="controls"> {#if showControls}
<div class="controls-left"> <div class="controls">
<AddRowButton /> <div class="controls-left">
<AddColumnButton /> <slot name="filter" />
<slot name="controls" /> <SortButton />
<SortButton /> <HideColumnsButton />
<HideColumnsButton /> <SizeButton />
<ColumnWidthButton /> <slot name="controls" />
<RowHeightButton /> </div>
<div class="controls-right">
{#if showAvatars}
<UserAvatars />
{/if}
</div>
</div> </div>
<div class="controls-right"> {/if}
{#if showAvatars}
<UserAvatars />
{/if}
</div>
</div>
{#if $loaded} {#if $loaded}
<div class="grid-data-outer" use:clickOutside={ui.actions.blur}> <div class="grid-data-outer" use:clickOutside={ui.actions.blur}>
<div class="grid-data-inner"> <div class="grid-data-inner">
@ -167,7 +162,20 @@
</div> </div>
<style> <style>
/* Core grid */
.grid { .grid {
/* Variables */
--accent-color: var(--primaryColor, var(--spectrum-global-color-blue-400));
--grid-background: var(--spectrum-global-color-gray-50);
--grid-background-alt: var(--spectrum-global-color-gray-100);
--cell-background: var(--grid-background);
--cell-background-hover: var(--grid-background-alt);
--cell-background-alt: var(--cell-background);
--cell-padding: 8px;
--cell-spacing: 4px;
--cell-border: 1px solid var(--spectrum-global-color-gray-200);
--cell-font-size: 14px;
--controls-height: 50px;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -175,17 +183,7 @@
align-items: stretch; align-items: stretch;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--cell-background); background: var(--grid-background);
/* Variables */
--cell-background: var(--spectrum-global-color-gray-50);
--cell-background-hover: var(--spectrum-global-color-gray-100);
--cell-background-alt: var(--cell-background);
--cell-padding: 8px;
--cell-spacing: 4px;
--cell-border: 1px solid var(--spectrum-global-color-gray-200);
--cell-font-size: 14px;
--controls-height: 50px;
} }
.grid, .grid,
.grid :global(*) { .grid :global(*) {
@ -201,6 +199,7 @@
--cell-background-alt: var(--spectrum-global-color-gray-75); --cell-background-alt: var(--spectrum-global-color-gray-75);
} }
/* Data layers */
.grid-data-outer, .grid-data-outer,
.grid-data-inner { .grid-data-inner {
flex: 1 1 auto; flex: 1 1 auto;
@ -234,7 +233,7 @@
border-bottom: 2px solid var(--spectrum-global-color-gray-200); border-bottom: 2px solid var(--spectrum-global-color-gray-200);
padding: var(--cell-padding); padding: var(--cell-padding);
gap: var(--cell-spacing); gap: var(--cell-spacing);
background: var(--background); background: var(--grid-background-alt);
z-index: 2; z-index: 2;
} }
.controls-left, .controls-left,
@ -270,7 +269,15 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: var(--background); background: var(--grid-background-alt);
opacity: 0.6; opacity: 0.6;
} }
/* Disable checkbox animation anywhere in the grid data */
.grid-data-outer :global(.spectrum-Checkbox-box:before),
.grid-data-outer :global(.spectrum-Checkbox-box:after),
.grid-data-outer :global(.spectrum-Checkbox-checkmark),
.grid-data-outer :global(.spectrum-Checkbox-partialCheckmark) {
transition: none;
}
</style> </style>

View File

@ -12,6 +12,7 @@
config, config,
hoveredRowId, hoveredRowId,
dispatch, dispatch,
isDragging,
} = getContext("grid") } = getContext("grid")
let body let body
@ -47,8 +48,8 @@
class="blank" class="blank"
class:highlighted={$hoveredRowId === BlankRowID} class:highlighted={$hoveredRowId === BlankRowID}
style="width:{renderColumnsWidth}px" style="width:{renderColumnsWidth}px"
on:mouseenter={() => ($hoveredRowId = BlankRowID)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseleave={() => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")} on:click={() => dispatch("add-row-inline")}
/> />
{/if} {/if}

View File

@ -16,6 +16,7 @@
focusedRow, focusedRow,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
contentLines, contentLines,
isDragging,
} = getContext("grid") } = getContext("grid")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
@ -27,8 +28,8 @@
<div <div
class="row" class="row"
on:focus on:focus
on:mouseenter={() => ($hoveredRowId = row._id)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
> >
{#each $renderedColumns as column, columnIdx (column.name)} {#each $renderedColumns as column, columnIdx (column.name)}
{@const cellId = `${row._id}-${column.name}`} {@const cellId = `${row._id}-${column.name}`}

View File

@ -12,6 +12,7 @@
bounds, bounds,
hoveredRowId, hoveredRowId,
hiddenColumnsWidth, hiddenColumnsWidth,
menu,
} = getContext("grid") } = getContext("grid")
export let scrollVertically = false export let scrollVertically = false
@ -30,6 +31,11 @@
const handleWheel = e => { const handleWheel = e => {
e.preventDefault() e.preventDefault()
debouncedHandleWheel(e.deltaX, e.deltaY, e.clientY) debouncedHandleWheel(e.deltaX, e.deltaY, e.clientY)
// If a context menu was visible, hide it
if ($menu.visible) {
menu.actions.close()
}
} }
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => { const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
const { top, left } = $scroll const { top, left } = $scroll

View File

@ -29,7 +29,7 @@
{/each} {/each}
</div> </div>
</GridScrollWrapper> </GridScrollWrapper>
{#if $config.allowAddColumns} {#if $config.allowSchemaChanges}
<div <div
class="add" class="add"
style="left:{left}px" style="left:{left}px"
@ -42,7 +42,7 @@
<style> <style>
.header { .header {
background: var(--background); background: var(--grid-background-alt);
border-bottom: var(--cell-border); border-bottom: var(--cell-border);
position: relative; position: relative;
height: var(--default-row-height); height: var(--default-row-height);
@ -60,7 +60,7 @@
border-left: var(--cell-border); border-left: var(--cell-border);
border-right: var(--cell-border); border-right: var(--cell-border);
border-bottom: var(--cell-border); border-bottom: var(--cell-border);
background: var(--spectrum-global-color-gray-100); background: var(--grid-background-alt);
z-index: 1; z-index: 1;
} }
.add:hover { .add:hover {

View File

@ -26,6 +26,8 @@
maxScrollTop, maxScrollTop,
rowVerticalInversionIndex, rowVerticalInversionIndex,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
selectedRows,
config,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -37,6 +39,7 @@
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
$: $tableId, (visible = false) $: $tableId, (visible = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows) $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
$: selectedRowCount = Object.values($selectedRows).length
const shouldInvertY = (offset, inversionIndex, rows) => { const shouldInvertY = (offset, inversionIndex, rows) => {
if (offset === 0) { if (offset === 0) {
@ -75,7 +78,7 @@
} }
const startAdding = async () => { const startAdding = async () => {
if (visible) { if (visible || !firstColumn) {
return return
} }
@ -129,9 +132,6 @@
e.preventDefault() e.preventDefault()
clear() clear()
} }
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
addRow()
} }
} }
@ -141,6 +141,18 @@
}) })
</script> </script>
<!-- New row FAB -->
{#if !visible && !selectedRowCount && $config.allowAddRows && firstColumn}
<div
class="new-row-fab"
on:click={() => dispatch("add-row-inline")}
transition:fade|local={{ duration: 130 }}
class:offset={!$stickyColumn}
>
<Icon name="Add" size="S" />
</div>
{/if}
<!-- Only show new row functionality if we have any columns --> <!-- Only show new row functionality if we have any columns -->
{#if visible} {#if visible}
<div <div
@ -151,7 +163,7 @@
<div class="underlay sticky" transition:fade|local={{ duration: 130 }} /> <div class="underlay sticky" transition:fade|local={{ duration: 130 }} />
<div class="underlay" transition:fade|local={{ duration: 130 }} /> <div class="underlay" transition:fade|local={{ duration: 130 }} />
<div class="sticky-column" transition:fade|local={{ duration: 130 }}> <div class="sticky-column" transition:fade|local={{ duration: 130 }}>
<GutterCell on:expand={addViaModal} rowHovered> <GutterCell expandable on:expand={addViaModal} rowHovered>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" /> <Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
{#if isAdding} {#if isAdding}
<div in:fade={{ duration: 130 }} class="loading-overlay" /> <div in:fade={{ duration: 130 }} class="loading-overlay" />
@ -227,6 +239,26 @@
{/if} {/if}
<style> <style>
/* New row FAB */
.new-row-fab {
position: absolute;
top: var(--default-row-height);
left: calc(var(--gutter-width) / 2);
transform: translateX(6px) translateY(-50%);
background: var(--cell-background);
padding: 4px;
border-radius: 50%;
border: var(--cell-border);
z-index: 10;
}
.new-row-fab:hover {
background: var(--cell-background-hover);
cursor: pointer;
}
.new-row-fab.offset {
margin-left: -6px;
}
.container { .container {
position: absolute; position: absolute;
top: var(--default-row-height); top: var(--default-row-height);

View File

@ -23,10 +23,11 @@
scrollLeft, scrollLeft,
dispatch, dispatch,
contentLines, contentLines,
isDragging,
} = getContext("grid") } = getContext("grid")
$: rowCount = $rows.length $: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length $: selectedRowCount = Object.values($selectedRows).length
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
const selectAll = () => { const selectAll = () => {
@ -50,7 +51,6 @@
> >
<div class="header row"> <div class="header row">
<GutterCell <GutterCell
disableExpand
disableNumber disableNumber
on:select={selectAll} on:select={selectAll}
defaultHeight defaultHeight
@ -71,8 +71,8 @@
{@const cellId = `${row._id}-${$stickyColumn?.name}`} {@const cellId = `${row._id}-${$stickyColumn?.name}`}
<div <div
class="row" class="row"
on:mouseenter={() => ($hoveredRowId = row._id)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
> >
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} /> <GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn} {#if $stickyColumn}
@ -96,11 +96,13 @@
{#if $config.allowAddRows && ($renderedColumns.length || $stickyColumn)} {#if $config.allowAddRows && ($renderedColumns.length || $stickyColumn)}
<div <div
class="row new" class="row new"
on:mouseenter={() => ($hoveredRowId = BlankRowID)} on:mouseenter={$isDragging
on:mouseleave={() => ($hoveredRowId = null)} ? null
: () => ($hoveredRowId = BlankRowID)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")} on:click={() => dispatch("add-row-inline")}
> >
<GutterCell disableExpand rowHovered={$hoveredRowId === BlankRowID}> <GutterCell rowHovered={$hoveredRowId === BlankRowID}>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" /> <Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
</GutterCell> </GutterCell>
{#if $stickyColumn} {#if $stickyColumn}
@ -159,7 +161,7 @@
z-index: 1; z-index: 1;
} }
.header :global(.cell) { .header :global(.cell) {
background: var(--spectrum-global-color-gray-100); background: var(--grid-background-alt);
} }
.row { .row {
display: flex; display: flex;

View File

@ -1,6 +1,5 @@
export const Padding = 256 export const Padding = 246
export const MaxCellRenderHeight = 252 export const MaxCellRenderHeight = 222
export const MaxCellRenderWidthOverflow = 200
export const ScrollBarSize = 8 export const ScrollBarSize = 8
export const GutterWidth = 72 export const GutterWidth = 72
export const DefaultColumnWidth = 200 export const DefaultColumnWidth = 200
@ -12,3 +11,5 @@ export const DefaultRowHeight = SmallRowHeight
export const NewRowID = "new" export const NewRowID = "new"
export const BlankRowID = "blank" export const BlankRowID = "blank"
export const RowPageSize = 100 export const RowPageSize = 100
export const FocusedCellMinOffset = 48
export const MaxCellRenderWidthOverflow = Padding - 3 * ScrollBarSize

View File

@ -3,7 +3,7 @@ import { createWebsocket } from "../../../utils"
import { SocketEvent, GridSocketEvent } from "@budibase/shared-core" import { SocketEvent, GridSocketEvent } from "@budibase/shared-core"
export const createGridWebsocket = context => { export const createGridWebsocket = context => {
const { rows, tableId, users, focusedCellId, table } = context const { rows, tableId, users, focusedCellId, table, API } = context
const socket = createWebsocket("/socket/grid") const socket = createWebsocket("/socket/grid")
const connectToTable = tableId => { const connectToTable = tableId => {
@ -11,9 +11,10 @@ export const createGridWebsocket = context => {
return return
} }
// Identify which table we are editing // Identify which table we are editing
const appId = API.getAppID()
socket.emit( socket.emit(
GridSocketEvent.SelectTable, GridSocketEvent.SelectTable,
{ tableId }, { tableId, appId },
({ users: gridUsers }) => { ({ users: gridUsers }) => {
users.set(gridUsers) users.set(gridUsers)
} }

View File

@ -15,6 +15,7 @@
selectedRows, selectedRows,
config, config,
menu, menu,
gridFocused,
} = getContext("grid") } = getContext("grid")
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
@ -24,6 +25,11 @@
// Global key listener which intercepts all key events // Global key listener which intercepts all key events
const handleKeyDown = e => { const handleKeyDown = e => {
// Ignore completely if the grid is not focused
if (!$gridFocused) {
return
}
// Avoid processing events sourced from certain origins // Avoid processing events sourced from certain origins
if (e.target?.closest) { if (e.target?.closest) {
for (let selector of ignoredOriginSelectors) { for (let selector of ignoredOriginSelectors) {

View File

@ -72,7 +72,9 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Maximize" icon="Maximize"
disabled={isNewRow || !$config.allowEditRows} disabled={isNewRow ||
!$config.allowEditRows ||
!$config.allowExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)} on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close} on:click={menu.actions.close}
> >

View File

@ -57,7 +57,7 @@
position: absolute; position: absolute;
top: 0; top: 0;
width: 2px; width: 2px;
background: var(--spectrum-global-color-blue-400); background: var(--accent-color);
margin-left: -2px; margin-left: -2px;
} }
</style> </style>

View File

@ -65,6 +65,6 @@
margin-left: -1px; margin-left: -1px;
width: 2px; width: 2px;
height: 100%; height: 100%;
background: var(--spectrum-global-color-blue-400); background: var(--accent-color);
} }
</style> </style>

View File

@ -15,11 +15,18 @@
scrollLeft, scrollLeft,
scrollTop, scrollTop,
height, height,
isDragging,
menu,
} = getContext("grid") } = getContext("grid")
// State for dragging bars // State for dragging bars
let initialMouse let initialMouse
let initialScroll let initialScroll
let isDraggingV = false
let isDraggingH = false
// Update state to reflect if we are dragging
$: isDragging.set(isDraggingV || isDraggingH)
// Calculate V scrollbar size and offset // Calculate V scrollbar size and offset
// Terminology is the same for both axes: // Terminology is the same for both axes:
@ -39,6 +46,13 @@
$: availWidth = renderWidth - barWidth $: availWidth = renderWidth - barWidth
$: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft) $: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft)
// Helper to close the context menu if it's open
const closeMenu = () => {
if ($menu.visible) {
menu.actions.close()
}
}
// V scrollbar drag handlers // V scrollbar drag handlers
const startVDragging = e => { const startVDragging = e => {
e.preventDefault() e.preventDefault()
@ -46,6 +60,8 @@
initialScroll = $scrollTop initialScroll = $scrollTop
document.addEventListener("mousemove", moveVDragging) document.addEventListener("mousemove", moveVDragging)
document.addEventListener("mouseup", stopVDragging) document.addEventListener("mouseup", stopVDragging)
isDraggingV = true
closeMenu()
} }
const moveVDragging = domDebounce(e => { const moveVDragging = domDebounce(e => {
const delta = e.clientY - initialMouse const delta = e.clientY - initialMouse
@ -59,6 +75,7 @@
const stopVDragging = () => { const stopVDragging = () => {
document.removeEventListener("mousemove", moveVDragging) document.removeEventListener("mousemove", moveVDragging)
document.removeEventListener("mouseup", stopVDragging) document.removeEventListener("mouseup", stopVDragging)
isDraggingV = false
} }
// H scrollbar drag handlers // H scrollbar drag handlers
@ -68,6 +85,8 @@
initialScroll = $scrollLeft initialScroll = $scrollLeft
document.addEventListener("mousemove", moveHDragging) document.addEventListener("mousemove", moveHDragging)
document.addEventListener("mouseup", stopHDragging) document.addEventListener("mouseup", stopHDragging)
isDraggingH = true
closeMenu()
} }
const moveHDragging = domDebounce(e => { const moveHDragging = domDebounce(e => {
const delta = e.clientX - initialMouse const delta = e.clientX - initialMouse
@ -81,6 +100,7 @@
const stopHDragging = () => { const stopHDragging = () => {
document.removeEventListener("mousemove", moveHDragging) document.removeEventListener("mousemove", moveHDragging)
document.removeEventListener("mouseup", stopHDragging) document.removeEventListener("mouseup", stopHDragging)
isDraggingH = false
} }
</script> </script>
@ -89,6 +109,7 @@
class="v-scrollbar" class="v-scrollbar"
style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;" style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;"
on:mousedown={startVDragging} on:mousedown={startVDragging}
class:dragging={isDraggingV}
/> />
{/if} {/if}
{#if $showHScrollbar} {#if $showHScrollbar}
@ -96,6 +117,7 @@
class="h-scrollbar" class="h-scrollbar"
style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;" style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;"
on:mousedown={startHDragging} on:mousedown={startHDragging}
class:dragging={isDraggingH}
/> />
{/if} {/if}
@ -103,11 +125,12 @@
div { div {
position: absolute; position: absolute;
background: var(--spectrum-global-color-gray-500); background: var(--spectrum-global-color-gray-500);
opacity: 0.7; opacity: 0.5;
border-radius: 4px; border-radius: 4px;
transition: opacity 130ms ease-out; transition: opacity 130ms ease-out;
} }
div:hover { div:hover,
div.dragging {
opacity: 1; opacity: 1;
} }
.v-scrollbar { .v-scrollbar {

View File

@ -46,7 +46,7 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { table, columns, stickyColumn, API, dispatch } = context const { table, columns, stickyColumn, API, dispatch, config } = context
// Updates the tables primary display column // Updates the tables primary display column
const changePrimaryDisplay = async column => { const changePrimaryDisplay = async column => {
@ -56,6 +56,23 @@ export const deriveStores = context => {
}) })
} }
// Updates the width of all columns
const changeAllColumnWidths = async width => {
columns.update(state => {
return state.map(col => ({
...col,
width,
}))
})
if (get(stickyColumn)) {
stickyColumn.update(state => ({
...state,
width,
}))
}
await saveChanges()
}
// Persists column changes by saving metadata against table schema // Persists column changes by saving metadata against table schema
const saveChanges = async () => { const saveChanges = async () => {
const $columns = get(columns) const $columns = get(columns)
@ -91,7 +108,9 @@ export const deriveStores = context => {
table.set(newTable) table.set(newTable)
// Update server // Update server
await API.saveTable(newTable) if (get(config).allowSchemaChanges) {
await API.saveTable(newTable)
}
// Broadcast change to external state can be updated, as this change // Broadcast change to external state can be updated, as this change
// will not be received by the builder websocket because we caused it ourselves // will not be received by the builder websocket because we caused it ourselves
@ -105,17 +124,19 @@ export const deriveStores = context => {
saveChanges, saveChanges,
saveTable, saveTable,
changePrimaryDisplay, changePrimaryDisplay,
changeAllColumnWidths,
}, },
}, },
} }
} }
export const initialise = context => { export const initialise = context => {
const { table, columns, stickyColumn, schemaOverrides } = context const { table, columns, stickyColumn, schemaOverrides, columnWhitelist } =
context
const schema = derived( const schema = derived(
[table, schemaOverrides], [table, schemaOverrides, columnWhitelist],
([$table, $schemaOverrides]) => { ([$table, $schemaOverrides, $columnWhitelist]) => {
if (!$table?.schema) { if (!$table?.schema) {
return null return null
} }
@ -142,6 +163,16 @@ export const initialise = context => {
} }
} }
}) })
// Apply whitelist if specified
if ($columnWhitelist?.length) {
Object.keys(newSchema).forEach(key => {
if (!$columnWhitelist.includes(key)) {
delete newSchema[key]
}
})
}
return newSchema return newSchema
} }
) )
@ -209,7 +240,7 @@ export const initialise = context => {
} }
stickyColumn.set({ stickyColumn.set({
name: primaryDisplay, name: primaryDisplay,
label: $schema[primaryDisplay].name || primaryDisplay, label: $schema[primaryDisplay].displayName || primaryDisplay,
schema: $schema[primaryDisplay], schema: $schema[primaryDisplay],
width: $schema[primaryDisplay].width || DefaultColumnWidth, width: $schema[primaryDisplay].width || DefaultColumnWidth,
visible: true, visible: true,

View File

@ -0,0 +1,27 @@
import { writable } from "svelte/store"
import { derivedMemo } from "../../../utils"
export const createStores = context => {
const config = writable(context.props)
const getProp = prop => derivedMemo(config, $config => $config[prop])
// Derive and memoize some props so that we can react to them in isolation
const tableId = getProp("tableId")
const initialSortColumn = getProp("initialSortColumn")
const initialSortOrder = getProp("initialSortOrder")
const initialFilter = getProp("initialFilter")
const initialRowHeight = getProp("initialRowHeight")
const schemaOverrides = getProp("schemaOverrides")
const columnWhitelist = getProp("columnWhitelist")
return {
config,
tableId,
initialSortColumn,
initialSortOrder,
initialFilter,
initialRowHeight,
schemaOverrides,
columnWhitelist,
}
}

View File

@ -0,0 +1,19 @@
import { writable } from "svelte/store"
export const createStores = context => {
const { props } = context
// Initialise to default props
const filter = writable(props.initialFilter)
return {
filter,
}
}
export const initialise = context => {
const { filter, initialFilter } = context
// Reset filter when initial filter prop changes
initialFilter.subscribe(filter.set)
}

View File

@ -11,8 +11,14 @@ import * as Users from "./users"
import * as Validation from "./validation" import * as Validation from "./validation"
import * as Viewport from "./viewport" import * as Viewport from "./viewport"
import * as Clipboard from "./clipboard" import * as Clipboard from "./clipboard"
import * as Config from "./config"
import * as Sort from "./sort"
import * as Filter from "./filter"
const DependencyOrderedStores = [ const DependencyOrderedStores = [
Config,
Sort,
Filter,
Bounds, Bounds,
Scroll, Scroll,
Rows, Rows,

View File

@ -1,5 +1,4 @@
import { writable, get } from "svelte/store" import { writable } from "svelte/store"
import { GutterWidth } from "../lib/constants"
export const createStores = () => { export const createStores = () => {
const menu = writable({ const menu = writable({
@ -14,18 +13,25 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { menu, bounds, focusedCellId, stickyColumn, rowHeight } = context const { menu, focusedCellId, rand } = context
const open = (cellId, e) => { const open = (cellId, e) => {
const $bounds = get(bounds)
const $stickyColumn = get(stickyColumn)
const $rowHeight = get(rowHeight)
e.preventDefault() e.preventDefault()
// Get DOM node for grid data wrapper to compute relative position to
const gridNode = document.getElementById(`grid-${rand}`)
const dataNode = gridNode?.getElementsByClassName("grid-data-outer")?.[0]
if (!dataNode) {
return
}
// Compute bounds of cell relative to outer data node
const targetBounds = e.target.getBoundingClientRect()
const dataBounds = dataNode.getBoundingClientRect()
focusedCellId.set(cellId) focusedCellId.set(cellId)
menu.set({ menu.set({
left: left: targetBounds.left - dataBounds.left + e.offsetX,
e.clientX - $bounds.left + GutterWidth + ($stickyColumn?.width || 0), top: targetBounds.top - dataBounds.top + e.offsetY,
top: e.clientY - $bounds.top + $rowHeight,
visible: true, visible: true,
}) })
} }

View File

@ -4,18 +4,13 @@ import { notifications } from "@budibase/bbui"
import { NewRowID, RowPageSize } from "../lib/constants" import { NewRowID, RowPageSize } from "../lib/constants"
import { tick } from "svelte" import { tick } from "svelte"
const initialSortState = { const SuppressErrors = true
column: null,
order: "ascending",
}
export const createStores = () => { export const createStores = () => {
const rows = writable([]) const rows = writable([])
const table = writable(null) const table = writable(null)
const filter = writable([])
const loading = writable(false) const loading = writable(false)
const loaded = writable(false) const loaded = writable(false)
const sort = writable(initialSortState)
const rowChangeCache = writable({}) const rowChangeCache = writable({})
const inProgressChanges = writable({}) const inProgressChanges = writable({})
const hasNextPage = writable(false) const hasNextPage = writable(false)
@ -47,10 +42,8 @@ export const createStores = () => {
rows, rows,
rowLookupMap, rowLookupMap,
table, table,
filter,
loaded, loaded,
loading, loading,
sort,
rowChangeCache, rowChangeCache,
inProgressChanges, inProgressChanges,
hasNextPage, hasNextPage,
@ -98,15 +91,18 @@ export const deriveStores = context => {
// Reset everything when table ID changes // Reset everything when table ID changes
let unsubscribe = null let unsubscribe = null
let lastResetKey = null let lastResetKey = null
tableId.subscribe($tableId => { tableId.subscribe(async $tableId => {
// Unsub from previous fetch if one exists // Unsub from previous fetch if one exists
unsubscribe?.() unsubscribe?.()
fetch.set(null) fetch.set(null)
instanceLoaded.set(false) instanceLoaded.set(false)
loading.set(true) loading.set(true)
// Reset state // Tick to allow other reactive logic to update stores when table ID changes
filter.set([]) // before proceeding. This allows us to wipe filters etc if needed.
await tick()
const $filter = get(filter)
const $sort = get(sort)
// Create new fetch model // Create new fetch model
const newFetch = fetchData({ const newFetch = fetchData({
@ -116,9 +112,9 @@ export const deriveStores = context => {
tableId: $tableId, tableId: $tableId,
}, },
options: { options: {
filter: [], filter: $filter,
sortColumn: initialSortState.column, sortColumn: $sort.column,
sortOrder: initialSortState.order, sortOrder: $sort.order,
limit: RowPageSize, limit: RowPageSize,
paginate: true, paginate: true,
}, },
@ -224,7 +220,10 @@ export const deriveStores = context => {
const addRow = async (row, idx, bubble = false) => { const addRow = async (row, idx, bubble = false) => {
try { try {
// Create row // Create row
const newRow = await API.saveRow({ ...row, tableId: get(tableId) }) const newRow = await API.saveRow(
{ ...row, tableId: get(tableId) },
SuppressErrors
)
// Update state // Update state
if (idx != null) { if (idx != null) {
@ -351,7 +350,10 @@ export const deriveStores = context => {
...state, ...state,
[rowId]: true, [rowId]: true,
})) }))
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] }) const saved = await API.saveRow(
{ ...row, ...get(rowChangeCache)[rowId] },
SuppressErrors
)
// Update state after a successful change // Update state after a successful change
if (saved?._id) { if (saved?._id) {

View File

@ -1,6 +1,6 @@
import { writable, derived, get } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { tick } from "svelte" import { tick } from "svelte"
import { Padding, GutterWidth } from "../lib/constants" import { Padding, GutterWidth, FocusedCellMinOffset } from "../lib/constants"
export const createStores = () => { export const createStores = () => {
const scroll = writable({ const scroll = writable({
@ -138,14 +138,13 @@ export const initialise = context => {
const $scroll = get(scroll) const $scroll = get(scroll)
const $bounds = get(bounds) const $bounds = get(bounds)
const $rowHeight = get(rowHeight) const $rowHeight = get(rowHeight)
const verticalOffset = 60
// Ensure vertical position is viewable // Ensure vertical position is viewable
if ($focusedRow) { if ($focusedRow) {
// Ensure row is not below bottom of screen // Ensure row is not below bottom of screen
const rowYPos = $focusedRow.__idx * $rowHeight const rowYPos = $focusedRow.__idx * $rowHeight
const bottomCutoff = const bottomCutoff =
$scroll.top + $bounds.height - $rowHeight - verticalOffset $scroll.top + $bounds.height - $rowHeight - FocusedCellMinOffset
let delta = rowYPos - bottomCutoff let delta = rowYPos - bottomCutoff
if (delta > 0) { if (delta > 0) {
scroll.update(state => ({ scroll.update(state => ({
@ -156,7 +155,7 @@ export const initialise = context => {
// Ensure row is not above top of screen // Ensure row is not above top of screen
else { else {
const delta = $scroll.top - rowYPos + verticalOffset const delta = $scroll.top - rowYPos + FocusedCellMinOffset
if (delta > 0) { if (delta > 0) {
scroll.update(state => ({ scroll.update(state => ({
...state, ...state,
@ -171,13 +170,12 @@ export const initialise = context => {
const $visibleColumns = get(visibleColumns) const $visibleColumns = get(visibleColumns)
const columnName = $focusedCellId?.split("-")[1] const columnName = $focusedCellId?.split("-")[1]
const column = $visibleColumns.find(col => col.name === columnName) const column = $visibleColumns.find(col => col.name === columnName)
const horizontalOffset = 50
if (!column) { if (!column) {
return return
} }
// Ensure column is not cutoff on left edge // Ensure column is not cutoff on left edge
let delta = $scroll.left - column.left + horizontalOffset let delta = $scroll.left - column.left + FocusedCellMinOffset
if (delta > 0) { if (delta > 0) {
scroll.update(state => ({ scroll.update(state => ({
...state, ...state,
@ -188,7 +186,7 @@ export const initialise = context => {
// Ensure column is not cutoff on right edge // Ensure column is not cutoff on right edge
else { else {
const rightEdge = column.left + column.width const rightEdge = column.left + column.width
const rightBound = $bounds.width + $scroll.left - horizontalOffset const rightBound = $bounds.width + $scroll.left - FocusedCellMinOffset
delta = rightEdge - rightBound delta = rightEdge - rightBound
if (delta > 0) { if (delta > 0) {
scroll.update(state => ({ scroll.update(state => ({

View File

@ -0,0 +1,27 @@
import { writable } from "svelte/store"
export const createStores = context => {
const { props } = context
// Initialise to default props
const sort = writable({
column: props.initialSortColumn,
order: props.initialSortOrder || "ascending",
})
return {
sort,
}
}
export const initialise = context => {
const { sort, initialSortColumn, initialSortOrder } = context
// Reset sort when initial sort props change
initialSortColumn.subscribe(newSortColumn => {
sort.update(state => ({ ...state, column: newSortColumn }))
})
initialSortOrder.subscribe(newSortOrder => {
sort.update(state => ({ ...state, order: newSortOrder }))
})
}

View File

@ -8,13 +8,16 @@ import {
NewRowID, NewRowID,
} from "../lib/constants" } from "../lib/constants"
export const createStores = () => { export const createStores = context => {
const { props } = context
const focusedCellId = writable(null) const focusedCellId = writable(null)
const focusedCellAPI = writable(null) const focusedCellAPI = writable(null)
const selectedRows = writable({}) const selectedRows = writable({})
const hoveredRowId = writable(null) const hoveredRowId = writable(null)
const rowHeight = writable(DefaultRowHeight) const rowHeight = writable(props.initialRowHeight || DefaultRowHeight)
const previousFocusedRowId = writable(null) const previousFocusedRowId = writable(null)
const gridFocused = writable(false)
const isDragging = writable(false)
// Derive the current focused row ID // Derive the current focused row ID
const focusedRowId = derived( const focusedRowId = derived(
@ -46,6 +49,8 @@ export const createStores = () => {
previousFocusedRowId, previousFocusedRowId,
hoveredRowId, hoveredRowId,
rowHeight, rowHeight,
gridFocused,
isDragging,
selectedRows: { selectedRows: {
...selectedRows, ...selectedRows,
actions: { actions: {
@ -94,9 +99,9 @@ export const deriveStores = context => {
// Derive the amount of content lines to show in cells depending on row height // Derive the amount of content lines to show in cells depending on row height
const contentLines = derived(rowHeight, $rowHeight => { const contentLines = derived(rowHeight, $rowHeight => {
if ($rowHeight === LargeRowHeight) { if ($rowHeight >= LargeRowHeight) {
return 3 return 3
} else if ($rowHeight === MediumRowHeight) { } else if ($rowHeight >= MediumRowHeight) {
return 2 return 2
} }
return 1 return 1
@ -129,6 +134,7 @@ export const initialise = context => {
hoveredRowId, hoveredRowId,
table, table,
rowHeight, rowHeight,
initialRowHeight,
} = context } = context
// Ensure we clear invalid rows from state if they disappear // Ensure we clear invalid rows from state if they disappear
@ -185,4 +191,13 @@ export const initialise = context => {
table.subscribe($table => { table.subscribe($table => {
rowHeight.set($table?.rowHeight || DefaultRowHeight) rowHeight.set($table?.rowHeight || DefaultRowHeight)
}) })
// Reset row height when initial row height prop changes
initialRowHeight.subscribe(height => {
if (height) {
rowHeight.set(height)
} else {
rowHeight.set(get(table)?.rowHeight || DefaultRowHeight)
}
})
} }

View File

@ -108,11 +108,22 @@ export const deriveStores = context => {
// Determine the row index at which we should start vertically inverting cell // Determine the row index at which we should start vertically inverting cell
// dropdowns // dropdowns
const rowVerticalInversionIndex = derived( const rowVerticalInversionIndex = derived(
[visualRowCapacity, rowHeight], [height, rowHeight, scrollTop],
([$visualRowCapacity, $rowHeight]) => { ([$height, $rowHeight, $scrollTop]) => {
return ( const offset = $scrollTop % $rowHeight
$visualRowCapacity - Math.ceil(MaxCellRenderHeight / $rowHeight) - 2
) // Compute the last row index with space to render popovers below it
const minBottom =
$height - ScrollBarSize * 3 - MaxCellRenderHeight + offset
const lastIdx = Math.floor(minBottom / $rowHeight)
// Compute the first row index with space to render popovers above it
const minTop = MaxCellRenderHeight + offset
const firstIdx = Math.ceil(minTop / $rowHeight)
// Use the greater of the two indices so that we prefer content below,
// unless there is room to render the entire popover above
return Math.max(lastIdx, firstIdx)
} }
) )
@ -125,7 +136,7 @@ export const deriveStores = context => {
let inversionIdx = $renderedColumns.length let inversionIdx = $renderedColumns.length
for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) { for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow < cutoff) { if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
break break
} }
} }

View File

@ -136,8 +136,10 @@ export default class DataFetch {
this.options.sortOrder = "ascending" this.options.sortOrder = "ascending"
} }
// If no sort column, use the primary display and fallback to first column // If no sort column, or an invalid sort column is provided, use the primary
if (!this.options.sortColumn) { // display and fallback to first column
const sortValid = this.options.sortColumn && schema[this.options.sortColumn]
if (!sortValid) {
let newSortColumn let newSortColumn
if (definition?.primaryDisplay && schema[definition.primaryDisplay]) { if (definition?.primaryDisplay && schema[definition.primaryDisplay]) {
newSortColumn = definition.primaryDisplay newSortColumn = definition.primaryDisplay

View File

@ -3,4 +3,5 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies" export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles" export * as RoleUtils from "./roles"
export * as Utils from "./utils" export * as Utils from "./utils"
export { memo, derivedMemo } from "./memo"
export { createWebsocket } from "./websocket" export { createWebsocket } from "./websocket"

View File

@ -0,0 +1,43 @@
import { writable, get, derived } from "svelte/store"
// A simple svelte store which deeply compares all changes and ensures that
// subscribed children will only fire when a new value is actually set
export const memo = initialValue => {
const store = writable(initialValue)
const tryUpdateValue = (newValue, currentValue) => {
// Sanity check for primitive equality
if (currentValue === newValue) {
return
}
// Otherwise deep compare via JSON stringify
const currentString = JSON.stringify(currentValue)
const newString = JSON.stringify(newValue)
if (currentString !== newString) {
store.set(newValue)
}
}
return {
subscribe: store.subscribe,
set: newValue => {
const currentValue = get(store)
tryUpdateValue(newValue, currentValue)
},
update: updateFn => {
const currentValue = get(store)
let mutableCurrentValue = JSON.parse(JSON.stringify(currentValue))
const newValue = updateFn(mutableCurrentValue)
tryUpdateValue(newValue, currentValue)
},
}
}
// Enriched version of svelte's derived store which returns a memo
export const derivedMemo = (store, derivation) => {
const derivedStore = derived(store, derivation)
const memoStore = memo(get(derivedStore))
derivedStore.subscribe(memoStore.set)
return memoStore
}

View File

@ -1,6 +1,6 @@
import authorized from "../middleware/authorized" import authorized from "../middleware/authorized"
import { BaseSocket } from "./websocket" import { BaseSocket } from "./websocket"
import { permissions } from "@budibase/backend-core" import { context, permissions } from "@budibase/backend-core"
import http from "http" import http from "http"
import Koa from "koa" import Koa from "koa"
import { getTableId } from "../api/controllers/row/utils" import { getTableId } from "../api/controllers/row/utils"
@ -8,20 +8,56 @@ import { Row, Table } from "@budibase/types"
import { Socket } from "socket.io" import { Socket } from "socket.io"
import { GridSocketEvent } from "@budibase/shared-core" import { GridSocketEvent } from "@budibase/shared-core"
const { PermissionType, PermissionLevel } = permissions
export default class GridSocket extends BaseSocket { export default class GridSocket extends BaseSocket {
constructor(app: Koa, server: http.Server) { constructor(app: Koa, server: http.Server) {
super(app, server, "/socket/grid", [authorized(permissions.BUILDER)]) super(app, server, "/socket/grid")
} }
async onConnect(socket: Socket) { async onConnect(socket: Socket) {
// Initial identification of connected spreadsheet // Initial identification of connected spreadsheet
socket.on(GridSocketEvent.SelectTable, async ({ tableId }, callback) => { socket.on(
await this.joinRoom(socket, tableId) GridSocketEvent.SelectTable,
async ({ tableId, appId }, callback) => {
// Ignore if no table or app specified
if (!tableId || !appId) {
socket.disconnect(true)
return
}
// Reply with all users in current room // Check if the user has permission to read this resource
const sessions = await this.getRoomSessions(tableId) const middleware = authorized(
callback({ users: sessions }) PermissionType.TABLE,
}) PermissionLevel.READ
)
const ctx = {
appId,
resourceId: tableId,
roleId: socket.data.roleId,
user: { _id: socket.data._id },
isAuthenticated: socket.data.isAuthenticated,
request: {
url: "/fake",
},
get: () => null,
throw: () => {
// If they don't have access, immediately disconnect them
socket.disconnect(true)
},
}
await context.doInAppContext(appId, async () => {
await middleware(ctx, async () => {
const room = `${appId}-${tableId}`
await this.joinRoom(socket, room)
// Reply with all users in current room
const sessions = await this.getRoomSessions(room)
callback({ users: sessions })
})
})
}
)
// Handle users selecting a new cell // Handle users selecting a new cell
socket.on(GridSocketEvent.SelectCell, ({ cellId }) => { socket.on(GridSocketEvent.SelectCell, ({ cellId }) => {
@ -31,7 +67,8 @@ export default class GridSocket extends BaseSocket {
emitRowUpdate(ctx: any, row: Row) { emitRowUpdate(ctx: any, row: Row) {
const tableId = getTableId(ctx) const tableId = getTableId(ctx)
this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { const room = `${ctx.appId}-${tableId}`
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, {
id: row._id, id: row._id,
row, row,
}) })
@ -39,17 +76,20 @@ export default class GridSocket extends BaseSocket {
emitRowDeletion(ctx: any, id: string) { emitRowDeletion(ctx: any, id: string) {
const tableId = getTableId(ctx) const tableId = getTableId(ctx)
this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { id, row: null }) const room = `${ctx.appId}-${tableId}`
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { id, row: null })
} }
emitTableUpdate(ctx: any, table: Table) { emitTableUpdate(ctx: any, table: Table) {
this.emitToRoom(ctx, table._id!, GridSocketEvent.TableChange, { const room = `${ctx.appId}-${table._id}`
this.emitToRoom(ctx, room, GridSocketEvent.TableChange, {
id: table._id, id: table._id,
table, table,
}) })
} }
emitTableDeletion(ctx: any, id: string) { emitTableDeletion(ctx: any, id: string) {
this.emitToRoom(ctx, id, GridSocketEvent.TableChange, { id, table: null }) const room = `${ctx.appId}-${id}`
this.emitToRoom(ctx, room, GridSocketEvent.TableChange, { id, table: null })
} }
} }

View File

@ -4,12 +4,18 @@ import Koa from "koa"
import Cookies from "cookies" import Cookies from "cookies"
import { userAgent } from "koa-useragent" import { userAgent } from "koa-useragent"
import { auth, Header, redis } from "@budibase/backend-core" import { auth, Header, redis } from "@budibase/backend-core"
import currentApp from "../middleware/currentapp"
import { createAdapter } from "@socket.io/redis-adapter" import { createAdapter } from "@socket.io/redis-adapter"
import { Socket } from "socket.io" import { Socket } from "socket.io"
import { getSocketPubSubClients } from "../utilities/redis" import { getSocketPubSubClients } from "../utilities/redis"
import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core" import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
import { SocketSession } from "@budibase/types" import { SocketSession } from "@budibase/types"
import { v4 as uuid } from "uuid"
const anonUser = () => ({
_id: uuid(),
email: "user@mail.com",
firstName: "Anonymous",
})
export class BaseSocket { export class BaseSocket {
io: Server io: Server
@ -34,7 +40,6 @@ export class BaseSocket {
const middlewares = [ const middlewares = [
userAgent, userAgent,
authenticate, authenticate,
currentApp,
...(additionalMiddlewares || []), ...(additionalMiddlewares || []),
] ]
@ -70,7 +75,8 @@ export class BaseSocket {
// Middlewares are finished // Middlewares are finished
// Extract some data from our enriched koa context to persist // Extract some data from our enriched koa context to persist
// as metadata for the socket // as metadata for the socket
const { _id, email, firstName, lastName } = ctx.user const user = ctx.user?._id ? ctx.user : anonUser()
const { _id, email, firstName, lastName } = user
socket.data = { socket.data = {
_id, _id,
email, email,
@ -78,6 +84,8 @@ export class BaseSocket {
lastName, lastName,
sessionId: socket.id, sessionId: socket.id,
connectedAt: Date.now(), connectedAt: Date.now(),
isAuthenticated: ctx.isAuthenticated,
roleId: ctx.roleId,
} }
next() next()
} }