1
0
Fork 0
mirror of synced 2024-05-16 10:23:13 +12:00

Merge pull request #10365 from Budibase/spreadsheet-integration

Grid in data section (2)
This commit is contained in:
Andrew Kingston 2023-04-20 17:49:27 +01:00 committed by GitHub
commit 676b85dbd8
125 changed files with 6323 additions and 561 deletions

View file

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

View file

@ -71,7 +71,7 @@
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}

View file

@ -6,6 +6,9 @@ let clickHandlers = []
*/
const handleClick = event => {
// Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) {
return
}
for (let className of ignoredClasses) {
if (event.target.closest(className)) {
return
@ -29,6 +32,7 @@ const handleClick = event => {
})
}
document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("contextmenu", handleClick, true)
/**
* Adds or updates a click handler

View file

@ -138,7 +138,7 @@
}
</script>
<div class="container">
<div class="container" class:compact>
{#if selectedImage}
{#if gallery}
<div class="gallery">
@ -355,6 +355,9 @@
input[type="file"] {
display: none;
}
.compact .spectrum-Dropzone {
padding: 6px 0 !important;
}
.gallery {
display: flex;
@ -379,6 +382,17 @@
object-fit: contain;
margin: 20px 30px;
}
.compact .placeholder,
.compact img {
margin: 10px 16px;
}
.compact img {
height: 90px;
}
.compact .gallery {
padding: 6px 10px;
margin-bottom: 8px;
}
.title {
display: flex;
flex-direction: row;
@ -447,6 +461,13 @@
.disabled .spectrum-Heading--sizeL {
color: var(--spectrum-alias-text-color-disabled);
}
.compact .spectrum-Dropzone {
padding-top: 8px;
padding-bottom: 8px;
}
.compact .spectrum-IllustratedMessage-description {
margin: 0;
}
.tags {
margin-top: 20px;

View file

@ -20,12 +20,13 @@
const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value)
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
$: selectedLookupMap = getSelectedLookupMap(arrayValue)
$: optionLookupMap = getOptionLookupMap(options)
$: fieldText = getFieldText(value, optionLookupMap, placeholder)
$: fieldText = getFieldText(arrayValue, optionLookupMap, placeholder)
$: isOptionSelected = optionValue => selectedLookupMap[optionValue] === true
$: toggleOption = makeToggleOption(selectedLookupMap, value)
$: toggleOption = makeToggleOption(selectedLookupMap, arrayValue)
const getFieldText = (value, map, placeholder) => {
if (Array.isArray(value) && value.length > 0) {
@ -84,7 +85,7 @@
{readonly}
{fieldText}
{options}
isPlaceholder={!value?.length}
isPlaceholder={!arrayValue.length}
{autocomplete}
bind:fetchTerm
{useFetch}

View file

@ -16,6 +16,7 @@
export let gallery = true
export let fileTags = []
export let maximum = undefined
export let compact = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -37,6 +38,7 @@
{gallery}
{fileTags}
{maximum}
{compact}
on:change={onChange}
/>
</Field>

View file

@ -20,6 +20,7 @@
export let dismissible = true
export let offset = 5
export let customHeight
export let animate = true
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -78,7 +79,7 @@
class="spectrum-Popover is-open"
role="presentation"
style="height: {customHeight}"
transition:fly|local={{ y: -20, duration: 200 }}
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
>
<slot />
</div>

View file

@ -5,8 +5,9 @@
const displayLimit = 5
$: badges = Array.isArray(value) ? value.slice(0, displayLimit) : []
$: leftover = (value?.length ?? 0) - badges.length
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
$: badges = arrayValue.slice(0, displayLimit)
$: leftover = arrayValue.length - badges.length
</script>
{#each badges as badge}

View file

@ -143,7 +143,7 @@
}
fields?.forEach(field => {
const fieldSchema = schema[field]
if (fieldSchema.width) {
if (fieldSchema.width && typeof fieldSchema.width === "string") {
style += ` ${fieldSchema.width}`
} else {
style += " minmax(auto, 1fr)"

View file

@ -97,4 +97,22 @@
a {
text-decoration: none;
}
}
/* Custom theme additions */
.spectrum--darkest {
--drop-shadow: rgba(0, 0, 0, 0.6);
--spectrum-global-color-blue-100: rgb(28, 33, 43);
}
.spectrum--dark {
--drop-shadow: rgba(0, 0, 0, 0.3);
--spectrum-global-color-blue-100: rgb(42, 47, 57);
}
.spectrum--light {
--drop-shadow: rgba(0, 0, 0, 0.075);
--spectrum-global-color-blue-100: rgb(240, 245, 255);
}
.spectrum--lightest {
--drop-shadow: rgba(0, 0, 0, 0.05);
--spectrum-global-color-blue-100: rgb(240, 244, 255);
}

View file

@ -1,286 +1,74 @@
<script>
import { fade } from "svelte/transition"
import { tables } from "stores/backend"
import CreateRowButton from "./buttons/CreateRowButton.svelte"
import CreateColumnButton from "./buttons/CreateColumnButton.svelte"
import CreateViewButton from "./buttons/CreateViewButton.svelte"
import ExistingRelationshipButton from "./buttons/ExistingRelationshipButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import ImportButton from "./buttons/ImportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import TableFilterButton from "./buttons/TableFilterButton.svelte"
import Table from "./Table.svelte"
import { TableNames } from "constants"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import {
Pagination,
Heading,
Body,
Layout,
notifications,
} from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridAddColumnModal from "components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridEditUserModal from "components/backend/DataTable/modals/grid/GridEditUserModal.svelte"
import GridCreateViewButton from "components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte"
import GridImportButton from "components/backend/DataTable/buttons/grid/GridImportButton.svelte"
import GridExportButton from "components/backend/DataTable/buttons/grid/GridExportButton.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
let hideAutocolumns = true
let filters
const userSchemaOverrides = {
firstName: { name: "First name", disabled: true },
lastName: { name: "Last name", disabled: true },
email: { name: "Email", disabled: true },
roleId: { name: "Role", disabled: true },
status: { name: "Status", disabled: true },
}
$: isUsersTable = $tables.selected?._id === TableNames.USERS
$: type = $tables.selected?.type
$: isInternal = type !== "external"
$: schema = $tables.selected?.schema
$: enrichedSchema = enrichSchema($tables.selected?.schema)
$: id = $tables.selected?._id
$: fetch = createFetch(id)
$: hasCols = checkHasCols(schema)
$: hasRows = !!$fetch.rows?.length
$: showError($fetch.error)
$: id, (filters = null)
let appliedFilter
let rawFilter
let appliedSort
let selectedRows = []
$: enrichedSchema,
() => {
appliedFilter = null
rawFilter = null
appliedSort = null
selectedRows = []
}
$: if (Number.isInteger($fetch.pageNumber)) {
selectedRows = []
}
const showError = error => {
if (error) {
notifications.error(error?.message || "Unable to fetch data.")
}
}
const enrichSchema = schema => {
let tempSchema = { ...schema }
tempSchema._id = {
type: "internal",
editable: false,
displayName: "ID",
autocolumn: true,
}
if (isInternal) {
tempSchema._rev = {
type: "internal",
editable: false,
displayName: "Revision",
autocolumn: true,
}
}
return tempSchema
}
const checkHasCols = schema => {
if (!schema || Object.keys(schema).length === 0) {
return false
}
let fields = Object.values(schema)
for (let field of fields) {
if (!field.autocolumn) {
return true
}
}
return false
}
// Fetches new data whenever the table changes
const createFetch = tableId => {
return fetchData({
API,
datasource: {
tableId,
type: "table",
},
options: {
schema,
limit: 10,
paginate: true,
},
})
}
// Fetch data whenever sorting option changes
const onSort = async e => {
const sort = {
sortColumn: e.detail.column,
sortOrder: e.detail.order,
}
await fetch.update(sort)
appliedSort = { ...sort }
appliedSort.sortOrder = appliedSort.sortOrder.toLowerCase()
selectedRows = []
}
// Fetch data whenever filters change
const onFilter = e => {
filters = e.detail
fetch.update({
filter: filters,
})
appliedFilter = e.detail
}
// Fetch data whenever schema changes
const onUpdateColumns = () => {
selectedRows = []
fetch.refresh()
tables.fetchTable(id)
}
// Fetch data whenever rows are modified. Unfortunately we have to lose
// our pagination place, as our bookmarks will have shifted.
const onUpdateRows = () => {
selectedRows = []
fetch.refresh()
}
// When importing new rows it is better to reinitialise request/paging data.
// Not doing so causes inconsistency in paging behaviour and content.
const onImportData = () => {
fetch.getInitialData()
}
$: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external"
</script>
<div>
<Table
title={$tables.selected?.name}
schema={enrichedSchema}
{type}
<div class="wrapper">
<Grid
{API}
tableId={id}
data={$fetch.rows}
bind:hideAutocolumns
loading={!$fetch.loaded}
on:sort={onSort}
allowEditing
disableSorting
on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows}
on:selectionUpdated={e => {
selectedRows = e.detail
}}
customPlaceholder
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)}
>
<div class="buttons">
<div class="left-buttons">
<CreateColumnButton
highlighted={$fetch.loaded && (!hasCols || !hasRows)}
on:updatecolumns={onUpdateColumns}
/>
{#if !isUsersTable}
<CreateRowButton
on:updaterows={onUpdateRows}
title={"Create row"}
modalContentComponent={CreateEditRow}
disabled={!hasCols}
highlighted={$fetch.loaded && hasCols && !hasRows}
/>
{/if}
{#if isInternal}
<CreateViewButton disabled={!hasCols || !hasRows} />
{/if}
</div>
<div class="right-buttons">
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<ImportButton
disabled={$tables.selected?._id === "ta_users"}
tableId={$tables.selected?._id}
on:importrows={onImportData}
/>
<ExportButton
disabled={!hasRows || !hasCols}
view={$tables.selected?._id}
filters={appliedFilter}
sorting={appliedSort}
{selectedRows}
/>
{#key id}
<TableFilterButton
{schema}
{filters}
on:change={onFilter}
disabled={!hasCols}
tableId={id}
/>
{/key}
</div>
</div>
<div slot="placeholder">
<Layout gap="S">
{#if !hasCols}
<Heading>Let's create some columns</Heading>
<Body>
Start building out your table structure<br />
by adding some columns
</Body>
{:else}
<Heading>Now let's add a row</Heading>
<Body>
Add some data to your table<br />
by adding some rows
</Body>
{/if}
</Layout>
</div>
</Table>
{#key id}
<div in:fade={{ delay: 200, duration: 100 }}>
<div class="pagination">
<Pagination
page={$fetch.pageNumber + 1}
hasPrevPage={$fetch.hasPrevPage}
hasNextPage={$fetch.hasNextPage}
goToPrevPage={$fetch.loading ? null : fetch.prevPage}
goToNextPage={$fetch.loading ? null : fetch.nextPage}
/>
</div>
</div>
{/key}
<svelte:fragment slot="controls">
{#if isInternal}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<GridRelationshipButton />
{/if}
<GridImportButton disabled={isUsersTable} />
<GridExportButton />
<GridFilterButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable}
<GridEditUserModal />
{:else}
<GridCreateEditRowModal />
{/if}
</svelte:fragment>
</Grid>
</div>
<style>
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
.buttons {
.wrapper {
flex: 1 1 auto;
margin: -28px -40px -40px -40px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.left-buttons,
.right-buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
flex-direction: column;
background: var(--background);
overflow: hidden;
}
</style>

View file

@ -1,15 +1,10 @@
<script>
import { fade } from "svelte/transition"
import { goto, params } from "@roxi/routify"
import { Table, Modal, Heading, notifications, Layout } from "@budibase/bbui"
import { API } from "api"
import { Table, Heading, Layout } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import DeleteRowsButton from "./buttons/DeleteRowsButton.svelte"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import CreateEditUser from "./modals/CreateEditUser.svelte"
import CreateEditColumn from "./modals/CreateEditColumn.svelte"
import { cloneDeep } from "lodash/fp"
import {
TableNames,
UNEDITABLE_USER_FIELDS,
@ -22,7 +17,6 @@
export let data = []
export let tableId
export let title
export let allowEditing = false
export let loading = false
export let hideAutocolumns
export let rowCount
@ -32,12 +26,7 @@
const dispatch = createEventDispatcher()
let selectedRows = []
let editableColumn
let editableRow
let editRowModal
let editColumnModal
let customRenderers = []
let confirmDelete
$: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS
@ -92,36 +81,6 @@
`/builder/app/${$params.application}/data/table/${tableId}/relationship/${rowId}/${fieldName}`
)
}
const deleteRows = async targetRows => {
try {
await API.deleteRows({
tableId,
rows: targetRows,
})
const deletedRowIds = targetRows.map(row => row._id)
data = data.filter(row => deletedRowIds.indexOf(row._id))
notifications.success(`Successfully deleted ${targetRows.length} rows`)
} catch (error) {
notifications.error("Error deleting rows")
}
}
const editRow = row => {
editableRow = row
if (row) {
editRowModal.show()
}
}
const editColumn = field => {
editableColumn = cloneDeep(schema?.[field])
if (editableColumn) {
editColumnModal.show()
}
}
</script>
<Layout noPadding gap="S">
@ -138,16 +97,6 @@
{/if}
<div class="popovers">
<slot />
{#if !isUsersTable && selectedRows.length > 0}
<DeleteRowsButton
on:updaterows
{selectedRows}
deleteRows={async rows => {
await deleteRows(rows)
resetSelectedRows()
}}
/>
{/if}
</div>
</Layout>
{#key tableId}
@ -160,13 +109,7 @@
{rowCount}
{disableSorting}
{customPlaceholder}
bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing}
allowEditColumns={allowEditing}
showAutoColumns={!hideAutocolumns}
on:editcolumn={e => editColumn(e.detail)}
on:editrow={e => editRow(e.detail)}
on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
>
@ -176,42 +119,6 @@
{/key}
</Layout>
<Modal bind:this={editRowModal}>
<svelte:component
this={editRowComponent}
on:updaterows
on:deleteRows={() => {
confirmDelete.show()
}}
row={editableRow}
/>
</Modal>
<ConfirmDialog
bind:this={confirmDelete}
okText="Delete"
onOk={async () => {
if (editableRow) {
await deleteRows([editableRow])
}
editableRow = undefined
}}
onCancel={async () => {
editRow(editableRow)
}}
title="Confirm Deletion"
>
Are you sure you want to delete this row?
</ConfirmDialog>
<Modal bind:this={editColumnModal}>
<CreateEditColumn
field={editableColumn}
on:updatecolumns
onClosed={editColumnModal.hide}
/>
</Modal>
<style>
.table-title {
height: 24px;

View file

@ -57,7 +57,6 @@
{data}
{loading}
{type}
allowEditing={false}
rowCount={10}
bind:hideAutocolumns
>

View file

@ -9,7 +9,6 @@
<ActionButton
icon="Calculator"
size="S"
quiet
on:click={modal.show}
active={view.field && view.calculation}

View file

@ -1,24 +0,0 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
export let highlighted = false
export let disabled = false
let modal
</script>
<ActionButton
{disabled}
selected={highlighted}
emphasized={highlighted}
icon="TableColumnAddRight"
quiet
size="S"
on:click={modal.show}
>
Create column
</ActionButton>
<Modal bind:this={modal}>
<CreateEditColumn on:updatecolumns />
</Modal>

View file

@ -1,26 +0,0 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditRow from "../modals/CreateEditRow.svelte"
export let modalContentComponent = CreateEditRow
export let title = "Create row"
export let disabled = false
export let highlighted = false
let modal
</script>
<ActionButton
{disabled}
emphasized={highlighted}
selected={highlighted}
icon="TableRowAddBottom"
size="S"
quiet
on:click={modal.show}
>
{title}
</ActionButton>
<Modal bind:this={modal}>
<svelte:component this={modalContentComponent} on:updaterows />
</Modal>

View file

@ -1,21 +0,0 @@
<script>
import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../modals/CreateViewModal.svelte"
export let disabled = false
let modal
</script>
<ActionButton
{disabled}
icon="CollectionAdd"
size="S"
quiet
on:click={modal.show}
>
Create view
</ActionButton>
<Modal bind:this={modal}>
<CreateViewModal />
</Modal>

View file

@ -19,7 +19,7 @@
$: text = `${item}${selectedRows?.length === 1 ? "" : "s"}`
</script>
<Button icon="Delete" size="s" warning quiet on:click={modal.show}>
<Button icon="Delete" warning quiet on:click={modal.show}>
Delete
{selectedRows.length}
{text}

View file

@ -1,15 +1,13 @@
<script>
import { Button, Modal } from "@budibase/bbui"
import { ActionButton, Modal } from "@budibase/bbui"
import EditRolesModal from "../modals/EditRoles.svelte"
let modal
</script>
<div>
<Button icon="UsersLock" primary size="S" quiet on:click={modal.show}>
Edit roles
</Button>
</div>
<ActionButton icon="UsersLock" quiet on:click={modal.show}>
Edit roles
</ActionButton>
<Modal bind:this={modal}>
<EditRolesModal />
</Modal>

View file

@ -7,15 +7,23 @@
export let table
const dispatch = createEventDispatcher()
$: datasource = findDatasource(table?._id)
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
let modal
const findDatasource = tableId => {
return $datasources.list.find(datasource => {
return (
Object.values(datasource.entities || {}).find(entity => {
return entity._id === tableId
}) != null
)
})
}
async function saveRelationship() {
try {
// Create datasource
@ -28,15 +36,9 @@
}
</script>
{#if table.sourceId}
{#if datasource}
<div>
<ActionButton
icon="DataCorrelated"
primary
size="S"
quiet
on:click={modal.show}
>
<ActionButton icon="DataCorrelated" primary quiet on:click={modal.show}>
Define existing relationship
</ActionButton>
</div>

View file

@ -11,13 +11,7 @@
let modal
</script>
<ActionButton
{disabled}
icon="DataDownload"
size="S"
quiet
on:click={modal.show}
>
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}>
Export
</ActionButton>
<Modal bind:this={modal}>

View file

@ -10,7 +10,6 @@
<Button
icon="Group"
primary
size="S"
quiet
active={!!view.groupBy}
on:click={modal.show}

View file

@ -11,7 +11,6 @@
<ActionButton
icon={hideAutocolumns ? "VisibilityOff" : "Visibility"}
primary
size="S"
quiet
on:click={hideOrUnhide}
>

View file

@ -8,7 +8,7 @@
let modal
</script>
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show} {disabled}>
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}>
Import
</ActionButton>
<Modal bind:this={modal}>

View file

@ -4,6 +4,7 @@
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
export let resourceId
export let disabled = false
let modal
let resourcePermissions
@ -14,7 +15,7 @@
}
</script>
<ActionButton icon="LockClosed" size="S" quiet on:click={openDropdown}>
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}>
Manage access
</ActionButton>
<Modal bind:this={modal}>

View file

@ -18,11 +18,10 @@
<ActionButton
icon="Filter"
size="S"
quiet
{disabled}
on:click={modal.show}
active={tempValue?.length > 0}
selected={tempValue?.length > 0}
>
Filter
</ActionButton>

View file

@ -9,7 +9,6 @@
<ActionButton
icon="Filter"
size="S"
quiet
on:click={modal.show}
active={view.filters?.length}

View file

@ -0,0 +1,18 @@
<script>
import { getContext } from "svelte"
import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../../modals/CreateViewModal.svelte"
const { rows, columns } = getContext("grid")
let modal
$: disabled = !$columns.length || !$rows.length
</script>
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
Create view
</ActionButton>
<Modal bind:this={modal}>
<CreateViewModal />
</Modal>

View file

@ -0,0 +1,21 @@
<script>
import ExportButton from "../ExportButton.svelte"
import { getContext } from "svelte"
const { rows, columns, tableId, sort, selectedRows, filter } =
getContext("grid")
$: disabled = !$rows.length || !$columns.length
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
</script>
<ExportButton
{disabled}
view={$tableId}
filters={$filter}
sorting={{
sortColumn: $sort.column,
sortOrder: $sort.order,
}}
selectedRows={selectedRowArray}
/>

View file

@ -0,0 +1,20 @@
<script>
import TableFilterButton from "../TableFilterButton.svelte"
import { getContext } from "svelte"
const { columns, config, filter, table } = getContext("grid")
const onFilter = e => {
filter.set(e.detail || [])
}
</script>
{#key $config.tableId}
<TableFilterButton
schema={$table?.schema}
filters={$filter}
on:change={onFilter}
disabled={!$columns.length}
tableId={$config.tableId}
/>
{/key}

View file

@ -0,0 +1,14 @@
<script>
import ImportButton from "../ImportButton.svelte"
import { getContext } from "svelte"
export let disabled = false
const { rows, tableId } = getContext("grid")
</script>
<ImportButton
{disabled}
tableId={$tableId}
on:importrows={rows.actions.refreshData}
/>

View file

@ -0,0 +1,8 @@
<script>
import ManageAccessButton from "../ManageAccessButton.svelte"
import { getContext } from "svelte"
const { config } = getContext("grid")
</script>
<ManageAccessButton resourceId={$config.tableId} />

View file

@ -0,0 +1,13 @@
<script>
import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte"
import { getContext } from "svelte"
const { table, rows } = getContext("grid")
</script>
{#if $table}
<ExistingRelationshipButton
table={$table}
on:updatecolumns={() => rows.actions.refreshData()}
/>
{/if}

View file

@ -182,8 +182,12 @@
indexes,
})
dispatch("updatecolumns")
if (originalName) {
notifications.success("Column updated successfully")
} else {
notifications.success("Column created successfully")
}
} catch (err) {
console.log(err)
notifications.error(`Error saving column: ${err.message}`)
}
}
@ -199,7 +203,7 @@
notifications.error("You cannot delete the display column")
} else {
await tables.deleteField(editableColumn)
notifications.success(`Column ${editableColumn.name} deleted.`)
notifications.success(`Column ${editableColumn.name} deleted`)
confirmDeleteDialog.hide()
hide()
deletion = false

View file

@ -23,9 +23,9 @@
async function saveRow() {
errors = []
try {
await API.saveRow({ ...row, tableId: table._id })
const res = await API.saveRow({ ...row, tableId: table._id })
notifications.success("Row saved successfully")
dispatch("updaterows")
dispatch("updaterows", res._id)
} catch (error) {
const response = error.json
if (error.handled && response?.errors) {

View file

@ -55,9 +55,9 @@
}
try {
await API.saveRow({ ...row, tableId: table._id })
const res = await API.saveRow({ ...row, tableId: table._id })
notifications.success("User saved successfully")
dispatch("updaterows")
dispatch("updaterows", res.id)
} catch (error) {
if (error.handled) {
const response = error.json

View file

@ -0,0 +1,15 @@
<script>
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid")
let modal
onMount(() => subscribe("add-column", modal.show))
</script>
<Modal bind:this={modal}>
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />
</Modal>

View file

@ -0,0 +1,28 @@
<script>
import CreateEditRow from "../../modals/CreateEditRow.svelte"
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
const { subscribe, rows } = getContext("grid")
let modal
let row
onMount(() =>
subscribe("add-row", () => {
row = {}
modal.show()
})
)
onMount(() =>
subscribe("edit-row", rowToEdit => {
row = cloneDeep(rowToEdit)
modal.show()
})
)
</script>
<Modal bind:this={modal}>
<CreateEditRow {row} on:updaterows={e => rows.actions.refreshRow(e.detail)} />
</Modal>

View file

@ -0,0 +1,24 @@
<script>
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import CreateEditColumn from "../CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid")
let editableColumn
let editColumnModal
const editColumn = column => {
editableColumn = column
editColumnModal.show()
}
onMount(() => subscribe("edit-column", editColumn))
</script>
<Modal bind:this={editColumnModal}>
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
</Modal>

View file

@ -0,0 +1,25 @@
<script>
import CreateEditUser from "../../modals/CreateEditUser.svelte"
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
const { subscribe, rows } = getContext("grid")
let modal
let row
onMount(() =>
subscribe("edit-row", rowToEdit => {
row = cloneDeep(rowToEdit)
modal.show()
})
)
</script>
<Modal bind:this={modal}>
<CreateEditUser
{row}
on:updaterows={e => rows.actions.refreshRow(e.detail)}
/>
</Modal>

View file

@ -13,6 +13,13 @@
$: sortedTables = $tables.list
.filter(table => table.sourceId === sourceId)
.sort(alphabetical)
const selectTable = tableId => {
tables.select(tableId)
if (!$isActive("./table/:tableId")) {
$goto(`./table/${tableId}`)
}
}
</script>
{#if $database?._id}
@ -25,7 +32,7 @@
text={table.name}
selected={$isActive("./table/:tableId") &&
$tables.selected?._id === table._id}
on:click={() => $goto(`./table/${table._id}`)}
on:click={() => selectTable(table._id)}
>
{#if table._id !== TableNames.USERS}
<EditTablePopover {table} />

View file

@ -325,9 +325,4 @@
text-overflow: ellipsis;
white-space: nowrap;
}
footer {
display: flex;
justify-content: center;
}
</style>

View file

@ -59,9 +59,7 @@
<div class="app-row-actions">
<AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={goToOverview}>Manage</Button>
<Button size="S" primary disabled={app.lockedOther} on:click={goToBuilder}>
Edit
</Button>
<Button size="S" primary on:click={goToBuilder}>Edit</Button>
</div>
</div>

View file

@ -70,7 +70,6 @@ a {
background: var(--spectrum-alias-background-color-default);
}
html * {
scrollbar-width: thin;
scrollbar-color: var(--spectrum-global-color-gray-400)
var(--spectrum-alias-background-color-default);
}

View file

@ -28,6 +28,7 @@
"dataprovider",
"repeater",
"table",
"spreadsheet",
"dynamicfilter",
"daterangepicker"
]

View file

@ -135,6 +135,24 @@ export function createTablesStore() {
await save(draft)
}
const updateTable = table => {
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
return
}
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
}
return {
subscribe: derivedStore.subscribe,
fetch,
@ -145,6 +163,7 @@ export function createTablesStore() {
delete: deleteTable,
saveField,
deleteField,
updateTable,
}
}

View file

@ -5161,5 +5161,36 @@
"type": "schema",
"suffix": "repeater"
}
},
"spreadsheet": {
"name": "Spreadsheet",
"icon": "ViewGrid",
"settings": [
{
"key": "table",
"type": "table",
"label": "Table"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field/sortable",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": [
"Ascending",
"Descending"
],
"defaultValue": "Ascending"
}
]
}
}

View file

@ -95,7 +95,7 @@
<svelte:head>
{#if $builderStore.usedPlugins?.length}
{#each $builderStore.usedPlugins as plugin}
{#each $builderStore.usedPlugins as plugin (plugin.hash)}
<script src={`${plugin.jsUrl}`}></script>
{/each}
{/if}

View file

@ -12,6 +12,7 @@ import {
environmentStore,
sidePanelStore,
dndIsDragging,
confirmationStore,
} from "stores"
import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable"
@ -35,6 +36,7 @@ export default {
sidePanelStore,
dndIsDragging,
currentRole,
confirmationStore,
styleable,
linkable,
getAction,

View file

@ -1,12 +1,14 @@
export const domDebounce = callback => {
export const domDebounce = (callback, extractParams = x => x) => {
let active = false
return e => {
let lastParams
return (...params) => {
lastParams = extractParams(...params)
if (!active) {
window.requestAnimationFrame(() => {
callback(e)
active = true
requestAnimationFrame(() => {
callback(lastParams)
active = false
})
active = true
}
}
}

View file

@ -8,7 +8,9 @@
"dependencies": {
"@budibase/bbui": "2.5.6-alpha.6",
"@budibase/shared-core": "2.5.6-alpha.6",
"dayjs": "^1.11.7",
"lodash": "^4.17.21",
"socket.io-client": "^4.6.1",
"svelte": "^3.46.2"
}
}

View file

@ -0,0 +1,153 @@
<script>
import { onMount } from "svelte"
import { getContext } from "svelte"
import { Dropzone, notifications } from "@budibase/bbui"
export let value
export let focused = false
export let onChange
export let readonly = false
export let api
export let invertX = false
export let invertY = false
const { API } = getContext("grid")
const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"]
let isOpen = false
$: editable = focused && !readonly
$: {
if (!focused) {
close()
}
}
const onKeyDown = () => {
return isOpen
}
const open = () => {
isOpen = true
}
const close = () => {
isOpen = false
}
const isImage = extension => {
return imageExtensions.includes(extension?.toLowerCase())
}
const handleFileTooLarge = fileSizeLimit => {
notifications.error(
`Files cannot exceed ${
fileSizeLimit / 1000000
}MB. Please try again with smaller files.`
)
}
const processFiles = async fileList => {
let data = new FormData()
for (let i = 0; i < fileList.length; i++) {
data.append("file", fileList[i])
}
try {
return await API.uploadBuilderAttachment(data)
} catch (error) {
notifications.error("Failed to upload attachment")
return []
}
}
const deleteAttachments = async fileList => {
try {
return await API.deleteBuilderAttachments(fileList)
} catch (error) {
return []
}
}
onMount(() => {
api = {
focus: () => open(),
blur: () => close(),
onKeyDown,
}
})
</script>
<div class="attachment-cell" class:editable on:click={editable ? open : null}>
{#each value || [] as attachment}
{#if isImage(attachment.extension)}
<img src={attachment.url} alt={attachment.extension} />
{:else}
<div class="file" title={attachment.name}>
{attachment.extension}
</div>
{/if}
{/each}
</div>
{#if isOpen}
<div class="dropzone" class:invertX class:invertY>
<Dropzone
{value}
compact
on:change={e => onChange(e.detail)}
{processFiles}
{deleteAttachments}
{handleFileTooLarge}
/>
</div>
{/if}
<style>
.attachment-cell {
flex: 1 1 auto;
display: flex;
flex-direction: row;
align-items: stretch;
padding: var(--cell-padding);
flex-wrap: nowrap;
gap: var(--cell-spacing);
align-self: stretch;
overflow: hidden;
user-select: none;
}
.attachment-cell.editable:hover {
cursor: pointer;
}
.file {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: 0 10px;
color: var(--spectrum-global-color-gray-700);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
font-size: 12px;
user-select: none;
}
.dropzone {
position: absolute;
top: 100%;
left: 0;
width: 320px;
background: var(--background);
border: var(--cell-border);
padding: var(--cell-padding);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
}
.dropzone.invertX {
left: auto;
right: 0;
}
.dropzone.invertY {
transform: translateY(-100%);
top: 0;
}
</style>

View file

@ -0,0 +1,44 @@
<script>
import { onMount } from "svelte"
import { Checkbox } from "@budibase/bbui"
export let value
export let focused = false
export let onChange
export let readonly = false
export let api
$: editable = focused && !readonly
const handleChange = e => {
onChange(e.detail)
}
const onKeyDown = e => {
if (e.key === "Enter") {
onChange(!value)
return true
}
return false
}
onMount(() => {
api = {
onKeyDown,
}
})
</script>
<div class="boolean-cell" class:editable>
<Checkbox {value} on:change={handleChange} />
</div>
<style>
.boolean-cell {
padding: 2px var(--cell-padding);
pointer-events: none;
}
.boolean-cell.editable {
pointer-events: all;
}
</style>

View file

@ -0,0 +1,86 @@
<script>
import { getContext } from "svelte"
import GridCell from "./GridCell.svelte"
import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store"
const { rows, focusedCellId, focusedCellAPI, menu, config, validation } =
getContext("grid")
export let highlighted
export let selected
export let rowFocused
export let rowIdx
export let focused
export let selectedUser
export let column
export let row
export let cellId
export let updateValue = rows.actions.updateValue
export let invertX = false
export let invertY = false
const emptyError = writable(null)
let api
// Get the error for this cell if the row is focused
$: error = getErrorStore(rowFocused, cellId)
// Determine if the cell is editable
$: readonly =
column.schema.autocolumn ||
column.schema.disabled ||
(!$config.allowEditRows && row._id)
// Register this cell API if the row is focused
$: {
if (focused) {
focusedCellAPI.set(cellAPI)
}
}
const getErrorStore = (selected, cellId) => {
if (!selected) {
return emptyError
}
return derived(validation, $validation => $validation[cellId])
}
const cellAPI = {
focus: () => api?.focus(),
blur: () => api?.blur(),
onKeyDown: (...params) => api?.onKeyDown(...params),
isReadonly: () => readonly,
getType: () => column.schema.type,
getValue: () => row[column.name],
setValue: value => {
validation.actions.setError(cellId, null)
updateValue(row._id, column.name, value)
},
}
</script>
<GridCell
{highlighted}
{selected}
{rowIdx}
{focused}
{selectedUser}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width}
>
<svelte:component
this={getCellRenderer(column)}
bind:api
value={row[column.name]}
schema={column.schema}
onChange={cellAPI.setValue}
{focused}
{readonly}
{invertY}
{invertX}
/>
</GridCell>

View file

@ -0,0 +1,77 @@
<script>
import dayjs from "dayjs"
import { CoreDatePicker, Icon } from "@budibase/bbui"
export let value
export let schema
export let onChange
export let focused = false
export let readonly = false
// adding the 0- will turn a string like 00:00:00 into a valid ISO
// date, but will make actual ISO dates invalid
$: time = new Date(`0-${value}`)
$: timeOnly = !isNaN(time) || schema?.timeOnly
$: dateOnly = schema?.dateOnly
$: format = timeOnly
? "HH:mm:ss"
: dateOnly
? "MMM D YYYY"
: "MMM D YYYY, HH:mm"
$: editable = focused && !readonly
</script>
<div class="container">
<div class="value">
{#if value}
{dayjs(timeOnly ? time : value).format(format)}
{/if}
</div>
{#if editable}
<Icon name="Calendar" />
{/if}
</div>
{#if editable}
<div class="picker">
<CoreDatePicker
{value}
on:change={e => onChange(e.detail)}
appendTo={document.documentElement}
enableTime={!dateOnly}
{timeOnly}
time24hr
ignoreTimezones={schema.ignoreTimezones}
/>
</div>
{/if}
<style>
.container {
padding: var(--cell-padding);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex: 1 1 auto;
gap: var(--cell-spacing);
}
.value {
flex: 1 1 auto;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 20px;
}
.picker {
position: absolute;
opacity: 0;
}
.picker :global(.flatpickr) {
min-width: 0;
}
.picker :global(.spectrum-Textfield-input) {
width: 100%;
}
</style>

View file

@ -0,0 +1,5 @@
<script>
import TextCell from "./TextCell.svelte"
</script>
<TextCell {...$$props} readonly />

View file

@ -0,0 +1,159 @@
<script>
export let focused = false
export let selected = false
export let highlighted = false
export let width = ""
export let selectedUser = null
export let error = null
export let rowIdx
export let defaultHeight = false
export let center = false
$: style = getStyle(width, selectedUser)
const getStyle = (width, selectedUser) => {
let style = `flex: 0 0 ${width}px;`
if (selectedUser) {
style += `--cell-color:${selectedUser.color};`
}
return style
}
</script>
<div
class="cell"
class:selected
class:highlighted
class:focused
class:error
class:center
class:default-height={defaultHeight}
class:selected-other={selectedUser != null}
on:focus
on:mousedown
on:mouseup
on:click
on:contextmenu
{style}
data-row={rowIdx}
>
{#if error}
<div class="label">
{error}
</div>
{/if}
<slot />
{#if selectedUser && !focused}
<div class="label">
{selectedUser.label}
</div>
{/if}
</div>
<style>
/* Cells */
.cell {
height: var(--row-height);
border-bottom: var(--cell-border);
border-right: var(--cell-border);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
color: var(--spectrum-global-color-gray-800);
font-size: var(--cell-font-size);
gap: var(--cell-spacing);
background: var(--cell-background);
position: relative;
width: 0;
--cell-color: transparent;
}
.cell.default-height {
height: var(--default-row-height);
}
.cell.center {
align-items: center;
}
/* Cell border */
.cell.focused:after,
.cell.error:after,
.cell.selected-other:not(.focused):after {
content: " ";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border: 2px solid var(--cell-color);
pointer-events: none;
border-radius: 2px;
box-sizing: border-box;
}
/* Cell border for cells with labels */
.cell.error:after,
.cell.selected-other:not(.focused):after {
border-radius: 0 2px 2px 2px;
}
.cell[data-row="0"].error:after,
.cell[data-row="0"].selected-other:not(.focused):after {
border-radius: 2px 2px 2px 0;
}
/* Cell z-index */
.cell.error,
.cell.selected-other:not(.focused) {
z-index: 1;
}
.cell.focused {
z-index: 2;
}
.cell.focused {
--cell-color: var(--spectrum-global-color-blue-400);
}
.cell.error {
--cell-color: var(--spectrum-global-color-red-500);
}
.cell:not(.focused) {
user-select: none;
}
.cell:hover {
cursor: default;
}
.cell.highlighted:not(.focused) {
--cell-background: var(--cell-background-hover);
}
.cell.selected:not(.focused) {
--cell-background: var(--spectrum-global-color-blue-100);
}
/* Label for additional text */
.label {
position: absolute;
bottom: 100%;
left: 0;
padding: 1px 4px 3px 4px;
margin: 0 0 -2px 0;
background: var(--user-color);
border-radius: 2px;
display: block;
color: white;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
user-select: none;
}
.cell[data-row="0"] .label {
bottom: auto;
top: 100%;
border-radius: 0 2px 2px 2px;
padding: 2px 4px 2px 4px;
margin: -2px 0 0 0;
}
.error .label {
background: var(--spectrum-global-color-red-500);
}
</style>

View file

@ -0,0 +1,237 @@
<script>
import { getContext } from "svelte"
import GridCell from "./GridCell.svelte"
import { Icon, Popover, Menu, MenuItem } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils"
export let column
export let idx
export let orderable = true
const {
reorder,
isReordering,
isResizing,
rand,
sort,
renderedColumns,
dispatch,
config,
ui,
columns,
} = getContext("grid")
const bannedDisplayColumnTypes = [
"link",
"array",
"attachment",
"boolean",
"formula",
"json",
]
let anchor
let open = false
let timeout
$: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $renderedColumns.length - 1
const editColumn = () => {
dispatch("edit-column", column.schema)
open = false
}
const onMouseDown = e => {
if (e.button === 0 && orderable) {
timeout = setTimeout(() => {
reorder.actions.startReordering(column.name, e)
}, 200)
}
}
const onMouseUp = e => {
if (e.button === 0 && orderable) {
clearTimeout(timeout)
}
}
const onContextMenu = e => {
e.preventDefault()
ui.actions.blur()
open = !open
}
const sortAscending = () => {
sort.set({
column: column.name,
order: "ascending",
})
open = false
}
const sortDescending = () => {
sort.set({
column: column.name,
order: "descending",
})
open = false
}
const moveLeft = () => {
reorder.actions.moveColumnLeft(column.name)
open = false
}
const moveRight = () => {
reorder.actions.moveColumnRight(column.name)
open = false
}
const makeDisplayColumn = () => {
columns.actions.changePrimaryDisplay(column.name)
open = false
}
</script>
<div
class="header-cell"
class:open
style="flex: 0 0 {column.width}px;"
bind:this={anchor}
class:disabled={$isReordering || $isResizing}
class:sorted={sortedBy}
>
<GridCell
on:mousedown={onMouseDown}
on:mouseup={onMouseUp}
on:contextmenu={onContextMenu}
width={column.width}
left={column.left}
defaultHeight
center
>
<Icon
size="S"
name={getColumnIcon(column)}
color={`var(--spectrum-global-color-gray-600)`}
/>
<div class="name">
{column.label}
</div>
{#if sortedBy}
<div class="sort-indicator">
<Icon
size="S"
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"}
color="var(--spectrum-global-color-gray-600)"
/>
</div>
{/if}
<div
class="more"
on:mousedown|stopPropagation
on:click={() => (open = true)}
>
<Icon
size="S"
name="MoreVertical"
color="var(--spectrum-global-color-gray-600)"
/>
</div>
</GridCell>
</div>
<Popover
bind:open
{anchor}
align="right"
offset={0}
popoverTarget={document.getElementById(`grid-${rand}`)}
animate={false}
>
<Menu>
<MenuItem
icon="Edit"
on:click={editColumn}
disabled={!$config.allowEditColumns || column.schema.disabled}
>
Edit column
</MenuItem>
<MenuItem
icon="Label"
on:click={makeDisplayColumn}
disabled={idx === "sticky" ||
!$config.allowEditColumns ||
bannedDisplayColumnTypes.includes(column.schema.type)}
>
Use as display column
</MenuItem>
<MenuItem
icon="SortOrderUp"
on:click={sortAscending}
disabled={column.name === $sort.column && $sort.order === "ascending"}
>
Sort A-Z
</MenuItem>
<MenuItem
icon="SortOrderDown"
on:click={sortDescending}
disabled={column.name === $sort.column && $sort.order === "descending"}
>
Sort Z-A
</MenuItem>
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
Move left
</MenuItem>
<MenuItem disabled={!canMoveRight} icon="ChevronRight" on:click={moveRight}>
Move right
</MenuItem>
</Menu>
</Popover>
<style>
.header-cell {
display: flex;
}
.header-cell.disabled {
pointer-events: none;
}
.header-cell :global(.cell) {
padding: 0 var(--cell-padding);
gap: calc(2 * var(--cell-spacing));
background: var(--spectrum-global-color-gray-100);
}
.header-cell.sorted :global(.cell) {
background: var(--spectrum-global-color-gray-200);
}
.name {
flex: 1 1 auto;
width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.more {
display: none;
padding: 4px;
margin: 0 -4px;
}
.header-cell.open .more,
.header-cell:hover .more {
display: block;
}
.more:hover {
cursor: pointer;
}
.more:hover :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-800) !important;
}
.header-cell.open .sort-indicator,
.header-cell:hover .sort-indicator {
display: none;
}
</style>

View file

@ -0,0 +1,36 @@
<script>
import LongFormCell from "./LongFormCell.svelte"
export let onChange
export let value
export let api
$: stringified = getStringifiedValue(value)
const getStringifiedValue = value => {
if (!value) {
return value
}
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return null
}
}
const parse = value => {
const trimmed = value?.trim()
if (!trimmed) {
onChange(null)
return
}
try {
const parsed = JSON.parse(trimmed)
onChange(parsed)
} catch (error) {
// Swallow
}
}
</script>
<LongFormCell {...$$props} bind:api value={stringified} onChange={parse} />

View file

@ -0,0 +1,117 @@
<script>
import { onMount, tick } from "svelte"
export let value
export let focused = false
export let onChange
export let readonly = false
export let api
export let invertX = false
export let invertY = false
let textarea
let isOpen = false
$: editable = focused && !readonly
$: {
if (!focused) {
isOpen = false
}
}
const handleChange = e => {
onChange(e.target.value)
}
const onKeyDown = () => {
return isOpen
}
const open = async () => {
isOpen = true
await tick()
textarea.focus()
textarea.setSelectionRange(0, 0)
}
const close = () => {
textarea?.blur()
isOpen = false
}
onMount(() => {
api = {
focus: () => open(),
blur: () => close(),
onKeyDown,
}
})
</script>
{#if isOpen}
<textarea
class:invertX
class:invertY
bind:this={textarea}
value={value || ""}
on:change={handleChange}
on:wheel|stopPropagation
spellcheck="false"
/>
{:else}
<div class="long-form-cell" on:click={editable ? open : null} class:editable>
<div class="value">
{value || ""}
</div>
</div>
{/if}
<style>
.long-form-cell {
flex: 1 1 auto;
padding: var(--cell-padding);
align-self: stretch;
display: flex;
align-items: flex-start;
overflow: hidden;
}
.long-form-cell.editable:hover {
cursor: text;
}
.value {
display: -webkit-box;
-webkit-line-clamp: var(--content-lines);
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 20px;
}
textarea {
padding: var(--cell-padding);
margin: 0;
border: 2px solid var(--cell-color);
background: var(--cell-background);
font-size: var(--cell-font-size);
font-family: var(--font-sans);
color: inherit;
position: absolute;
top: 0;
left: 0;
width: calc(100% + var(--max-cell-render-width-overflow));
height: var(--max-cell-render-height);
z-index: 1;
border-radius: 2px;
resize: none;
line-height: 20px;
}
textarea.invertX {
left: auto;
right: 0;
}
textarea.invertY {
transform: translateY(-100%);
top: calc(100% + 1px);
}
textarea:focus {
outline: none;
}
</style>

View file

@ -0,0 +1,7 @@
<script>
import OptionsCell from "./OptionsCell.svelte"
export let api
</script>
<OptionsCell bind:api {...$$props} multi />

View file

@ -0,0 +1,7 @@
<script>
import TextCell from "./TextCell.svelte"
export let api
</script>
<TextCell {...$$props} bind:api type="number" />

View file

@ -0,0 +1,239 @@
<script>
import { Icon } from "@budibase/bbui"
import { getColor } from "../lib/utils"
import { onMount } from "svelte"
export let value
export let schema
export let onChange
export let focused = false
export let multi = false
export let readonly = false
export let api
export let invertX = false
export let invertY = false
let isOpen = false
let focusedOptionIdx = null
$: options = schema?.constraints?.inclusion || []
$: editable = focused && !readonly
$: values = Array.isArray(value) ? value : [value].filter(x => x != null)
$: {
// Close when deselected
if (!focused) {
close()
}
}
const open = () => {
isOpen = true
focusedOptionIdx = 0
}
const close = () => {
isOpen = false
}
const getOptionColor = value => {
const index = value ? options.indexOf(value) : null
return getColor(index)
}
const toggleOption = option => {
if (!multi) {
onChange(option === value ? null : option)
close()
} else {
const sanitizedValues = values.filter(x => options.includes(x))
if (sanitizedValues.includes(option)) {
onChange(sanitizedValues.filter(x => x !== option))
} else {
onChange([...sanitizedValues, option])
}
}
}
const onKeyDown = e => {
if (!isOpen) {
return false
}
e.preventDefault()
if (e.key === "ArrowDown") {
focusedOptionIdx = Math.min(focusedOptionIdx + 1, options.length - 1)
} else if (e.key === "ArrowUp") {
focusedOptionIdx = Math.max(focusedOptionIdx - 1, 0)
} else if (e.key === "Enter") {
toggleOption(options[focusedOptionIdx])
}
return true
}
onMount(() => {
api = {
focus: open,
blur: close,
onKeyDown,
}
})
</script>
<div
class="container"
class:multi
class:editable
class:open
on:click|self={editable ? open : null}
>
<div class="values" on:click={editable ? open : null}>
{#each values as val}
{@const color = getOptionColor(val)}
{#if color}
<div class="badge text" style="--color: {color}">
<span>
{val}
</span>
</div>
{:else}
<div class="text">
{val || ""}
</div>
{/if}
{/each}
</div>
{#if editable}
<div class="arrow" on:click={open}>
<Icon name="ChevronDown" />
</div>
{/if}
{#if isOpen}
<div
class="options"
class:invertX
class:invertY
on:wheel={e => e.stopPropagation()}
>
{#each options as option, idx}
{@const color = getOptionColor(option)}
<div
class="option"
on:click={() => toggleOption(option)}
class:focused={focusedOptionIdx === idx}
on:mouseenter={() => (focusedOptionIdx = idx)}
>
<div class="badge text" style="--color: {color}">
{option}
</div>
{#if values.includes(option)}
<Icon
name="Checkmark"
color="var(--spectrum-global-color-blue-400)"
/>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
align-self: stretch;
flex: 1 1 auto;
overflow: hidden;
}
.container.editable:hover {
cursor: pointer;
}
.values {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
flex: 1 1 auto;
grid-column-gap: var(--cell-spacing);
grid-row-gap: var(--cell-padding);
overflow: hidden;
padding: var(--cell-padding);
flex-wrap: wrap;
}
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi .text {
flex: 0 0 auto;
}
.badge {
padding: 0 var(--cell-padding);
background: var(--color);
border-radius: var(--cell-padding);
user-select: none;
display: flex;
align-items: center;
gap: var(--cell-spacing);
height: 20px;
max-width: 100%;
}
.badge span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.arrow {
position: absolute;
right: 0;
top: 0;
height: 100%;
bottom: 2px;
padding: 0 6px 0 16px;
display: grid;
place-items: center;
background: linear-gradient(
to right,
transparent 0%,
var(--cell-background) 40%
);
}
.options {
min-width: calc(100% + 2px);
position: absolute;
top: 100%;
left: -1px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
max-height: var(--max-cell-render-height);
overflow-y: auto;
border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
}
.options.invertX {
left: auto;
right: 0;
}
.options.invertY {
transform: translateY(-100%);
top: 0;
}
.option {
flex: 0 0 var(--default-row-height);
padding: 0 var(--cell-padding);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--cell-spacing);
background-color: var(--background);
}
.option:hover,
.option.focused {
background-color: var(--spectrum-global-color-gray-200);
}
</style>

View file

@ -0,0 +1,503 @@
<script context="module">
// We can create a module level cache for all relationship cells to avoid
// having to fetch the table definition one time for each cell
let primaryDisplayCache = {}
const getPrimaryDisplayForTableId = async (API, tableId) => {
if (primaryDisplayCache[tableId]) {
return primaryDisplayCache[tableId]
}
const definition = await API.fetchTableDefinition(tableId)
const primaryDisplay =
definition?.primaryDisplay || definition?.schema?.[0]?.name
primaryDisplayCache[tableId] = primaryDisplay
return primaryDisplay
}
</script>
<script>
import { getColor } from "../lib/utils"
import { onMount, getContext } from "svelte"
import { Icon, Input, ProgressCircle } from "@budibase/bbui"
import { debounce } from "../../../utils/utils"
export let value
export let api
export let readonly
export let focused
export let schema
export let onChange
export let invertX = false
export let invertY = false
const { API, dispatch } = getContext("grid")
const color = getColor(0)
let isOpen = false
let searchResults
let searchString
let lastSearchString
let primaryDisplay
let candidateIndex
let lastSearchId
let searching = false
$: oneRowOnly = schema?.relationshipType === "one-to-many"
$: editable = focused && !readonly
$: lookupMap = buildLookupMap(value, isOpen)
$: debouncedSearch(searchString)
$: {
if (!focused) {
close()
}
}
// Builds a lookup map to quickly check which rows are selected
const buildLookupMap = (value, isOpen) => {
let map = {}
if (!isOpen || !value?.length) {
return map
}
for (let i = 0; i < value.length; i++) {
map[value[i]._id] = true
}
return map
}
// Checks if a certain row is currently selected
const isRowSelected = row => {
if (!row?._id) {
return false
}
return lookupMap?.[row._id] === true
}
// Search for rows based on the search string
const search = async (searchString, force = false) => {
// Avoid update state at all if we've already handled the update and this is
// a wasted search due to svelte reactivity
if (!force && !searchString && !lastSearchString) {
return
}
// Reset state if this search is invalid
if (!schema?.tableId || !isOpen) {
lastSearchString = null
candidateIndex = null
searchResults = []
return
}
// Search for results, using IDs to track invocations and ensure we're
// handling the latest update
lastSearchId = Math.random()
searching = true
const thisSearchId = lastSearchId
const results = await API.searchTable({
paginate: false,
tableId: schema.tableId,
limit: 20,
query: {
string: {
[`1:${primaryDisplay}`]: searchString || "",
},
},
})
searching = false
// In case searching takes longer than our debounced update, abandon these
// results
if (thisSearchId !== lastSearchId) {
return
}
// Sort and process results
searchResults = sortRows(
results.rows?.map(row => ({
...row,
primaryDisplay: row[primaryDisplay],
}))
)
candidateIndex = searchResults?.length ? 0 : null
lastSearchString = searchString
}
// Debounced version of searching
const debouncedSearch = debounce(search, 250)
// Alphabetically sorts rows by their primary display column
const sortRows = rows => {
if (!rows?.length) {
return []
}
return rows.slice().sort((a, b) => {
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
})
}
const open = async () => {
isOpen = true
// Find the primary display for the related table
if (!primaryDisplay) {
searching = true
primaryDisplay = await getPrimaryDisplayForTableId(API, schema.tableId)
}
// Show initial list of results
await search(null, true)
}
const close = () => {
isOpen = false
searchResults = []
searchString = null
lastSearchString = null
candidateIndex = null
}
const onKeyDown = e => {
if (!isOpen) {
return false
}
if (e.key === "ArrowDown") {
// Select next result on down arrow
e.preventDefault()
if (candidateIndex == null) {
candidateIndex = 0
} else {
candidateIndex = Math.min(searchResults.length - 1, candidateIndex + 1)
}
} else if (e.key === "ArrowUp") {
// Select previous result on up array
e.preventDefault()
if (candidateIndex === 0) {
candidateIndex = null
} else if (candidateIndex != null) {
candidateIndex = Math.max(0, candidateIndex - 1)
}
} else if (e.key === "Enter") {
// Toggle the highlighted result on enter press
if (candidateIndex != null && searchResults[candidateIndex] != null) {
toggleRow(searchResults[candidateIndex])
}
}
return true
}
// Toggles whether a row is included in the relationship or not
const toggleRow = async row => {
if (value?.some(x => x._id === row._id)) {
// If the row is already included, remove it and update the candidate
// row to be the the same position if possible
if (oneRowOnly) {
await onChange([])
} else {
const newValue = value.filter(x => x._id !== row._id)
if (!newValue.length) {
candidateIndex = null
} else {
candidateIndex = Math.min(candidateIndex, newValue.length - 1)
}
await onChange(newValue)
}
} else {
// If we don't have this row, include it
if (oneRowOnly) {
await onChange([row])
} else {
await onChange(sortRows([...(value || []), row]))
}
candidateIndex = null
}
close()
}
const showRelationship = async id => {
const relatedRow = await API.fetchRow({
tableId: schema.tableId,
rowId: id,
})
dispatch("edit-row", relatedRow)
}
const readable = value => {
if (value == null) {
return ""
}
if (value instanceof Object) {
return JSON.stringify(value)
}
return value
}
onMount(() => {
api = {
focus: open,
blur: close,
onKeyDown,
}
})
</script>
<div class="wrapper" class:editable class:focused style="--color:{color};">
<div class="container">
<div class="values" on:wheel={e => (focused ? e.stopPropagation() : null)}>
{#each value || [] as relationship, idx}
{#if relationship.primaryDisplay}
<div class="badge">
<span
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable(relationship.primaryDisplay)}
</span>
{#if editable}
<Icon
name="Close"
size="XS"
hoverable
on:click={() => toggleRow(relationship)}
/>
{/if}
</div>
{/if}
{/each}
{#if editable}
<div class="add" on:click={open}>
<Icon name="Add" size="S" />
</div>
{/if}
</div>
{#if value?.length}
<div class="count">
{value?.length || 0}
</div>
{/if}
</div>
{#if isOpen}
<div class="dropdown" class:invertX class:invertY on:wheel|stopPropagation>
<div class="search">
<Input
autofocus
quiet
type="text"
bind:value={searchString}
placeholder={primaryDisplay ? `Search by ${primaryDisplay}` : null}
/>
</div>
{#if searching}
<div class="searching">
<ProgressCircle size="S" />
</div>
{:else if searchResults?.length}
<div class="results">
{#each searchResults as row, idx}
<div
class="result"
on:click={() => toggleRow(row)}
class:candidate={idx === candidateIndex}
on:mouseenter={() => (candidateIndex = idx)}
>
<div class="badge">
<span>
{readable(row.primaryDisplay)}
</span>
</div>
{#if isRowSelected(row)}
<Icon
size="S"
name="Checkmark"
color="var(--spectrum-global-color-blue-400)"
/>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.wrapper {
flex: 1 1 auto;
align-self: flex-start;
min-height: var(--row-height);
max-height: var(--row-height);
overflow: hidden;
--max-relationship-height: 120px;
}
.wrapper.focused {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: var(--cell-background);
z-index: 1;
max-height: none;
overflow: visible;
}
.container {
min-height: var(--row-height);
overflow: hidden;
display: flex;
align-items: flex-start;
}
.focused .container {
overflow-y: auto;
border-radius: 2px;
max-height: var(--max-relationship-height);
}
.focused .container:after {
content: " ";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border: 2px solid var(--cell-color);
pointer-events: none;
border-radius: 2px;
box-sizing: border-box;
}
.values {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
flex: 1 1 auto;
grid-column-gap: var(--cell-spacing);
grid-row-gap: var(--cell-padding);
overflow: hidden;
padding: var(--cell-padding);
flex-wrap: wrap;
}
.count {
display: none;
position: absolute;
top: 0;
right: 0;
color: var(--spectrum-global-color-gray-500);
padding: var(--cell-padding) var(--cell-padding) var(--cell-padding) 20px;
background: linear-gradient(
to right,
transparent 0%,
var(--cell-background) 40%
);
}
.wrapper:hover:not(.focused) .count {
display: block;
}
.badge {
flex: 0 0 auto;
padding: 0 var(--cell-padding);
background: var(--color);
border-radius: var(--cell-padding);
user-select: none;
display: flex;
align-items: center;
gap: var(--cell-spacing);
height: 20px;
max-width: 100%;
}
.badge span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.editable .values .badge span:hover {
cursor: pointer;
text-decoration: underline;
}
.add {
background: var(--spectrum-global-color-gray-200);
padding: 4px;
border-radius: 4px;
}
.add :global(.spectrum-Icon) {
width: 12px;
height: 12px;
}
.add:hover {
background: var(--spectrum-global-color-gray-300);
cursor: pointer;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: calc(
var(--max-cell-render-height) + var(--row-height) -
var(--max-relationship-height)
);
background: var(--background);
border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 0 8px 0;
}
.dropdown.invertY {
transform: translateY(-100%);
top: -1px;
}
.dropdown.invertX {
left: auto;
right: 0;
}
.searching {
padding: var(--cell-padding);
display: flex;
justify-content: center;
}
.results {
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: stretch;
}
.result {
padding: 0 var(--cell-padding);
flex: 0 0 var(--default-row-height);
display: flex;
gap: var(--cell-spacing);
justify-content: space-between;
align-items: center;
}
.result.candidate {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
.result .badge {
max-width: calc(100% - 30px);
}
.search {
flex: 0 0 calc(var(--default-row-height) - 1px);
display: flex;
align-items: center;
margin: 4px var(--cell-padding);
width: calc(100% - 2 * var(--cell-padding));
}
.search :global(.spectrum-Textfield) {
min-width: 0;
width: 100%;
}
.search :global(.spectrum-Textfield-input) {
font-size: 13px;
}
.search :global(.spectrum-Form-item) {
flex: 1 1 auto;
}
</style>

View file

@ -0,0 +1,110 @@
<script>
import { onMount } from "svelte"
export let value
export let focused = false
export let onChange
export let type = "text"
export let readonly = false
export let api
let input
let active = false
$: editable = focused && !readonly
const handleChange = e => {
onChange(e.target.value)
}
const onKeyDown = e => {
if (!active) {
return false
}
if (e.key === "Enter") {
input?.blur()
const event = new KeyboardEvent("keydown", { key: "ArrowDown" })
document.dispatchEvent(event)
}
return true
}
onMount(() => {
api = {
focus: () => input?.focus(),
blur: () => input?.blur(),
onKeyDown,
}
})
</script>
{#if editable}
<input
bind:this={input}
on:focus={() => (active = true)}
on:blur={() => (active = false)}
{type}
value={value || ""}
on:change={handleChange}
spellcheck="false"
/>
{:else}
<div class="text-cell" class:number={type === "number"}>
<div class="value">
{value || ""}
</div>
</div>
{/if}
<style>
.text-cell {
flex: 1 1 auto;
padding: var(--cell-padding);
align-self: stretch;
display: flex;
align-items: flex-start;
overflow: hidden;
}
.text-cell.number {
justify-content: flex-end;
}
.value {
display: -webkit-box;
-webkit-line-clamp: var(--content-lines);
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 20px;
}
.number .value {
-webkit-line-clamp: 1;
}
input {
flex: 1 1 auto;
border: none;
padding: var(--cell-padding);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: none;
font-size: var(--cell-font-size);
font-family: var(--font-sans);
color: inherit;
line-height: 20px;
}
input:focus {
outline: none;
}
input[type="number"] {
text-align: right;
}
/* Hide arrows for number fields */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>

View file

@ -0,0 +1,16 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
const { config, dispatch } = getContext("grid")
</script>
<ActionButton
icon="TableColumnAddRight"
quiet
size="M"
on:click={() => dispatch("add-column")}
disabled={!$config.allowAddColumns}
>
Create column
</ActionButton>

View file

@ -0,0 +1,18 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
const { dispatch, columns, stickyColumn, config, loaded } = getContext("grid")
</script>
<ActionButton
icon="TableRowAddBottom"
quiet
size="M"
on:click={() => dispatch("add-row")}
disabled={!loaded ||
!$config.allowAddRows ||
(!$columns.length && !$stickyColumn)}
>
Create row
</ActionButton>

View file

@ -0,0 +1,46 @@
<script>
import { Button } from "@budibase/bbui"
</script>
<div class="beta-background" />
<div class="beta">
Enjoying the Grid?
<Button
size="M"
cta
on:click={() => window.open("https://t.maze.co/156382627", "_blank")}
>
Give Feedback
</Button>
</div>
<style>
.beta {
position: absolute;
bottom: 32px;
right: 32px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.beta :global(.spectrum-Button) {
background: var(--spectrum-global-color-magenta-400);
border-color: var(--spectrum-global-color-magenta-400);
}
.beta-background {
z-index: 0;
pointer-events: none;
position: absolute;
bottom: -230px;
right: -105px;
width: 1400px;
height: 320px;
transform: rotate(-22deg);
background: linear-gradient(
to top,
var(--cell-background) 20%,
transparent
);
}
</style>

View file

@ -0,0 +1,72 @@
<script>
import {
Modal,
ModalContent,
ActionButton,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
const { selectedRows, rows, config } = getContext("grid")
let modal
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: rowsToDelete = Object.entries($selectedRows)
.map(entry => {
if (entry[1] === true) {
return $rows.find(x => x._id === entry[0])
} else {
return null
}
})
.filter(x => x != null)
// Deletion callback when confirmed
const performDeletion = async () => {
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete)
notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
}
</script>
{#if selectedRowCount}
<div class="delete-button" data-ignore-click-outside="true">
<ActionButton
icon="Delete"
size="S"
on:click={modal.show}
disabled={!$config.allowEditRows}
>
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
</ActionButton>
</div>
{/if}
<Modal bind:this={modal}>
<ModalContent
title="Delete rows"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performDeletion}
size="M"
>
Are you sure you want to delete {selectedRowCount}
row{selectedRowCount === 1 ? "" : "s"}?
</ModalContent>
</Modal>
<style>
.delete-button :global(.spectrum-ActionButton:not(:disabled) *) {
color: var(--spectrum-global-color-red-400);
}
.delete-button :global(.spectrum-ActionButton:not(:disabled)) {
border-color: var(--spectrum-global-color-red-400);
}
/*.delete-button.disabled :global(.spectrum-ActionButton *) {*/
/* color: var(--spectrum-global-color-gray-600);*/
/*}*/
/*.delete-button.disabled :global(.spectrum-ActionButton) {*/
/* border-color: var(--spectrum-global-color-gray-400);*/
/*}*/
</style>

View file

@ -0,0 +1,91 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover, Toggle } from "@budibase/bbui"
const { columns } = getContext("grid")
let open = false
let anchor
$: anyHidden = $columns.some(col => !col.visible)
const toggleVisibility = (column, visible) => {
columns.update(state => {
const index = state.findIndex(col => col.name === column.name)
state[index].visible = visible
return state.slice()
})
columns.actions.saveChanges()
}
const showAll = () => {
columns.update(state => {
return state.map(col => ({
...col,
visible: true,
}))
})
columns.actions.saveChanges()
}
const hideAll = () => {
columns.update(state => {
return state.map(col => ({
...col,
visible: false,
}))
})
columns.actions.saveChanges()
}
</script>
<div bind:this={anchor}>
<ActionButton
icon="VisibilityOff"
quiet
size="M"
on:click={() => (open = !open)}
selected={open || anyHidden}
disabled={!$columns.length}
>
Hide columns
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
<div class="columns">
{#each $columns as column}
<Toggle
size="S"
value={column.visible}
on:change={e => toggleVisibility(column, e.detail)}
/>
<span>{column.name}</span>
{/each}
</div>
<div class="buttons">
<ActionButton on:click={showAll}>Show all</ActionButton>
<ActionButton on:click={hideAll}>Hide all</ActionButton>
</div>
</div>
</Popover>
<style>
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
}
.columns {
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
}
</style>

View file

@ -0,0 +1,70 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui"
import {
LargeRowHeight,
MediumRowHeight,
SmallRowHeight,
} from "../lib/constants"
const { rowHeight, columns, table } = 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="LineHeight"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
>
Row height
</ActionButton>
</div>
<Popover bind:open {anchor} align="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,107 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui"
const { sort, visibleColumns, stickyColumn } = getContext("grid")
const orderOptions = [
{ label: "A-Z", value: "ascending" },
{ label: "Z-A", value: "descending" },
]
let open = false
let anchor
$: columnOptions = getColumnOptions($stickyColumn, $visibleColumns)
$: checkValidSortColumn($sort.column, $stickyColumn, $visibleColumns)
const getColumnOptions = (stickyColumn, columns) => {
let options = []
if (stickyColumn) {
options.push(stickyColumn.name)
}
return [...options, ...columns.map(col => col.name)]
}
const updateSortColumn = e => {
sort.update(state => ({
...state,
column: e.detail,
}))
}
const updateSortOrder = e => {
sort.update(state => ({
...state,
order: e.detail,
}))
}
// Ensure we never have a sort column selected that is not visible
const checkValidSortColumn = (sortColumn, stickyColumn, visibleColumns) => {
if (!sortColumn) {
return
}
if (
sortColumn !== stickyColumn?.name &&
!visibleColumns.some(col => col.name === sortColumn)
) {
if (stickyColumn) {
sort.update(state => ({
...state,
column: stickyColumn.name,
}))
} else {
sort.update(state => ({
...state,
column: visibleColumns[0]?.name,
}))
}
}
}
</script>
<div bind:this={anchor}>
<ActionButton
icon="SortOrderDown"
quiet
size="M"
on:click={() => (open = !open)}
selected={open || $sort.column}
disabled={!columnOptions.length}
>
Sort
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
<Select
placeholder={null}
value={$sort.column}
options={columnOptions}
autoWidth
on:change={updateSortColumn}
label="Column"
/>
<Select
placeholder={null}
value={$sort.order}
options={orderOptions}
autoWidth
on:change={updateSortOrder}
label="Order"
/>
</div>
</Popover>
<style>
.content {
padding: 6px 12px 12px 12px;
display: flex;
align-items: center;
gap: 8px;
}
.content :global(.spectrum-Picker) {
width: 140px;
}
</style>

View file

@ -0,0 +1 @@
export { default as Grid } from "./layout/Grid.svelte"

View file

@ -0,0 +1,24 @@
<script>
export let user
</script>
<div class="user" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
<style>
div {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
div:hover {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,250 @@
<script>
import { setContext } from "svelte"
import { writable } from "svelte/store"
import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui"
import { createEventManagers } from "../lib/events"
import { createAPIClient } from "../../../api"
import { attachStores } from "../stores"
import DeleteButton from "../controls/DeleteButton.svelte"
import BetaButton from "../controls/BetaButton.svelte"
import GridBody from "./GridBody.svelte"
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
import ReorderOverlay from "../overlays/ReorderOverlay.svelte"
import HeaderRow from "./HeaderRow.svelte"
import ScrollOverlay from "../overlays/ScrollOverlay.svelte"
import MenuOverlay from "../overlays/MenuOverlay.svelte"
import StickyColumn from "./StickyColumn.svelte"
import UserAvatars from "./UserAvatars.svelte"
import KeyboardManager from "../overlays/KeyboardManager.svelte"
import SortButton from "../controls/SortButton.svelte"
import AddColumnButton from "../controls/AddColumnButton.svelte"
import HideColumnsButton from "../controls/HideColumnsButton.svelte"
import AddRowButton from "../controls/AddRowButton.svelte"
import RowHeightButton from "../controls/RowHeightButton.svelte"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
GutterWidth,
DefaultRowHeight,
} from "../lib/constants"
export let API = null
export let tableId = null
export let schemaOverrides = null
export let allowAddRows = true
export let allowAddColumns = true
export let allowEditColumns = true
export let allowExpandRows = true
export let allowEditRows = true
export let allowDeleteRows = true
// Unique identifier for DOM nodes inside this instance
const rand = Math.random()
// State stores
const tableIdStore = writable(tableId)
const schemaOverridesStore = writable(schemaOverrides)
const config = writable({
allowAddRows,
allowAddColumns,
allowEditColumns,
allowExpandRows,
allowEditRows,
allowDeleteRows,
})
// Build up context
let context = {
API: API || createAPIClient(),
rand,
config,
tableId: tableIdStore,
schemaOverrides: schemaOverridesStore,
}
context = { ...context, ...createEventManagers() }
context = attachStores(context)
// Reference some stores for local use
const {
isResizing,
isReordering,
ui,
loaded,
loading,
rowHeight,
contentLines,
} = context
// Keep stores up to date
$: tableIdStore.set(tableId)
$: schemaOverridesStore.set(schemaOverrides)
$: config.set({
allowAddRows,
allowAddColumns,
allowEditColumns,
allowExpandRows,
allowEditRows,
allowDeleteRows,
})
// Set context for children to consume
setContext("grid", context)
// Expose ability to retrieve context externally for external control
export const getContext = () => context
// Initialise websocket for multi-user
// onMount(() => createWebsocket(context))
</script>
<div
class="grid"
id="grid-{rand}"
class:is-resizing={$isResizing}
class:is-reordering={$isReordering}
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">
<div class="controls-left">
<AddRowButton />
<AddColumnButton />
<slot name="controls" />
<RowHeightButton />
<HideColumnsButton />
<SortButton />
</div>
<div class="controls-right">
<DeleteButton />
<UserAvatars />
</div>
</div>
{#if $loaded}
<div class="grid-data-outer" use:clickOutside={ui.actions.blur}>
<div class="grid-data-inner">
<StickyColumn />
<div class="grid-data-content">
<HeaderRow />
<GridBody />
</div>
<div class="overlays">
<ResizeOverlay />
<ReorderOverlay />
<BetaButton />
<ScrollOverlay />
<MenuOverlay />
</div>
</div>
</div>
{/if}
{#if $loading}
<div in:fade|local={{ duration: 130 }} class="grid-loading">
<ProgressCircle />
</div>
{/if}
<KeyboardManager />
</div>
<style>
.grid {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
position: relative;
overflow: hidden;
background: var(--cell-background);
/* Variables */
--cell-background: var(--spectrum-global-color-gray-50);
--cell-background-hover: var(--spectrum-global-color-gray-100);
--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 :global(*) {
box-sizing: border-box;
}
.grid.is-resizing :global(*) {
cursor: col-resize !important;
}
.grid.is-reordering :global(*) {
cursor: grabbing !important;
}
.grid-data-outer,
.grid-data-inner {
flex: 1 1 auto;
display: flex;
justify-items: flex-start;
align-items: stretch;
overflow: hidden;
}
.grid-data-outer {
height: 0;
flex-direction: column;
}
.grid-data-inner {
flex-direction: row;
position: relative;
}
.grid-data-content {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Controls */
.controls {
height: var(--controls-height);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
padding: var(--cell-padding);
gap: var(--cell-spacing);
background: var(--background);
}
.controls-left,
.controls-right {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--cell-spacing);
}
.controls-right {
gap: 12px;
}
/* Overlays */
.overlays {
z-index: 10;
}
/* Loading */
.grid-loading {
position: absolute;
width: 100%;
height: 100%;
display: grid;
place-items: center;
z-index: 10;
}
.grid-loading:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--background);
opacity: 0.6;
}
</style>

View file

@ -0,0 +1,38 @@
<script>
import { getContext, onMount } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import GridRow from "./GridRow.svelte"
const { bounds, renderedRows, rowVerticalInversionIndex } = getContext("grid")
let body
onMount(() => {
// Observe and record the height of the body
const observer = new ResizeObserver(() => {
bounds.set(body.getBoundingClientRect())
})
observer.observe(body)
return () => {
observer.disconnect()
}
})
</script>
<div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive>
{#each $renderedRows as row, idx}
<GridRow {row} {idx} invertY={idx >= $rowVerticalInversionIndex} />
{/each}
</GridScrollWrapper>
</div>
<style>
.grid-body {
display: block;
position: relative;
cursor: default;
overflow: hidden;
flex: 1 1 auto;
}
</style>

View file

@ -0,0 +1,57 @@
<script>
import { getContext } from "svelte"
import DataCell from "../cells/DataCell.svelte"
export let row
export let idx
export let invertY = false
const {
focusedCellId,
reorder,
selectedRows,
renderedColumns,
hoveredRowId,
selectedCellMap,
focusedRow,
columnHorizontalInversionIndex,
} = getContext("grid")
$: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id
$: rowFocused = $focusedRow?._id === row._id
$: reorderSource = $reorder.sourceColumn
</script>
<div
class="row"
on:focus
on:mouseenter={() => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)}
>
{#each $renderedColumns as column, columnIdx (column.name)}
{@const cellId = `${row._id}-${column.name}`}
<DataCell
{cellId}
{column}
{row}
{invertY}
{rowFocused}
invertX={columnIdx >= $columnHorizontalInversionIndex}
highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected}
rowIdx={idx}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
width={column.width}
/>
{/each}
</div>
<style>
.row {
width: 0;
display: flex;
height: var(--row-height);
}
</style>

View file

@ -0,0 +1,84 @@
<script>
import { getContext } from "svelte"
import { domDebounce } from "../../../utils/utils"
const {
rowHeight,
scroll,
focusedCellId,
renderedRows,
maxScrollTop,
maxScrollLeft,
bounds,
hoveredRowId,
hiddenColumnsWidth,
} = getContext("grid")
export let scrollVertically = false
export let scrollHorizontally = false
export let wheelInteractive = false
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
const generateStyle = (scroll, rowHeight, hiddenWidths) => {
const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}
// Handles a wheel even and updates the scroll offsets
const handleWheel = e => {
e.preventDefault()
const modifier = e.ctrlKey || e.metaKey
let x = modifier ? e.deltaY : e.deltaX
let y = modifier ? e.deltaX : e.deltaY
debouncedHandleWheel(x, y, e.clientY)
}
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
const { top, left } = $scroll
// Calculate new scroll top
let newScrollTop = top + deltaY
newScrollTop = Math.max(0, Math.min(newScrollTop, $maxScrollTop))
// Calculate new scroll left
let newScrollLeft = left + deltaX
newScrollLeft = Math.max(0, Math.min(newScrollLeft, $maxScrollLeft))
// Update state
scroll.set({
left: scrollHorizontally ? newScrollLeft : left,
top: scrollVertically ? newScrollTop : top,
})
// Hover row under cursor
const y = clientY - $bounds.top + (newScrollTop % $rowHeight)
const hoveredRow = $renderedRows[Math.floor(y / $rowHeight)]
hoveredRowId.set(hoveredRow?._id)
})
</script>
<div
class="outer"
on:wheel={wheelInteractive ? handleWheel : null}
on:click|self={() => ($focusedCellId = null)}
>
<div {style} class="inner">
<slot />
</div>
</div>
<style>
.outer {
min-width: 100%;
min-height: 100%;
height: 100%;
width: 100%;
max-height: 100%;
max-width: 100%;
overflow: hidden;
}
.inner {
position: absolute;
}
</style>

View file

@ -0,0 +1,30 @@
<script>
import { getContext } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import HeaderCell from "../cells/HeaderCell.svelte"
const { renderedColumns } = getContext("grid")
</script>
<div class="header">
<GridScrollWrapper scrollHorizontally>
<div class="row">
{#each $renderedColumns as column, idx}
<HeaderCell {column} {idx} />
{/each}
</div>
</GridScrollWrapper>
</div>
<style>
.header {
background: var(--background);
border-bottom: var(--cell-border);
position: relative;
height: var(--default-row-height);
z-index: 1;
}
.row {
display: flex;
}
</style>

View file

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

View file

@ -0,0 +1,9 @@
<script>
import { getContext } from "svelte"
const { rows } = getContext("grid")
</script>
<div>
{$rows.length} row{$rows.length === 1 ? "" : "s"}
</div>

View file

@ -0,0 +1,245 @@
<script>
import { getContext } from "svelte"
import { Checkbox, Icon } from "@budibase/bbui"
import GridCell from "../cells/GridCell.svelte"
import DataCell from "../cells/DataCell.svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte"
import HeaderCell from "../cells/HeaderCell.svelte"
import { GutterWidth } from "../lib/constants"
const {
rows,
selectedRows,
stickyColumn,
renderedRows,
focusedCellId,
hoveredRowId,
config,
selectedCellMap,
focusedRow,
dispatch,
scrollLeft,
} = getContext("grid")
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: width = GutterWidth + ($stickyColumn?.width || 0)
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
if (allSelected) {
$selectedRows = {}
} else {
let allRows = {}
$rows.forEach(row => {
allRows[row._id] = true
})
$selectedRows = allRows
}
}
const selectRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
</script>
<div
class="sticky-column"
style="flex: 0 0 {width}px"
class:scrolled={$scrollLeft > 0}
>
<div class="header row">
<GridCell width={GutterWidth} defaultHeight center>
<div class="gutter">
<div class="checkbox visible">
{#if $config.allowDeleteRows}
<div on:click={selectAll}>
<Checkbox
value={rowCount && selectedRowCount === rowCount}
disabled={!$renderedRows.length}
/>
</div>
{/if}
</div>
{#if $config.allowExpandRows}
<div class="expand">
<Icon name="Maximize" size="S" />
</div>
{/if}
</div>
</GridCell>
{#if $stickyColumn}
<HeaderCell column={$stickyColumn} orderable={false} idx="sticky" />
{/if}
</div>
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<GridScrollWrapper scrollVertically wheelInteractive>
{#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = `${row._id}-${$stickyColumn?.name}`}
<div
class="row"
on:mouseenter={() => ($hoveredRowId = row._id)}
on:mouseleave={() => ($hoveredRowId = null)}
>
<GridCell
width={GutterWidth}
highlighted={rowFocused || rowHovered}
selected={rowSelected}
>
<div class="gutter">
<div
on:click={() => selectRow(row._id)}
class="checkbox"
class:visible={$config.allowDeleteRows &&
(rowSelected || rowHovered || rowFocused)}
>
<Checkbox value={rowSelected} />
</div>
<div
class="number"
class:visible={!$config.allowDeleteRows ||
!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>
{#if $config.allowExpandRows}
<div class="expand" class:visible={rowFocused || rowHovered}>
<Icon
name="Maximize"
hoverable
size="S"
on:click={() => {
dispatch("edit-row", row)
}}
/>
</div>
{/if}
</div>
</GridCell>
{#if $stickyColumn}
<DataCell
{row}
{cellId}
{rowFocused}
selected={rowSelected}
highlighted={rowHovered || rowFocused}
rowIdx={idx}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
width={$stickyColumn.width}
column={$stickyColumn}
/>
{/if}
</div>
{/each}
</GridScrollWrapper>
</div>
</div>
<style>
.sticky-column {
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
}
/* Add right border */
.sticky-column:before {
position: absolute;
content: "";
width: 0;
height: 100%;
left: calc(100% - 1px);
top: 0;
border-left: var(--cell-border);
}
/* Add shadow when scrolled */
.sticky-column.scrolled:after {
position: absolute;
content: "";
width: 8px;
height: 100%;
left: 100%;
top: 0;
opacity: 1;
background: linear-gradient(to right, var(--drop-shadow), transparent);
z-index: -1;
}
/* Don't show borders between cells in the sticky column */
.sticky-column :global(.cell:not(:last-child)) {
border-right: none;
}
.header {
z-index: 1;
}
.header :global(.cell) {
background: var(--spectrum-global-color-gray-100);
}
.row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
.content {
position: relative;
flex: 1 1 auto;
}
/* Styles for gutter */
.gutter {
flex: 1 1 auto;
display: grid;
align-items: center;
padding: var(--cell-padding);
grid-template-columns: 1fr auto;
gap: var(--cell-spacing);
}
.checkbox,
.number {
display: none;
flex-direction: row;
justify-content: center;
align-items: center;
}
.checkbox :global(.spectrum-Checkbox) {
min-height: 0;
height: 20px;
}
.checkbox :global(.spectrum-Checkbox-box) {
margin: 3px 0 0 0;
}
.number {
color: var(--spectrum-global-color-gray-500);
}
.checkbox.visible,
.number.visible {
display: flex;
}
.expand {
opacity: 0;
pointer-events: none;
}
.expand.visible {
opacity: 1;
pointer-events: all;
}
</style>

View file

@ -0,0 +1,20 @@
<script>
import { getContext } from "svelte"
import Avatar from "./Avatar.svelte"
const { users } = getContext("grid")
</script>
<div class="users">
{#each $users as user}
<Avatar {user} />
{/each}
</div>
<style>
.users {
display: flex;
flex-direction: row;
gap: 8px;
}
</style>

View file

@ -0,0 +1,11 @@
export const Padding = 276
export const MaxCellRenderHeight = 252
export const MaxCellRenderWidthOverflow = 200
export const ScrollBarSize = 8
export const GutterWidth = 72
export const DefaultColumnWidth = 200
export const MinColumnWidth = 100
export const SmallRowHeight = 36
export const MediumRowHeight = 64
export const LargeRowHeight = 92
export const DefaultRowHeight = SmallRowHeight

View file

@ -0,0 +1,29 @@
import { createEventDispatcher } from "svelte"
export const createEventManagers = () => {
const svelteDispatch = createEventDispatcher()
let subscribers = {}
// Dispatches an event, notifying subscribers and also emitting a normal
// svelte event
const dispatch = (event, payload) => {
svelteDispatch(event, payload)
const subs = subscribers[event] || []
for (let i = 0; i < subs.length; i++) {
subs[i](payload)
}
}
// Subscribes to events
const subscribe = (event, callback) => {
const subs = subscribers[event] || []
subscribers[event] = [...subs, callback]
// Return unsubscribe function
return () => {
subscribers[event] = subscribers[event].filter(cb => cb !== callback)
}
}
return { dispatch, subscribe }
}

View file

@ -0,0 +1,29 @@
import OptionsCell from "../cells/OptionsCell.svelte"
import DateCell from "../cells/DateCell.svelte"
import MultiSelectCell from "../cells/MultiSelectCell.svelte"
import NumberCell from "../cells/NumberCell.svelte"
import RelationshipCell from "../cells/RelationshipCell.svelte"
import TextCell from "../cells/TextCell.svelte"
import LongFormCell from "../cells/LongFormCell.svelte"
import BooleanCell from "../cells/BooleanCell.svelte"
import FormulaCell from "../cells/FormulaCell.svelte"
import JSONCell from "../cells/JSONCell.svelte"
import AttachmentCell from "../cells/AttachmentCell.svelte"
const TypeComponentMap = {
text: TextCell,
options: OptionsCell,
datetime: DateCell,
barcodeqr: TextCell,
longform: LongFormCell,
array: MultiSelectCell,
number: NumberCell,
boolean: BooleanCell,
attachment: AttachmentCell,
link: RelationshipCell,
formula: FormulaCell,
json: JSONCell,
}
export const getCellRenderer = column => {
return TypeComponentMap[column?.schema?.type] || TextCell
}

View file

@ -0,0 +1,26 @@
export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) {
return null
}
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})`
}
const TypeIconMap = {
text: "Text",
options: "Dropdown",
datetime: "Date",
barcodeqr: "Camera",
longform: "TextAlignLeft",
array: "Dropdown",
number: "123",
boolean: "Boolean",
attachment: "AppleFiles",
link: "Link",
formula: "Calculator",
json: "Brackets",
}
export const getColumnIcon = column => {
const type = column.schema.type
return TypeIconMap[type] || "Text"
}

View file

@ -0,0 +1,63 @@
import { get } from "svelte/store"
import { io } from "socket.io-client"
export const createWebsocket = context => {
const { rows, tableId, users, userId, focusedCellId } = context
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
const socket = io(`${proto}//${host}:${port}`, {
path: "/socket/grid",
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
const connectToTable = tableId => {
if (!socket.connected) {
return
}
// Identify which table we are editing
socket.emit("select-table", tableId, response => {
// handle initial connection info
users.set(response.users)
userId.set(response.id)
})
}
// Event handlers
socket.on("connect", () => {
connectToTable(get(tableId))
})
socket.on("row-update", data => {
if (data.id) {
rows.actions.refreshRow(data.id)
}
})
socket.on("user-update", user => {
users.actions.updateUser(user)
})
socket.on("user-disconnect", user => {
users.actions.removeUser(user)
})
socket.on("connect_error", err => {
console.log("Failed to connect to grid websocket:", err.message)
})
// Change websocket connection when table changes
tableId.subscribe(connectToTable)
// Notify selected cell changes
focusedCellId.subscribe($focusedCellId => {
socket.emit("select-cell", $focusedCellId)
})
return () => socket?.disconnect()
}

View file

@ -0,0 +1,180 @@
<script>
import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils"
const {
enrichedRows,
focusedCellId,
visibleColumns,
focusedRow,
stickyColumn,
focusedCellAPI,
clipboard,
} = getContext("grid")
// Global key listener which intercepts all key events
const handleKeyDown = e => {
// If nothing selected avoid processing further key presses
if (!$focusedCellId) {
if (e.key === "Tab") {
e.preventDefault()
focusFirstCell()
}
return
}
// Always intercept certain key presses
const api = $focusedCellAPI
if (e.key === "Escape") {
api?.blur?.()
} else if (e.key === "Tab") {
api?.blur?.()
changeFocusedColumn(1)
}
// Pass the key event to the selected cell and let it decide whether to
// capture it or not
if (!api?.isReadonly()) {
const handled = api?.onKeyDown?.(e)
if (handled) {
return
}
}
// Avoid processing events sourced from modals
if (e.target?.closest?.(".spectrum-Modal")) {
return
}
e.preventDefault()
// Handle the key ourselves
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "c":
clipboard.actions.copy()
break
case "v":
clipboard.actions.paste()
break
}
} else {
switch (e.key) {
case "ArrowLeft":
changeFocusedColumn(-1)
break
case "ArrowRight":
changeFocusedColumn(1)
break
case "ArrowUp":
changeFocusedRow(-1)
break
case "ArrowDown":
changeFocusedRow(1)
break
case "Delete":
case "Backspace":
deleteSelectedCell()
break
case "Enter":
focusCell()
break
default:
startEnteringValue(e.key, e.which)
}
}
}
// Focuses the first cell in the grid
const focusFirstCell = () => {
const firstRow = $enrichedRows[0]
if (!firstRow) {
return
}
const firstColumn = $stickyColumn || $visibleColumns[0]
if (!firstColumn) {
return
}
focusedCellId.set(`${firstRow._id}-${firstColumn.name}`)
}
// Changes the focused cell by moving it left or right to a different column
const changeFocusedColumn = delta => {
if (!$focusedCellId) {
return
}
const cols = $visibleColumns
const split = $focusedCellId.split("-")
const columnName = split[1]
let newColumnName
if (columnName === $stickyColumn?.name) {
const index = delta - 1
newColumnName = cols[index]?.name
} else {
const index = cols.findIndex(col => col.name === columnName) + delta
if (index === -1) {
newColumnName = $stickyColumn?.name
} else {
newColumnName = cols[index]?.name
}
}
if (newColumnName) {
$focusedCellId = `${split[0]}-${newColumnName}`
}
}
// Changes the focused cell by moving it up or down to a new row
const changeFocusedRow = delta => {
if (!$focusedRow) {
return
}
const newRow = $enrichedRows[$focusedRow.__idx + delta]
if (newRow) {
const split = $focusedCellId.split("-")
$focusedCellId = `${newRow._id}-${split[1]}`
}
}
// Debounce to avoid holding down delete and spamming requests
const deleteSelectedCell = debounce(() => {
if ($focusedCellAPI?.isReadonly()) {
return
}
$focusedCellAPI.setValue(null)
}, 100)
// Focuses the current cell for editing
const focusCell = () => {
if ($focusedCellAPI?.isReadonly()) {
return
}
$focusedCellAPI?.focus?.()
}
// Utils to identify a key code
const keyCodeIsNumber = keyCode => keyCode >= 48 && keyCode <= 57
const keyCodeIsLetter = keyCode => keyCode >= 65 && keyCode <= 90
// Focuses the cell and starts entering a new value
const startEnteringValue = (key, keyCode) => {
if ($focusedCellAPI) {
const type = $focusedCellAPI.getType()
if (type === "number" && keyCodeIsNumber(keyCode)) {
$focusedCellAPI.setValue(parseInt(key))
$focusedCellAPI.focus()
} else if (
["string", "barcodeqr", "longform"].includes(type) &&
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
) {
$focusedCellAPI.setValue(key)
$focusedCellAPI.focus()
}
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
})
</script>

View file

@ -0,0 +1,95 @@
<script>
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui"
import { getContext } from "svelte"
const {
focusedRow,
menu,
rows,
columns,
focusedCellId,
stickyColumn,
config,
copiedCell,
clipboard,
dispatch,
} = getContext("grid")
$: style = makeStyle($menu)
const makeStyle = menu => {
return `left:${menu.left}px; top:${menu.top}px;`
}
const deleteRow = () => {
rows.actions.deleteRows([$focusedRow])
menu.actions.close()
notifications.success("Deleted 1 row")
}
const duplicate = async () => {
menu.actions.close()
const newRow = await rows.actions.duplicateRow($focusedRow)
if (newRow) {
const column = $stickyColumn?.name || $columns[0].name
$focusedCellId = `${newRow._id}-${column}`
}
}
</script>
{#if $menu.visible}
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}>
<Menu>
<MenuItem
icon="Copy"
on:click={clipboard.actions.copy}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={$copiedCell == null}
on:click={clipboard.actions.paste}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Maximize"
disabled={!$config.allowEditRows}
on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
</MenuItem>
<MenuItem
icon="Duplicate"
disabled={!$config.allowAddRows}
on:click={duplicate}
>
Duplicate row
</MenuItem>
<MenuItem
icon="Delete"
disabled={!$config.allowDeleteRows}
on:click={deleteRow}
>
Delete row
</MenuItem>
</Menu>
</div>
{/if}
<style>
.menu {
position: absolute;
background: var(--cell-background);
border: 1px solid var(--spectrum-global-color-gray-300);
width: 180px;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
}
</style>

View file

@ -0,0 +1,63 @@
<script>
import { getContext } from "svelte"
import GridScrollWrapper from "../layout/GridScrollWrapper.svelte"
import { DefaultRowHeight, GutterWidth } from "../lib/constants"
const {
isReordering,
reorder,
visibleColumns,
stickyColumn,
rowHeight,
renderedRows,
scrollLeft,
} = getContext("grid")
$: targetColumn = $reorder.targetColumn
$: minLeft = GutterWidth + ($stickyColumn?.width || 0)
$: left = getLeft(targetColumn, $stickyColumn, $visibleColumns, $scrollLeft)
$: height = $rowHeight * $renderedRows.length + DefaultRowHeight
$: style = `left:${left}px; height:${height}px;`
$: visible = $isReordering && left >= minLeft
const getLeft = (targetColumn, stickyColumn, visibleColumns, scrollLeft) => {
let left = GutterWidth + (stickyColumn?.width || 0) - scrollLeft
// If this is not the sticky column, add additional left space
if (targetColumn !== stickyColumn?.name) {
const column = visibleColumns.find(x => x.name === targetColumn)
if (!column) {
return left
}
left += column.left + column.width
}
return left
}
</script>
{#if visible}
<div class="reorder-wrapper">
<GridScrollWrapper scrollVertically>
<div class="reorder-overlay" {style} />
</GridScrollWrapper>
</div>
{/if}
<style>
.reorder-wrapper {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
pointer-events: none;
}
.reorder-overlay {
position: absolute;
top: 0;
width: 2px;
background: var(--spectrum-global-color-blue-400);
margin-left: -2px;
}
</style>

View file

@ -0,0 +1,70 @@
<script>
import { getContext } from "svelte"
import { GutterWidth } from "../lib/constants"
const {
columns,
resize,
renderedColumns,
stickyColumn,
isReordering,
scrollLeft,
} = getContext("grid")
$: cutoff = $scrollLeft + GutterWidth + ($columns[0]?.width || 0)
$: offset = GutterWidth + ($stickyColumn?.width || 0)
$: activeColumn = $resize.column
const getStyle = (column, offset, scrollLeft) => {
const left = offset + column.left + column.width - scrollLeft
return `left:${left}px;`
}
</script>
{#if !$isReordering}
{#if $stickyColumn}
<div
class="resize-slider"
class:visible={activeColumn === $stickyColumn.name}
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
on:dblclick={() => resize.actions.resetSize($stickyColumn)}
style="left:{GutterWidth + $stickyColumn.width}px;"
>
<div class="resize-indicator" />
</div>
{/if}
{#each $renderedColumns as column}
<div
class="resize-slider"
class:visible={activeColumn === column.name}
on:mousedown={e => resize.actions.startResizing(column, e)}
on:dblclick={() => resize.actions.resetSize(column)}
style={getStyle(column, offset, $scrollLeft)}
>
<div class="resize-indicator" />
</div>
{/each}
{/if}
<style>
.resize-slider {
position: absolute;
top: 0;
height: var(--default-row-height);
opacity: 0;
padding: 0 8px;
transform: translateX(-50%);
user-select: none;
}
.resize-slider:hover,
.resize-slider.visible {
cursor: col-resize;
opacity: 1;
}
.resize-indicator {
margin-left: -1px;
width: 2px;
height: 100%;
background: var(--spectrum-global-color-blue-400);
}
</style>

View file

@ -0,0 +1,121 @@
<script>
import { getContext } from "svelte"
import { domDebounce } from "../../../utils/utils"
import { DefaultRowHeight, ScrollBarSize } from "../lib/constants"
const {
scroll,
contentHeight,
maxScrollTop,
contentWidth,
maxScrollLeft,
screenWidth,
showHScrollbar,
showVScrollbar,
scrollLeft,
scrollTop,
height,
} = getContext("grid")
// State for dragging bars
let initialMouse
let initialScroll
// 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 * ScrollBarSize
$: barHeight = Math.max(50, ($height / $contentHeight) * renderHeight)
$: availHeight = renderHeight - barHeight
$: barTop =
ScrollBarSize +
DefaultRowHeight +
availHeight * ($scrollTop / $maxScrollTop)
// Calculate H scrollbar size and offset
$: renderWidth = $screenWidth - 2 * ScrollBarSize
$: barWidth = Math.max(50, ($screenWidth / $contentWidth) * renderWidth)
$: availWidth = renderWidth - barWidth
$: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft)
// V scrollbar drag handlers
const startVDragging = e => {
e.preventDefault()
initialMouse = e.clientY
initialScroll = $scrollTop
document.addEventListener("mousemove", moveVDragging)
document.addEventListener("mouseup", stopVDragging)
}
const moveVDragging = domDebounce(e => {
const delta = e.clientY - initialMouse
const weight = delta / availHeight
const newScrollTop = initialScroll + weight * $maxScrollTop
scroll.update(state => ({
...state,
top: Math.max(0, Math.min(newScrollTop, $maxScrollTop)),
}))
})
const stopVDragging = () => {
document.removeEventListener("mousemove", moveVDragging)
document.removeEventListener("mouseup", stopVDragging)
}
// H scrollbar drag handlers
const startHDragging = e => {
e.preventDefault()
initialMouse = e.clientX
initialScroll = $scrollLeft
document.addEventListener("mousemove", moveHDragging)
document.addEventListener("mouseup", stopHDragging)
}
const moveHDragging = domDebounce(e => {
const delta = e.clientX - initialMouse
const weight = delta / availWidth
const newScrollLeft = initialScroll + weight * $maxScrollLeft
scroll.update(state => ({
...state,
left: Math.max(0, Math.min(newScrollLeft, $maxScrollLeft)),
}))
})
const stopHDragging = () => {
document.removeEventListener("mousemove", moveHDragging)
document.removeEventListener("mouseup", stopHDragging)
}
</script>
{#if $showVScrollbar}
<div
class="v-scrollbar"
style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;"
on:mousedown={startVDragging}
/>
{/if}
{#if $showHScrollbar}
<div
class="h-scrollbar"
style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;"
on:mousedown={startHDragging}
/>
{/if}
<style>
div {
position: absolute;
background: var(--spectrum-global-color-gray-500);
opacity: 0.7;
border-radius: 4px;
transition: opacity 130ms ease-out;
}
div:hover {
opacity: 1;
}
.v-scrollbar {
width: var(--size);
right: var(--size);
}
.h-scrollbar {
height: var(--size);
bottom: var(--size);
}
</style>

View file

@ -0,0 +1,16 @@
import { derived, writable } from "svelte/store"
export const createStores = () => {
const bounds = writable({
left: 0,
top: 0,
width: 0,
height: 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)
return { bounds, height, width }
}

View file

@ -0,0 +1,34 @@
import { writable, get } from "svelte/store"
export const createStores = () => {
const copiedCell = writable(null)
return {
copiedCell,
}
}
export const deriveStores = context => {
const { copiedCell, focusedCellAPI } = context
const copy = () => {
copiedCell.set(get(focusedCellAPI)?.getValue())
}
const paste = () => {
const $copiedCell = get(copiedCell)
const $focusedCellAPI = get(focusedCellAPI)
if ($copiedCell != null && $focusedCellAPI) {
$focusedCellAPI.setValue($copiedCell)
}
}
return {
clipboard: {
actions: {
copy,
paste,
},
},
}
}

View file

@ -0,0 +1,206 @@
import { derived, get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { GutterWidth, DefaultColumnWidth } from "../lib/constants"
export const createStores = () => {
const columns = writable([])
const stickyColumn = writable(null)
// Derive an enriched version of columns with left offsets and indexes
// automatically calculated
const enrichedColumns = derived(
columns,
$columns => {
let offset = 0
return $columns.map(column => {
const enriched = {
...column,
left: offset,
}
if (column.visible) {
offset += column.width
}
return enriched
})
},
[]
)
// Derived list of columns which have not been explicitly hidden
const visibleColumns = derived(
enrichedColumns,
$columns => {
return $columns.filter(col => col.visible)
},
[]
)
return {
columns: {
...columns,
subscribe: enrichedColumns.subscribe,
},
stickyColumn,
visibleColumns,
}
}
export const deriveStores = context => {
const { table, columns, stickyColumn, API, dispatch } = context
// Updates the tables primary display column
const changePrimaryDisplay = async column => {
return await saveTable({
...get(table),
primaryDisplay: column,
})
}
// Persists column changes by saving metadata against table schema
const saveChanges = async () => {
const $columns = get(columns)
const $table = get(table)
const $stickyColumn = get(stickyColumn)
const newSchema = cloneDeep($table.schema)
// Build new updated table schema
Object.keys(newSchema).forEach(column => {
// Respect order specified by columns
const index = $columns.findIndex(x => x.name === column)
if (index !== -1) {
newSchema[column].order = index
} else {
delete newSchema[column].order
}
// Copy over metadata
if (column === $stickyColumn?.name) {
newSchema[column].visible = true
newSchema[column].width = $stickyColumn.width || DefaultColumnWidth
} else {
newSchema[column].visible = $columns[index]?.visible ?? true
newSchema[column].width = $columns[index]?.width || DefaultColumnWidth
}
})
await saveTable({ ...$table, schema: newSchema })
}
const saveTable = async newTable => {
// Update local state
table.set(newTable)
// Broadcast event so that we can keep sync with external state
// (e.g. data section which maintains a list of table definitions)
dispatch("updatetable", newTable)
// Update server
await API.saveTable(newTable)
}
return {
columns: {
...columns,
actions: {
saveChanges,
saveTable,
changePrimaryDisplay,
},
},
}
}
export const initialise = context => {
const { table, columns, stickyColumn, schemaOverrides } = context
const schema = derived(
[table, schemaOverrides],
([$table, $schemaOverrides]) => {
let newSchema = $table?.schema
if (!newSchema) {
return null
}
Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) {
newSchema[field] = {
...newSchema[field],
...$schemaOverrides[field],
}
}
})
return newSchema
}
)
// Merge new schema fields with existing schema in order to preserve widths
schema.subscribe($schema => {
if (!$schema) {
columns.set([])
stickyColumn.set(null)
return
}
const $table = get(table)
// Find primary display
let primaryDisplay
if ($table.primaryDisplay && $schema[$table.primaryDisplay]) {
primaryDisplay = $table.primaryDisplay
}
// Get field list
let fields = []
Object.keys($schema).forEach(field => {
if (field !== primaryDisplay) {
fields.push(field)
}
})
// Update columns, removing extraneous columns and adding missing ones
columns.set(
fields
.map(field => ({
name: field,
label: $schema[field].name || field,
schema: $schema[field],
width: $schema[field].width || DefaultColumnWidth,
visible: $schema[field].visible ?? true,
order: $schema[field].order,
}))
.sort((a, b) => {
// Sort by order first
const orderA = a.order
const orderB = b.order
if (orderA != null && orderB != null) {
return orderA < orderB ? -1 : 1
} else if (orderA != null) {
return -1
} else if (orderB != null) {
return 1
}
// Then sort by auto columns
const autoColA = a.schema?.autocolumn
const autoColB = b.schema?.autocolumn
if (autoColA === autoColB) {
return 0
}
return autoColA ? 1 : -1
})
)
// Update sticky column
if (!primaryDisplay) {
stickyColumn.set(null)
return
}
stickyColumn.set({
name: primaryDisplay,
label: $schema[primaryDisplay].name || primaryDisplay,
schema: $schema[primaryDisplay],
width: $schema[primaryDisplay].width || DefaultColumnWidth,
visible: true,
order: 0,
left: GutterWidth,
})
})
}

View file

@ -0,0 +1,48 @@
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"
import * as Clipboard from "./clipboard"
const DependencyOrderedStores = [
Bounds,
Scroll,
Rows,
Columns,
UI,
Validation,
Resize,
Viewport,
Reorder,
Users,
Menu,
Pagination,
Clipboard,
]
export const attachStores = context => {
// Atomic store creation
for (let store of DependencyOrderedStores) {
context = { ...context, ...store.createStores?.(context) }
}
// Derived store creation
for (let store of DependencyOrderedStores) {
context = { ...context, ...store.deriveStores?.(context) }
}
// Initialise any store logic
for (let store of DependencyOrderedStores) {
store.initialise?.(context)
}
return context
}

View file

@ -0,0 +1,49 @@
import { writable, get } from "svelte/store"
import { GutterWidth } from "../lib/constants"
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)
const $stickyColumn = get(stickyColumn)
const $rowHeight = get(rowHeight)
e.preventDefault()
focusedCellId.set(cellId)
menu.set({
left:
e.clientX - $bounds.left + GutterWidth + ($stickyColumn?.width || 0),
top: e.clientY - $bounds.top + $rowHeight,
visible: true,
})
}
const close = () => {
menu.update(state => ({
...state,
visible: false,
}))
}
return {
menu: {
...menu,
actions: {
open,
close,
},
},
}
}

View file

@ -0,0 +1,24 @@
import { derived } from "svelte/store"
export const initialise = context => {
const { scrolledRowCount, rows, visualRowCapacity } = context
// Derive how many rows we have in total
const rowCount = derived(rows, $rows => $rows.length, 0)
// Derive how many rows we have available to scroll
const remainingRows = derived(
[scrolledRowCount, rowCount, visualRowCapacity],
([$scrolledRowCount, $rowCount, $visualRowCapacity]) => {
return Math.max(0, $rowCount - $scrolledRowCount - $visualRowCapacity)
},
100
)
// Fetch next page when fewer than 25 remaining rows to scroll
remainingRows.subscribe(remaining => {
if (remaining < 25) {
rows.actions.loadNextPage()
}
})
}

View file

@ -0,0 +1,155 @@
import { get, writable, derived } from "svelte/store"
const reorderInitialState = {
sourceColumn: null,
targetColumn: null,
breakpoints: [],
initialMouseX: null,
scrollLeft: 0,
gridLeft: 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, visibleColumns, scroll, bounds, stickyColumn, ui } =
context
// Callback when dragging on a colum header and starting reordering
const startReordering = (column, e) => {
const $visibleColumns = get(visibleColumns)
const $bounds = get(bounds)
const $scroll = get(scroll)
const $stickyColumn = get(stickyColumn)
ui.actions.blur()
// Generate new breakpoints for the current columns
let breakpoints = $visibleColumns.map(col => ({
x: col.left + col.width,
column: col.name,
}))
if ($stickyColumn) {
breakpoints.unshift({
x: 0,
column: $stickyColumn.name,
})
}
// Update state
reorder.set({
sourceColumn: column,
targetColumn: null,
breakpoints,
initialMouseX: e.clientX,
scrollLeft: $scroll.left,
gridLeft: $bounds.left,
})
// Add listeners to handle mouse movement
document.addEventListener("mousemove", onReorderMouseMove)
document.addEventListener("mouseup", stopReordering)
// Trigger a move event immediately so ensure a candidate column is chosen
onReorderMouseMove(e)
}
// Callback when moving the mouse when reordering columns
const onReorderMouseMove = e => {
const $reorder = get(reorder)
// Compute the closest breakpoint to the current position
let targetColumn
let minDistance = Number.MAX_SAFE_INTEGER
const mouseX = e.clientX - $reorder.gridLeft + $reorder.scrollLeft
$reorder.breakpoints.forEach(point => {
const distance = Math.abs(point.x - mouseX)
if (distance < minDistance) {
minDistance = distance
targetColumn = point.column
}
})
if (targetColumn !== $reorder.targetColumn) {
reorder.update(state => ({
...state,
targetColumn,
}))
}
}
// Callback when stopping reordering columns
const stopReordering = async () => {
// Swap position of columns
let { sourceColumn, targetColumn } = get(reorder)
moveColumn(sourceColumn, targetColumn)
// Reset state
reorder.set(reorderInitialState)
// Remove event handlers
document.removeEventListener("mousemove", onReorderMouseMove)
document.removeEventListener("mouseup", stopReordering)
// Save column changes
await columns.actions.saveChanges()
}
// Moves a column after another columns.
// An undefined target column will move the source to index 0.
const moveColumn = (sourceColumn, targetColumn) => {
let $columns = get(columns)
let sourceIdx = $columns.findIndex(x => x.name === sourceColumn)
let targetIdx = $columns.findIndex(x => x.name === targetColumn)
targetIdx++
columns.update(state => {
const removed = state.splice(sourceIdx, 1)
if (--targetIdx < sourceIdx) {
targetIdx++
}
state.splice(targetIdx, 0, removed[0])
return state.slice()
})
}
// Moves a column one place left (as appears visually)
const moveColumnLeft = async column => {
const $visibleColumns = get(visibleColumns)
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
moveColumn(column, $visibleColumns[sourceIdx - 2]?.name)
await columns.actions.saveChanges()
}
// Moves a column one place right (as appears visually)
const moveColumnRight = async column => {
const $visibleColumns = get(visibleColumns)
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
if (sourceIdx === $visibleColumns.length - 1) {
return
}
moveColumn(column, $visibleColumns[sourceIdx + 1]?.name)
await columns.actions.saveChanges()
}
return {
reorder: {
...reorder,
actions: {
startReordering,
stopReordering,
moveColumnLeft,
moveColumnRight,
},
},
}
}

Some files were not shown because too many files have changed in this diff Show more