1
0
Fork 0
mirror of synced 2024-09-20 11:27:56 +12:00

Merge branch 'master' into dean-fixes

This commit is contained in:
Andrew Kingston 2024-07-22 10:17:50 +01:00 committed by GitHub
commit 27e4b4e3b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 3749 additions and 1796 deletions

View file

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.29.21",
"version": "2.29.22",
"npmClient": "yarn",
"packages": [
"packages/*",

View file

@ -42,27 +42,28 @@ const envLimit = environment.SQL_MAX_ROWS
: null
const BASE_LIMIT = envLimit || 5000
function likeKey(client: string | string[], key: string): string {
let start: string, end: string
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
// and "foo" for Postgres.
function quote(client: SqlClient, str: string): string {
switch (client) {
case SqlClient.MY_SQL:
start = end = "`"
break
case SqlClient.SQL_LITE:
case SqlClient.ORACLE:
case SqlClient.POSTGRES:
start = end = '"'
break
return `"${str}"`
case SqlClient.MS_SQL:
start = "["
end = "]"
break
default:
throw new Error("Unknown client generating like key")
return `[${str}]`
case SqlClient.MY_SQL:
return `\`${str}\``
}
const parts = key.split(".")
key = parts.map(part => `${start}${part}${end}`).join(".")
}
// Takes a string like a.b.c and returns a quoted identifier like [a].[b].[c]
// for SQL Server and `a`.`b`.`c` for MySQL.
function quotedIdentifier(client: SqlClient, key: string): string {
return key
.split(".")
.map(part => quote(client, part))
.join(".")
}
function parse(input: any) {
@ -113,34 +114,81 @@ function generateSelectStatement(
knex: Knex
): (string | Knex.Raw)[] | "*" {
const { resource, meta } = json
const client = knex.client.config.client as SqlClient
if (!resource || !resource.fields || resource.fields.length === 0) {
return "*"
}
const schema = meta?.table?.schema
const schema = meta.table.schema
return resource.fields.map(field => {
const fieldNames = field.split(/\./g)
const tableName = fieldNames[0]
const columnName = fieldNames[1]
const columnSchema = schema?.[columnName]
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
const externalType = schema[columnName].externalType
if (externalType?.includes("money")) {
return knex.raw(
`"${tableName}"."${columnName}"::money::numeric as "${field}"`
)
}
const parts = field.split(/\./g)
let table: string | undefined = undefined
let column: string | undefined = undefined
// Just a column name, e.g.: "column"
if (parts.length === 1) {
column = parts[0]
}
// A table name and a column name, e.g.: "table.column"
if (parts.length === 2) {
table = parts[0]
column = parts[1]
}
// A link doc, e.g.: "table.doc1.fieldName"
if (parts.length > 2) {
table = parts[0]
column = parts.slice(1).join(".")
}
if (!column) {
throw new Error(`Invalid field name: ${field}`)
}
const columnSchema = schema[column]
if (
knex.client.config.client === SqlClient.MS_SQL &&
client === SqlClient.POSTGRES &&
columnSchema?.externalType?.includes("money")
) {
return knex.raw(
`${quotedIdentifier(
client,
[table, column].join(".")
)}::money::numeric as ${quote(client, field)}`
)
}
if (
client === SqlClient.MS_SQL &&
columnSchema?.type === FieldType.DATETIME &&
columnSchema.timeOnly
) {
// Time gets returned as timestamp from mssql, not matching the expected HH:mm format
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
}
return `${field} as ${field}`
// There's at least two edge cases being handled in the expression below.
// 1. The column name could start/end with a space, and in that case we
// want to preseve that space.
// 2. Almost all column names are specified in the form table.column, except
// in the case of relationships, where it's table.doc1.column. In that
// case, we want to split it into `table`.`doc1.column` for reasons that
// aren't actually clear to me, but `table`.`doc1` breaks things with the
// sample data tests.
if (table) {
return knex.raw(
`${quote(client, table)}.${quote(client, column)} as ${quote(
client,
field
)}`
)
} else {
return knex.raw(`${quote(client, field)} as ${quote(client, field)}`)
}
})
}
@ -173,9 +221,9 @@ function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
}
class InternalBuilder {
private readonly client: string
private readonly client: SqlClient
constructor(client: string) {
constructor(client: SqlClient) {
this.client = client
}
@ -250,9 +298,10 @@ class InternalBuilder {
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
`%${value.toLowerCase()}%`,
])
query = query[rawFnc](
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
[`%${value.toLowerCase()}%`]
)
}
}
@ -302,7 +351,10 @@ class InternalBuilder {
}
statement +=
(statement ? andOr : "") +
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
`COALESCE(LOWER(${quotedIdentifier(
this.client,
key
)}), '') LIKE ?`
}
if (statement === "") {
@ -336,9 +388,10 @@ class InternalBuilder {
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
`${value.toLowerCase()}%`,
])
query = query[rawFnc](
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
[`${value.toLowerCase()}%`]
)
}
})
}
@ -376,12 +429,15 @@ class InternalBuilder {
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`,
`CASE WHEN ${quotedIdentifier(
this.client,
key
)} = ? THEN 1 ELSE 0 END = 1`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
`COALESCE(${quotedIdentifier(this.client, key)} = ?, FALSE)`,
[value]
)
}
@ -392,12 +448,15 @@ class InternalBuilder {
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`,
`CASE WHEN ${quotedIdentifier(
this.client,
key
)} = ? THEN 1 ELSE 0 END = 0`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
`COALESCE(${quotedIdentifier(this.client, key)} != ?, TRUE)`,
[value]
)
}
@ -769,7 +828,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
private readonly limit: number
// pass through client to get flavour of SQL
constructor(client: string, limit: number = BASE_LIMIT) {
constructor(client: SqlClient, limit: number = BASE_LIMIT) {
super(client)
this.limit = limit
}

View file

@ -195,14 +195,14 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {
}
class SqlTableQueryBuilder {
private readonly sqlClient: string
private readonly sqlClient: SqlClient
// pass through client to get flavour of SQL
constructor(client: string) {
constructor(client: SqlClient) {
this.sqlClient = client
}
getSqlClient(): string {
getSqlClient(): SqlClient {
return this.sqlClient
}

View file

@ -8,6 +8,7 @@ const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) {
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
@ -147,6 +148,10 @@ export function isValidFilter(value: any) {
return value != null && value !== ""
}
export function isValidTime(value: string) {
return TIME_REGEX.test(value)
}
export function sqlLog(client: string, query: string, values?: any[]) {
if (!environment.SQL_LOGGING_ENABLE) {
return

View file

@ -29,6 +29,7 @@
>
<div class="icon" class:newStyles>
<svg
on:contextmenu
on:click
class:hoverable
class:disabled

View file

@ -1,33 +1,22 @@
<script>
import "@spectrum-css/progressbar/dist/index-vars.css"
import { tweened } from "svelte/motion"
import { cubicOut } from "svelte/easing"
export let value = false
export let easing = cubicOut
export let duration = 1000
export let width = false
export let sideLabel = false
export let hidePercentage = true
export let color // red, green, default = blue
export let size = "M"
const progress = tweened(0, {
duration: duration,
easing: easing,
})
$: if (value || value === 0) $progress = value
</script>
<div
class:spectrum-ProgressBar--indeterminate={!value && value !== 0}
class:spectrum-ProgressBar--sideLabel={sideLabel}
class="spectrum-ProgressBar spectrum-ProgressBar--size{size}"
value={$progress}
{value}
role="progressbar"
aria-valuenow={$progress}
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax="100"
style={width ? `width: ${width};` : ""}
@ -43,7 +32,7 @@
<div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
>
{Math.round($progress)}%
{Math.round(value)}%
</div>
{/if}
<div class="spectrum-ProgressBar-track">
@ -51,7 +40,7 @@
class="spectrum-ProgressBar-fill"
class:color-green={color === "green"}
class:color-red={color === "red"}
style={value || value === 0 ? `width: ${$progress}%` : ""}
style="width: {value}%; --duration: {duration}ms;"
/>
</div>
<div class="spectrum-ProgressBar-label" hidden="" />
@ -64,4 +53,7 @@
.color-red {
background: #dd2019;
}
.spectrum-ProgressBar-fill {
transition: width var(--duration) ease-out;
}
</style>

View file

@ -3,13 +3,13 @@
--ink: #000000;
/* Brand colours */
--bb-coral: #FF4E4E;
--bb-coral-light: #F97777;
--bb-indigo: #6E56FF;
--bb-indigo-light: #9F8FFF;
--bb-lime: #ECFFB5;
--bb-coral: #ff4e4e;
--bb-coral-light: #f97777;
--bb-indigo: #6e56ff;
--bb-indigo-light: #9f8fff;
--bb-lime: #ecffb5;
--bb-forest-green: #053835;
--bb-beige: #F6EFEA;
--bb-beige: #f6efea;
--grey-1: #fafafa;
--grey-2: #f5f5f5;
@ -49,10 +49,10 @@
--rounded-medium: 8px;
--rounded-large: 16px;
--font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter",
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-serif: "Georgia", Cambria, Times New Roman, Times, serif;
--font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
@ -111,7 +111,7 @@ a {
/* Custom theme additions */
.spectrum--darkest {
--drop-shadow: rgba(0, 0, 0, 0.6);
--spectrum-global-color-blue-100: rgb(28, 33, 43);
--spectrum-global-color-blue-100: rgb(30, 36, 50);
}
.spectrum--dark {
--drop-shadow: rgba(0, 0, 0, 0.3);

View file

@ -0,0 +1,62 @@
<script>
import { contextMenuStore } from "stores/builder"
import { Popover, Menu, MenuItem } from "@budibase/bbui"
let dropdown
let anchor
const handleKeyDown = () => {
if ($contextMenuStore.visible) {
contextMenuStore.close()
}
}
const handleItemClick = async itemCallback => {
await itemCallback()
contextMenuStore.close()
}
</script>
<svelte:window on:keydown={handleKeyDown} />
{#key $contextMenuStore.position}
<div
bind:this={anchor}
class="anchor"
style:top={`${$contextMenuStore.position.y}px`}
style:left={`${$contextMenuStore.position.x}px`}
/>
{/key}
<Popover
open={$contextMenuStore.visible}
animate={false}
bind:this={dropdown}
{anchor}
resizable={false}
align="left"
on:close={contextMenuStore.close}
>
<Menu>
{#each $contextMenuStore.items as item}
{#if item.visible}
<MenuItem
icon={item.icon}
keyBind={item.keyBind}
on:click={() => handleItemClick(item.callback)}
disabled={item.disabled}
>
{item.name}
</MenuItem>
{/if}
{/each}
</Menu>
</Popover>
<style>
.anchor {
z-index: 100;
position: absolute;
width: 0;
height: 0;
}
</style>

View file

@ -17,12 +17,12 @@
export let blockIdx
export let lastStep
export let modal
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
let lockedFeatures = [
ActionStepID.COLLECT,
@ -91,19 +91,17 @@
return acc
}, {})
const selectAction = action => {
actionVal = action
const selectAction = async action => {
selectedAction = action.name
}
async function addBlockToAutomation() {
try {
const newBlock = automationStore.actions.constructBlock(
"ACTION",
actionVal.stepId,
actionVal
action.stepId,
action
)
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
modal.hide()
} catch (error) {
notifications.error("Error saving automation")
}
@ -114,10 +112,10 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<ModalContent
title="Add automation step"
confirmText="Save"
size="L"
showConfirmButton={false}
showCancelButton={false}
disabled={!selectedAction}
onConfirm={addBlockToAutomation}
>
<Layout noPadding gap="XS">
<Detail size="S">Apps</Detail>

View file

@ -206,7 +206,7 @@
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {lastStep} {blockIdx} />
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">

View file

@ -81,7 +81,7 @@
// Check the schema to see if required fields have been entered
$: isError =
!isTriggerValid(trigger) ||
!trigger.schema.outputs.required.every(
!trigger.schema.outputs.required?.every(
required => $memoTestData?.[required] || required !== "row"
)

View file

@ -1,48 +0,0 @@
<script>
import { onMount } from "svelte"
import {
automationStore,
selectedAutomation,
userSelectedResourceMap,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte"
import { notifications } from "@budibase/bbui"
$: selectedAutomationId = $selectedAutomation?._id
onMount(async () => {
try {
await automationStore.actions.fetch()
} catch (error) {
notifications.error("Error getting automations list")
}
})
function selectAutomation(id) {
automationStore.actions.select(id)
}
</script>
<div class="automations-list">
{#each $automationStore.automations.sort(aut => aut.name) as automation}
<NavItem
icon="ShareAndroid"
text={automation.name}
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
>
<EditAutomationPopover {automation} />
</NavItem>
{/each}
</div>
<style>
.automations-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View file

@ -0,0 +1,123 @@
<script>
import {
selectedAutomation,
userSelectedResourceMap,
automationStore,
contextMenuStore,
} from "stores/builder"
import { notifications, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte"
import NavItem from "components/common/NavItem.svelte"
export let automation
export let icon
let confirmDeleteDialog
let updateAutomationDialog
async function deleteAutomation() {
try {
await automationStore.actions.delete(automation)
notifications.success("Automation deleted successfully")
} catch (error) {
notifications.error("Error deleting automation")
}
}
async function duplicateAutomation() {
try {
await automationStore.actions.duplicate(automation)
notifications.success("Automation has been duplicated successfully")
} catch (error) {
notifications.error("Error duplicating automation")
}
}
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: updateAutomationDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: automation.definition.trigger.name === "Webhook",
callback: duplicateAutomation,
},
{
icon: automation.disabled ? "CheckmarkCircle" : "Cancel",
name: automation.disabled ? "Activate" : "Pause",
keyBind: null,
visible: true,
disabled: false,
callback: () => {
automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)
},
},
]
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(automation._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<NavItem
on:contextmenu={openContextMenu}
{icon}
iconColor={"var(--spectrum-global-color-gray-900)"}
text={automation.name}
selected={automation._id === $selectedAutomation?._id}
hovering={automation._id === $contextMenuStore.id}
on:click={() => automationStore.actions.select(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
disabled={automation.disabled}
>
<div class="icon">
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
</div>
</NavItem>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Automation"
onOk={deleteAutomation}
title="Confirm Deletion"
>
Are you sure you wish to delete the automation
<i>{automation.name}?</i>
This action cannot be undone.
</ConfirmDialog>
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View file

@ -3,20 +3,13 @@
import { Modal, notifications, Layout } from "@budibase/bbui"
import NavHeader from "components/common/NavHeader.svelte"
import { onMount } from "svelte"
import {
automationStore,
selectedAutomation,
userSelectedResourceMap,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte"
import { automationStore } from "stores/builder"
import AutomationNavItem from "./AutomationNavItem.svelte"
export let modal
export let webhookModal
let searchString
$: selectedAutomationId = $selectedAutomation?._id
$: filteredAutomations = $automationStore.automations
.filter(automation => {
return (
@ -49,10 +42,6 @@
notifications.error("Error getting automations list")
}
})
function selectAutomation(id) {
automationStore.actions.select(id)
}
</script>
<div class="side-bar">
@ -71,17 +60,7 @@
{triggerGroup?.name}
</div>
{#each triggerGroup.entries as automation}
<NavItem
icon={triggerGroup.icon}
iconColor={"var(--spectrum-global-color-gray-900)"}
text={automation.name}
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
disabled={automation.disabled}
>
<EditAutomationPopover {automation} />
</NavItem>
<AutomationNavItem {automation} icon={triggerGroup.icon} />
{/each}
</div>
{/each}

View file

@ -1,73 +0,0 @@
<script>
import { automationStore } from "stores/builder"
import { ActionMenu, MenuItem, notifications, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte"
export let automation
let confirmDeleteDialog
let updateAutomationDialog
async function deleteAutomation() {
try {
await automationStore.actions.delete(automation)
notifications.success("Automation deleted successfully")
} catch (error) {
notifications.error("Error deleting automation")
}
}
async function duplicateAutomation() {
try {
await automationStore.actions.duplicate(automation)
notifications.success("Automation has been duplicated successfully")
} catch (error) {
notifications.error("Error duplicating automation")
}
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon s hoverable name="MoreSmallList" />
</div>
<MenuItem
icon="Duplicate"
on:click={duplicateAutomation}
disabled={automation.definition.trigger.name === "Webhook"}
>Duplicate</MenuItem
>
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem>
<MenuItem
icon={automation.disabled ? "CheckmarkCircle" : "Cancel"}
on:click={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
>
{automation.disabled ? "Activate" : "Pause"}
</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Automation"
onOk={deleteAutomation}
title="Confirm Deletion"
>
Are you sure you wish to delete the automation
<i>{automation.name}?</i>
This action cannot be undone.
</ConfirmDialog>
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View file

@ -79,18 +79,18 @@
table = $tables.list.find(table => table._id === tableId)
schemaFields = Object.entries(table?.schema ?? {})
.filter(entry => {
const [, field] = entry
return field.type !== "formula" && !field.autocolumn
})
.sort(([nameA], [nameB]) => {
return nameA < nameB ? -1 : 1
})
if (table) {
editableRow["tableId"] = tableId
schemaFields = Object.entries(table?.schema ?? {})
.filter(entry => {
const [, field] = entry
return field.type !== "formula" && !field.autocolumn
})
.sort(([nameA], [nameB]) => {
return nameA < nameB ? -1 : 1
})
// Parse out any data not in the schema.
for (const column in editableFields) {
if (!Object.hasOwn(table?.schema, column)) {

View file

@ -0,0 +1,82 @@
<script>
import { isActive } from "@roxi/routify"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import { contextMenuStore, userSelectedResourceMap } from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { Icon } from "@budibase/bbui"
import UpdateDatasourceModal from "components/backend/DatasourceNavigator/modals/UpdateDatasourceModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
export let datasource
let editModal
let deleteConfirmationModal
const getContextMenuItems = () => {
return [
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: editModal.show,
},
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
]
}
const openContextMenu = e => {
if (datasource._id === BUDIBASE_INTERNAL_DB_ID) {
return
}
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(datasource._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<NavItem
on:contextmenu={openContextMenu}
border
text={datasource.name}
opened={datasource.open}
selected={$isActive("./datasource") && datasource.selected}
hovering={datasource._id === $contextMenuStore.id}
withArrow={true}
on:click
on:iconClick
selectedBy={$userSelectedResourceMap[datasource._id]}
>
<div class="datasource-icon" slot="icon">
<IntegrationIcon
integrationType={datasource.source}
schema={datasource.schema}
size="18"
/>
</div>
{#if datasource._id !== BUDIBASE_INTERNAL_DB_ID}
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
{/if}
</NavItem>
<UpdateDatasourceModal {datasource} bind:this={editModal} />
<DeleteConfirmationModal {datasource} bind:this={deleteConfirmationModal} />
<style>
.datasource-icon {
display: grid;
place-items: center;
flex: 0 0 24px;
}
</style>

View file

@ -1,15 +1,16 @@
<script>
import { goto } from "@roxi/routify"
import { datasources } from "stores/builder"
import { notifications, ActionMenu, MenuItem, Icon } from "@budibase/bbui"
import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import UpdateDatasourceModal from "components/backend/DatasourceNavigator/modals/UpdateDatasourceModal.svelte"
import { BUDIBASE_DATASOURCE_TYPE } from "constants/backend"
export let datasource
let confirmDeleteDialog
let updateDatasourceDialog
export const show = () => {
confirmDeleteDialog.show()
}
async function deleteDatasource() {
try {
@ -25,16 +26,6 @@
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
{#if datasource.type !== BUDIBASE_DATASOURCE_TYPE}
<MenuItem icon="Edit" on:click={updateDatasourceDialog.show}>Edit</MenuItem>
{/if}
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Datasource"
@ -45,13 +36,3 @@
<i>{datasource.name}?</i>
This action cannot be undone.
</ConfirmDialog>
<UpdateDatasourceModal {datasource} bind:this={updateDatasourceDialog} />
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View file

@ -1,7 +1,6 @@
<script>
import { goto, isActive, params } from "@roxi/routify"
import { Layout } from "@budibase/bbui"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import {
datasources,
queries,
@ -10,16 +9,10 @@
viewsV2,
userSelectedResourceMap,
} from "stores/builder"
import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte"
import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import QueryNavItem from "./QueryNavItem.svelte"
import NavItem from "components/common/NavItem.svelte"
import TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte"
import {
customQueryIconText,
customQueryIconColor,
customQueryText,
} from "helpers/data/utils"
import IntegrationIcon from "./IntegrationIcon.svelte"
import DatasourceNavItem from "./DatasourceNavItem/DatasourceNavItem.svelte"
import { TableNames } from "constants"
import { enrichDatasources } from "./datasourceUtils"
import { onMount } from "svelte"
@ -86,44 +79,15 @@
/>
{/if}
{#each enrichedDataSources.filter(ds => ds.show) as datasource}
<NavItem
border
text={datasource.name}
opened={datasource.open}
selected={$isActive("./datasource") && datasource.selected}
withArrow={true}
<DatasourceNavItem
{datasource}
on:click={() => selectDatasource(datasource)}
on:iconClick={() => toggleNode(datasource)}
selectedBy={$userSelectedResourceMap[datasource._id]}
>
<div class="datasource-icon" slot="icon">
<IntegrationIcon
integrationType={datasource.source}
schema={datasource.schema}
size="18"
/>
</div>
{#if datasource._id !== BUDIBASE_INTERNAL_DB_ID}
<EditDatasourcePopover {datasource} />
{/if}
</NavItem>
/>
{#if datasource.open}
<TableNavigator tables={datasource.tables} {selectTable} />
{#each datasource.queries as query}
<NavItem
indentLevel={1}
icon="SQLQuery"
iconText={customQueryIconText(datasource, query)}
iconColor={customQueryIconColor(datasource, query)}
text={customQueryText(datasource, query)}
selected={$isActive("./query/:queryId") &&
$queries.selectedQueryId === query._id}
on:click={() => $goto(`./query/${query._id}`)}
selectedBy={$userSelectedResourceMap[query._id]}
>
<EditQueryPopover {query} />
</NavItem>
<QueryNavItem {datasource} {query} />
{/each}
{/if}
{/each}
@ -140,11 +104,6 @@
.hierarchy-items-container {
margin: 0 calc(-1 * var(--spacing-l));
}
.datasource-icon {
display: grid;
place-items: center;
flex: 0 0 24px;
}
.no-results {
color: var(--spectrum-global-color-gray-600);

View file

@ -0,0 +1,103 @@
<script>
import {
customQueryIconText,
customQueryIconColor,
customQueryText,
} from "helpers/data/utils"
import { goto as gotoStore, isActive } from "@roxi/routify"
import {
datasources,
queries,
userSelectedResourceMap,
contextMenuStore,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { notifications, Icon } from "@budibase/bbui"
export let datasource
export let query
let confirmDeleteDialog
// goto won't work in the context menu callback if the store is called directly
$: goto = $gotoStore
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: false,
callback: async () => {
try {
const newQuery = await queries.duplicate(query)
goto(`./query/${newQuery._id}`)
} catch (error) {
notifications.error("Error duplicating query")
}
},
},
]
}
async function deleteQuery() {
try {
// Go back to the datasource if we are deleting the active query
if ($queries.selectedQueryId === query._id) {
goto(`./datasource/${query.datasourceId}`)
}
await queries.delete(query)
await datasources.fetch()
notifications.success("Query deleted")
} catch (error) {
notifications.error("Error deleting query")
}
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(query._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<NavItem
on:contextmenu={openContextMenu}
indentLevel={1}
icon="SQLQuery"
iconText={customQueryIconText(datasource, query)}
iconColor={customQueryIconColor(datasource, query)}
text={customQueryText(datasource, query)}
selected={$isActive("./query/:queryId") &&
$queries.selectedQueryId === query._id}
hovering={query._id === $contextMenuStore.id}
on:click={() => goto(`./query/${query._id}`)}
selectedBy={$userSelectedResourceMap[query._id]}
>
<Icon size="S" hoverable name="MoreSmallList" on:click={openContextMenu} />
</NavItem>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Query"
onOk={deleteQuery}
title="Confirm Deletion"
>
Are you sure you wish to delete this query? This action cannot be undone.
</ConfirmDialog>
<style>
</style>

View file

@ -1,59 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { datasources, queries } from "stores/builder"
export let query
let confirmDeleteDialog
async function deleteQuery() {
try {
// Go back to the datasource if we are deleting the active query
if ($queries.selectedQueryId === query._id) {
$goto(`./datasource/${query.datasourceId}`)
}
await queries.delete(query)
await datasources.fetch()
notifications.success("Query deleted")
} catch (error) {
notifications.error("Error deleting query")
}
}
async function duplicateQuery() {
try {
const newQuery = await queries.duplicate(query)
$goto(`./query/${newQuery._id}`)
} catch (error) {
notifications.error("Error duplicating query")
}
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
<MenuItem icon="Duplicate" on:click={duplicateQuery}>Duplicate</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Query"
onOk={deleteQuery}
title="Confirm Deletion"
>
Are you sure you wish to delete this query? This action cannot be undone.
</ConfirmDialog>
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View file

@ -1,35 +1,15 @@
<script>
import { goto, params } from "@roxi/routify"
import { cloneDeep } from "lodash/fp"
import { tables, datasources, screenStore } from "stores/builder"
import {
ActionMenu,
Icon,
Input,
MenuItem,
Modal,
ModalContent,
notifications,
} from "@budibase/bbui"
import { Input, notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { DB_TYPE_EXTERNAL } from "constants/backend"
export let table
let editorModal, editTableNameModal
let confirmDeleteDialog
let error = ""
let originalName
let updatedName
let templateScreens
let willBeDeleted
let deleteTableName
$: externalTable = table?.sourceType === DB_TYPE_EXTERNAL
function showDeleteModal() {
export const show = () => {
templateScreens = $screenStore.screens.filter(
screen => screen.autoTableId === table._id
)
@ -39,6 +19,10 @@
confirmDeleteDialog.show()
}
let templateScreens
let willBeDeleted
let deleteTableName
async function deleteTable() {
const isSelected = $params.tableId === table._id
try {
@ -62,58 +46,8 @@
function hideDeleteDialog() {
deleteTableName = ""
}
async function save() {
const updatedTable = cloneDeep(table)
updatedTable.name = updatedName
await tables.save(updatedTable)
await datasources.fetch()
notifications.success("Table renamed successfully")
}
function checkValid(evt) {
const tableName = evt.target.value
error =
originalName === tableName
? `Table with name ${tableName} already exists. Please choose another name.`
: ""
}
const initForm = () => {
originalName = table.name + ""
updatedName = table.name + ""
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon s hoverable name="MoreSmallList" />
</div>
{#if !externalTable}
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
{/if}
<MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
</ActionMenu>
<Modal bind:this={editorModal} on:show={initForm}>
<ModalContent
bind:this={editTableNameModal}
title="Edit Table"
confirmText="Save"
onConfirm={save}
disabled={updatedName === originalName || error}
>
<form on:submit|preventDefault={() => editTableNameModal.confirm()}>
<Input
label="Table Name"
thin
bind:value={updatedName}
on:input={checkValid}
{error}
/>
</form>
</ModalContent>
</Modal>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Table"
@ -142,13 +76,6 @@
</ConfirmDialog>
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
div.delete-items {
margin-top: 10px;
margin-bottom: 10px;

View file

@ -0,0 +1,58 @@
<script>
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder"
import { Input, Modal, ModalContent, notifications } from "@budibase/bbui"
export let table
export const show = () => {
editorModal.show()
}
let editorModal, editTableNameModal
let error = ""
let originalName
let updatedName
async function save() {
const updatedTable = cloneDeep(table)
updatedTable.name = updatedName
await tables.save(updatedTable)
await datasources.fetch()
notifications.success("Table renamed successfully")
}
function checkValid(evt) {
const tableName = evt.target.value
error =
originalName === tableName
? `Table with name ${tableName} already exists. Please choose another name.`
: ""
}
const initForm = () => {
originalName = table.name + ""
updatedName = table.name + ""
}
</script>
<Modal bind:this={editorModal} on:show={initForm}>
<ModalContent
bind:this={editTableNameModal}
title="Edit Table"
confirmText="Save"
onConfirm={save}
disabled={updatedName === originalName || error}
>
<form on:submit|preventDefault={() => editTableNameModal.confirm()}>
<Input
label="Table Name"
thin
bind:value={updatedName}
on:input={checkValid}
{error}
/>
</form>
</ModalContent>
</Modal>

View file

@ -0,0 +1,68 @@
<script>
import {
tables as tablesStore,
userSelectedResourceMap,
contextMenuStore,
} from "stores/builder"
import { TableNames } from "constants"
import NavItem from "components/common/NavItem.svelte"
import { isActive } from "@roxi/routify"
import EditModal from "./EditModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
import { Icon } from "@budibase/bbui"
import { DB_TYPE_EXTERNAL } from "constants/backend"
export let table
export let idx
let editModal
let deleteConfirmationModal
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: table?.sourceType !== DB_TYPE_EXTERNAL,
disabled: false,
callback: editModal.show,
},
]
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(table._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<NavItem
on:contextmenu={openContextMenu}
indentLevel={1}
border={idx > 0}
icon={table._id === TableNames.USERS ? "UserGroup" : "Table"}
text={table.name}
hovering={table._id === $contextMenuStore.id}
selected={$isActive("./table/:tableId") &&
$tablesStore.selected?._id === table._id}
selectedBy={$userSelectedResourceMap[table._id]}
on:click
>
{#if table._id !== TableNames.USERS}
<Icon s on:click={openContextMenu} hoverable name="MoreSmallList" />
{/if}
</NavItem>
<EditModal {table} bind:this={editModal} />
<DeleteConfirmationModal {table} bind:this={deleteConfirmationModal} />

View file

@ -1,15 +1,7 @@
<script>
import {
tables as tablesStore,
views,
viewsV2,
userSelectedResourceMap,
} from "stores/builder"
import { TableNames } from "constants"
import EditTablePopover from "./popovers/EditTablePopover.svelte"
import EditViewPopover from "./popovers/EditViewPopover.svelte"
import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify"
import { goto } from "@roxi/routify"
import TableNavItem from "./TableNavItem/TableNavItem.svelte"
import ViewNavItem from "./ViewNavItem/ViewNavItem.svelte"
export let tables
export let selectTable
@ -19,37 +11,15 @@
const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
}
const isViewActive = (view, isActive, views, viewsV2) => {
return (
(isActive("./view/v1") && views.selected?.name === view.name) ||
(isActive("./view/v2") && viewsV2.selected?.id === view.id)
)
}
</script>
<div class="hierarchy-items-container">
{#each sortedTables as table, idx}
<NavItem
indentLevel={1}
border={idx > 0}
icon={table._id === TableNames.USERS ? "UserGroup" : "Table"}
text={table.name}
selected={$isActive("./table/:tableId") &&
$tablesStore.selected?._id === table._id}
on:click={() => selectTable(table._id)}
selectedBy={$userSelectedResourceMap[table._id]}
>
{#if table._id !== TableNames.USERS}
<EditTablePopover {table} />
{/if}
</NavItem>
<TableNavItem {table} {idx} on:click={() => selectTable(table._id)} />
{#each [...Object.entries(table.views || {})].sort() as [name, view], idx (idx)}
<NavItem
indentLevel={2}
icon="Remove"
text={name}
selected={isViewActive(view, $isActive, $views, $viewsV2)}
<ViewNavItem
{view}
{name}
on:click={() => {
if (view.version === 2) {
$goto(`./view/v2/${encodeURIComponent(view.id)}`)
@ -57,11 +27,7 @@
$goto(`./view/v1/${encodeURIComponent(name)}`)
}
}}
selectedBy={$userSelectedResourceMap[name] ||
$userSelectedResourceMap[view.id]}
>
<EditViewPopover {view} />
</NavItem>
/>
{/each}
{/each}
</div>

View file

@ -0,0 +1,34 @@
<script>
import { views, viewsV2 } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { notifications } from "@budibase/bbui"
export let view
let confirmDeleteDialog
export const show = () => {
confirmDeleteDialog.show()
}
async function deleteView() {
try {
if (view.version === 2) {
await viewsV2.delete(view)
} else {
await views.delete(view)
}
notifications.success("View deleted")
} catch (error) {
notifications.error("Error deleting view")
}
}
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
body={`Are you sure you wish to delete the view '${view.name}'? Your data will be deleted and this action cannot be undone.`}
okText="Delete View"
onOk={deleteView}
title="Confirm Deletion"
/>

View file

@ -0,0 +1,45 @@
<script>
import { views, viewsV2 } from "stores/builder"
import { cloneDeep } from "lodash/fp"
import { notifications, Input, Modal, ModalContent } from "@budibase/bbui"
export let view
let editorModal
let originalName
let updatedName
export const show = () => {
editorModal.show()
}
async function save() {
const updatedView = cloneDeep(view)
updatedView.name = updatedName
if (view.version === 2) {
await viewsV2.save({
originalName,
...updatedView,
})
} else {
await views.save({
originalName,
...updatedView,
})
}
notifications.success("View renamed successfully")
}
const initForm = () => {
updatedName = view.name + ""
originalName = view.name + ""
}
</script>
<Modal bind:this={editorModal} on:show={initForm}>
<ModalContent title="Edit View" onConfirm={save} confirmText="Save">
<Input label="View Name" thin bind:value={updatedName} />
</ModalContent>
</Modal>

View file

@ -0,0 +1,71 @@
<script>
import {
contextMenuStore,
views,
viewsV2,
userSelectedResourceMap,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import { isActive } from "@roxi/routify"
import { Icon } from "@budibase/bbui"
import EditViewModal from "./EditViewModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
export let view
export let name
let editModal
let deleteConfirmationModal
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: editModal.show,
},
]
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(view.id, items, { x: e.clientX, y: e.clientY })
}
const isViewActive = (view, isActive, views, viewsV2) => {
return (
(isActive("./view/v1") && views.selected?.name === view.name) ||
(isActive("./view/v2") && viewsV2.selected?.id === view.id)
)
}
</script>
<NavItem
on:contextmenu={openContextMenu}
indentLevel={2}
icon="Remove"
text={name}
selected={isViewActive(view, $isActive, $views, $viewsV2)}
hovering={view.id === $contextMenuStore.id}
on:click
selectedBy={$userSelectedResourceMap[name] ||
$userSelectedResourceMap[view.id]}
>
<Icon on:click={openContextMenu} s hoverable name="MoreSmallList" />
</NavItem>
<EditViewModal {view} bind:this={editModal} />
<DeleteConfirmationModal {view} bind:this={deleteConfirmationModal} />

View file

@ -1,78 +0,0 @@
<script>
import { views, viewsV2 } from "stores/builder"
import { cloneDeep } from "lodash/fp"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import {
notifications,
Icon,
Input,
ActionMenu,
MenuItem,
Modal,
ModalContent,
} from "@budibase/bbui"
export let view
let editorModal
let originalName
let updatedName
let confirmDeleteDialog
async function save() {
const updatedView = cloneDeep(view)
updatedView.name = updatedName
if (view.version === 2) {
await viewsV2.save({
originalName,
...updatedView,
})
} else {
await views.save({
originalName,
...updatedView,
})
}
notifications.success("View renamed successfully")
}
async function deleteView() {
try {
if (view.version === 2) {
await viewsV2.delete(view)
} else {
await views.delete(view)
}
notifications.success("View deleted")
} catch (error) {
notifications.error("Error deleting view")
}
}
const initForm = () => {
updatedName = view.name + ""
originalName = view.name + ""
}
</script>
<ActionMenu>
<div slot="control" class="icon open-popover">
<Icon s hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>
<Modal bind:this={editorModal} on:show={initForm}>
<ModalContent title="Edit View" onConfirm={save} confirmText="Save">
<Input label="View Name" thin bind:value={updatedName} />
</ModalContent>
</Modal>
<ConfirmDialog
bind:this={confirmDeleteDialog}
body={`Are you sure you wish to delete the view '${view.name}'? Your data will be deleted and this action cannot be undone.`}
okText="Delete View"
onOk={deleteView}
title="Confirm Deletion"
/>

View file

@ -83,6 +83,7 @@
on:mouseenter
on:mouseleave
on:click={onClick}
on:contextmenu
ondragover="return false"
ondragenter="return false"
{id}

View file

@ -38,6 +38,7 @@
await API.deleteApp(appId)
appsStore.load()
notifications.success("App deleted successfully")
deleting = false
onDeleteSuccess()
} catch (err) {
notifications.error("Error deleting app")

View file

@ -84,6 +84,8 @@
showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
minWidth={360}
maxWidth={360}
offset={18}
>
<span class="popover-wrap">

View file

@ -99,4 +99,11 @@
);
align-items: center;
}
.type-icon span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 0;
flex: 1 1 auto;
}
</style>

View file

@ -0,0 +1,56 @@
<script>
import { Modal } from "@budibase/bbui"
import DeleteModal from "components/deploy/DeleteModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { licensing } from "stores/portal"
export let app
let exportPublishedVersion = false
let deleteModal
let exportModal
let duplicateModal
export const showDuplicateModal = () => {
duplicateModal.show()
}
export const showExportDevModal = () => {
exportPublishedVersion = false
exportModal.show()
}
export const showExportProdModal = () => {
exportPublishedVersion = true
exportModal.show()
}
export const showDeleteModal = () => {
deleteModal.show()
}
</script>
<DeleteModal
bind:this={deleteModal}
appId={app?.devId}
appName={app?.name}
onDeleteSuccess={async () => {
await licensing.init()
}}
/>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={duplicateModal} padding={false}>
<DuplicateAppModal
appId={app?.devId}
appName={app?.name}
onDuplicateSuccess={async () => {
await licensing.init()
}}
/>
</Modal>

View file

@ -5,14 +5,17 @@
import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte"
import AppContextMenuModals from "./AppContextMenuModals.svelte"
import getAppContextMenuItems from "./getAppContextMenuItems.js"
import FavouriteAppButton from "pages/builder/portal/apps/FavouriteAppButton.svelte"
import { contextMenuStore } from "stores/builder"
export let app
export let lockedAction
let actionsOpen = false
let appContextMenuModals
$: contextMenuOpen = `${app.appId}-index` === $contextMenuStore.id
$: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
$: unclickable = !isBuilder && !app.deployed
@ -40,16 +43,35 @@
window.open(`/app${app.url}`, "_blank")
}
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getAppContextMenuItems({
app,
onDuplicate: appContextMenuModals?.showDuplicateModal,
onExportDev: appContextMenuModals?.showExportDevModal,
onExportProd: appContextMenuModals?.showExportProdModal,
onDelete: appContextMenuModals?.showDeleteModal,
})
contextMenuStore.open(`${app.appId}-index`, items, {
x: e.clientX,
y: e.clientY,
})
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class:contextMenuOpen
class="app-row"
class:unclickable
class:actionsOpen
class:favourite={app.favourite}
on:click={lockedAction || handleDefaultClick}
on:contextmenu={openContextMenu}
>
<div class="title">
<div class="app-icon">
@ -89,14 +111,11 @@
</Button>
</div>
<div class="row-action">
<AppRowContext
{app}
on:open={() => {
actionsOpen = true
}}
on:close={() => {
actionsOpen = false
}}
<Icon
on:click={openContextMenu}
size="S"
hoverable
name="MoreSmallList"
/>
</div>
{:else}
@ -109,6 +128,7 @@
<FavouriteAppButton {app} noWrap />
</div>
</div>
<AppContextMenuModals {app} bind:this={appContextMenuModals} />
</div>
<style>
@ -123,7 +143,8 @@
transition: border 130ms ease-out;
border: 1px solid transparent;
}
.app-row:not(.unclickable):hover {
.app-row:not(.unclickable):hover,
.contextMenuOpen {
cursor: pointer;
border-color: var(--spectrum-global-color-gray-300);
}
@ -132,9 +153,9 @@
display: none;
}
.app-row.contextMenuOpen .favourite-icon,
.app-row:hover .favourite-icon,
.app-row.favourite .favourite-icon,
.app-row.actionsOpen .favourite-icon {
.app-row.favourite .favourite-icon {
display: flex;
}
@ -176,8 +197,8 @@
display: none;
}
.app-row:hover .app-row-actions,
.app-row.actionsOpen .app-row-actions {
.app-row.contextMenuOpen .app-row-actions,
.app-row:hover .app-row-actions {
gap: var(--spacing-m);
flex-direction: row;
justify-content: flex-end;

View file

@ -1,108 +0,0 @@
<script>
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import DeleteModal from "components/deploy/DeleteModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { onMount } from "svelte"
import { licensing } from "stores/portal"
export let app
export let align = "right"
export let options
let deleteModal
let exportModal
let duplicateModal
let exportPublishedVersion = false
let loaded = false
const getActions = app => {
if (!loaded) {
return []
}
return [
{
id: "duplicate",
icon: "Copy",
onClick: duplicateModal.show,
body: "Duplicate",
},
{
id: "exportDev",
icon: "Export",
onClick: () => {
exportPublishedVersion = false
exportModal.show()
},
body: "Export latest edited app",
},
{
id: "exportProd",
icon: "Export",
onClick: () => {
exportPublishedVersion = true
exportModal.show()
},
body: "Export latest published app",
},
{
id: "delete",
icon: "Delete",
onClick: deleteModal.show,
body: "Delete",
},
].filter(action => {
if (action.id === "exportProd" && app.deployed !== true) {
return false
} else if (Array.isArray(options) && !options.includes(action.id)) {
return false
}
return true
})
}
$: actions = getActions(app, loaded)
onMount(() => {
loaded = true
})
let appLimitModal
</script>
<DeleteModal
bind:this={deleteModal}
appId={app.devId}
appName={app.name}
onDeleteSuccess={async () => {
await licensing.init()
}}
/>
<AppLimitModal bind:this={appLimitModal} />
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={duplicateModal} padding={false}>
<DuplicateAppModal
appId={app.devId}
appName={app.name}
onDuplicateSuccess={async () => {
await licensing.init()
}}
/>
</Modal>
<ActionMenu {align} on:open on:close>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
{#each actions as action}
<MenuItem icon={action.icon} on:click={action.onClick}>
{action.body}
</MenuItem>
{/each}
</ActionMenu>

View file

@ -0,0 +1,44 @@
const getAppContextMenuItems = ({
app,
onDuplicate,
onExportDev,
onExportProd,
onDelete,
}) => {
return [
{
icon: "Copy",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: false,
callback: onDuplicate,
},
{
icon: "Export",
name: "Export latest edited app",
keyBind: null,
visible: true,
disabled: false,
callback: onExportDev,
},
{
icon: "Export",
name: "Export latest published app",
keyBind: null,
visible: true,
disabled: !app.deployed,
callback: onExportProd,
},
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: onDelete,
},
]
}
export default getAppContextMenuItems

View file

@ -5,6 +5,7 @@
import { CookieUtils, Constants } from "@budibase/frontend-core"
import { API } from "api"
import Branding from "./Branding.svelte"
import ContextMenu from "components/ContextMenu.svelte"
let loaded = false
@ -160,6 +161,7 @@
<!--Portal branding overrides -->
<Branding />
<ContextMenu />
{#if loaded}
<slot />

View file

@ -1,129 +0,0 @@
<script>
import { componentStore } from "stores/builder"
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
export let component
export let opened
$: definition = componentStore.getDefinition(component?._component)
$: noPaste = !$componentStore.componentToPaste
$: isBlock = definition?.block === true
$: canEject = !(definition?.ejectable === false)
const keyboardEvent = (key, ctrlKey = false) => {
document.dispatchEvent(
new CustomEvent("component-menu", {
detail: {
key,
ctrlKey,
id: component?._id,
},
})
)
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem
icon="Delete"
keyBind="!BackAndroid"
on:click={() => keyboardEvent("Delete")}
>
Delete
</MenuItem>
{#if isBlock && canEject}
<MenuItem
icon="Export"
keyBind="Ctrl+E"
on:click={() => keyboardEvent("e", true)}
>
Eject block
</MenuItem>
{/if}
<MenuItem
icon="ChevronUp"
keyBind="Ctrl+!ArrowUp"
on:click={() => keyboardEvent("ArrowUp", true)}
>
Move up
</MenuItem>
<MenuItem
icon="ChevronDown"
keyBind="Ctrl+!ArrowDown"
on:click={() => keyboardEvent("ArrowDown", true)}
>
Move down
</MenuItem>
<MenuItem
icon="Duplicate"
keyBind="Ctrl+D"
on:click={() => keyboardEvent("d", true)}
>
Duplicate
</MenuItem>
<MenuItem
icon="Cut"
keyBind="Ctrl+X"
on:click={() => keyboardEvent("x", true)}
>
Cut
</MenuItem>
<MenuItem
icon="Copy"
keyBind="Ctrl+C"
on:click={() => keyboardEvent("c", true)}
>
Copy
</MenuItem>
<MenuItem
icon="LayersSendToBack"
keyBind="Ctrl+V"
on:click={() => keyboardEvent("v", true)}
disabled={noPaste}
>
Paste
</MenuItem>
{#if component?._children?.length}
<MenuItem
icon="TreeExpand"
keyBind="!ArrowRight"
on:click={() => keyboardEvent("ArrowRight", false)}
disabled={opened}
>
Expand
</MenuItem>
<MenuItem
icon="TreeCollapse"
keyBind="!ArrowLeft"
on:click={() => keyboardEvent("ArrowLeft", false)}
disabled={!opened}
>
Collapse
</MenuItem>
<MenuItem
icon="TreeExpandAll"
keyBind="Ctrl+!ArrowRight"
on:click={() => keyboardEvent("ArrowRight", true)}
>
Expand All
</MenuItem>
<MenuItem
icon="TreeCollapseAll"
keyBind="Ctrl+!ArrowLeft"
on:click={() => keyboardEvent("ArrowLeft", true)}
>
Collapse All
</MenuItem>
{/if}
</ActionMenu>
<style>
.icon {
display: grid;
place-items: center;
}
</style>

View file

@ -1,7 +1,6 @@
<script>
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte"
import { notifications } from "@budibase/bbui"
import { Icon, notifications } from "@budibase/bbui"
import {
selectedScreen,
componentStore,
@ -9,6 +8,7 @@
selectedComponent,
hoverStore,
componentTreeNodesStore,
contextMenuStore,
} from "stores/builder"
import {
findComponentPath,
@ -17,6 +17,7 @@
} from "helpers/components"
import { get } from "svelte/store"
import { dndStore } from "./dndStore"
import getComponentContextMenuItems from "./getComponentContextMenuItems"
export let components = []
export let level = 0
@ -85,6 +86,18 @@
}
const hover = hoverStore.hover
const openContextMenu = (e, component, opened) => {
e.preventDefault()
e.stopPropagation()
const items = getComponentContextMenuItems(
component,
!opened,
componentStore
)
contextMenuStore.open(component._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
@ -93,6 +106,7 @@
{#each filteredComponents || [] as component, index (component._id)}
{@const opened = isOpen(component, openNodes)}
<li
on:contextmenu={e => openContextMenu(e, component, opened)}
on:click|stopPropagation={() => {
componentStore.select(component._id)
}}
@ -107,7 +121,8 @@
on:dragover={dragover(component, index)}
on:iconClick={() => handleIconClick(component._id)}
on:drop={onDrop}
hovering={$hoverStore.componentId === component._id}
hovering={$hoverStore.componentId === component._id ||
component._id === $contextMenuStore.id}
on:mouseenter={() => hover(component._id)}
on:mouseleave={() => hover(null)}
text={getComponentText(component)}
@ -120,7 +135,12 @@
highlighted={isChildOfSelectedComponent(component)}
selectedBy={$userSelectedResourceMap[component._id]}
>
<ComponentDropdownMenu {opened} {component} />
<Icon
size="S"
hoverable
name="MoreSmallList"
on:click={e => openContextMenu(e, component, opened)}
/>
</NavItem>
{#if opened}

View file

@ -1,57 +0,0 @@
<script>
import { componentStore } from "stores/builder"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
export let component
$: definition = componentStore.getDefinition(component?._component)
$: noPaste = !$componentStore.componentToPaste
// "editable" has been repurposed for inline text editing.
// It remains here for legacy compatibility.
// Future components should define "static": true for indicate they should
// not show a context menu.
$: showMenu = definition?.editable !== false && definition?.static !== true
const storeComponentForCopy = (cut = false) => {
componentStore.copy(component, cut)
}
const pasteComponent = mode => {
try {
componentStore.paste(component, mode)
} catch (error) {
notifications.error("Error saving component")
}
}
</script>
{#if showMenu}
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem
icon="Copy"
keyBind="Ctrl+C"
on:click={() => storeComponentForCopy(false)}
>
Copy
</MenuItem>
<MenuItem
icon="LayersSendToBack"
keyBind="Ctrl+V"
on:click={() => pasteComponent("inside")}
disabled={noPaste}
>
Paste
</MenuItem>
</ActionMenu>
{/if}
<style>
.icon {
display: grid;
place-items: center;
}
</style>

View file

@ -0,0 +1,123 @@
import { get } from "svelte/store"
import { componentStore } from "stores/builder"
const getContextMenuItems = (component, componentCollapsed) => {
const definition = componentStore.getDefinition(component?._component)
const noPaste = !get(componentStore).componentToPaste
const isBlock = definition?.block === true
const canEject = !(definition?.ejectable === false)
const hasChildren = component?._children?.length
const keyboardEvent = (key, ctrlKey = false) => {
document.dispatchEvent(
new CustomEvent("component-menu", {
detail: {
key,
ctrlKey,
id: component?._id,
},
})
)
}
return [
{
icon: "Delete",
name: "Delete",
keyBind: "!BackAndroid",
visible: true,
disabled: false,
callback: () => keyboardEvent("Delete"),
},
{
icon: "ChevronUp",
name: "Move up",
keyBind: "Ctrl+!ArrowUp",
visible: true,
disabled: false,
callback: () => keyboardEvent("ArrowUp", true),
},
{
icon: "ChevronDown",
name: "Move down",
keyBind: "Ctrl+!ArrowDown",
visible: true,
disabled: false,
callback: () => keyboardEvent("ArrowDown", true),
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: "Ctrl+D",
visible: true,
disabled: false,
callback: () => keyboardEvent("d", true),
},
{
icon: "Cut",
name: "Cut",
keyBind: "Ctrl+X",
visible: true,
disabled: false,
callback: () => keyboardEvent("x", true),
},
{
icon: "Copy",
name: "Copy",
keyBind: "Ctrl+C",
visible: true,
disabled: false,
callback: () => keyboardEvent("c", true),
},
{
icon: "LayersSendToBack",
name: "Paste",
keyBind: "Ctrl+V",
visible: true,
disabled: noPaste,
callback: () => keyboardEvent("v", true),
},
{
icon: "Export",
name: "Eject block",
keyBind: "Ctrl+E",
visible: isBlock && canEject,
disabled: false,
callback: () => keyboardEvent("e", true),
},
{
icon: "TreeExpand",
name: "Expand",
keyBind: "!ArrowRight",
visible: hasChildren,
disabled: !componentCollapsed,
callback: () => keyboardEvent("ArrowRight", false),
},
{
icon: "TreeExpandAll",
name: "Expand All",
keyBind: "Ctrl+!ArrowRight",
visible: hasChildren,
disabled: !componentCollapsed,
callback: () => keyboardEvent("ArrowRight", true),
},
{
icon: "TreeCollapse",
name: "Collapse",
keyBind: "!ArrowLeft",
visible: hasChildren,
disabled: componentCollapsed,
callback: () => keyboardEvent("ArrowLeft", false),
},
{
icon: "TreeCollapseAll",
name: "Collapse All",
keyBind: "Ctrl+!ArrowLeft",
visible: hasChildren,
disabled: componentCollapsed,
callback: () => keyboardEvent("ArrowLeft", true),
},
]
}
export default getContextMenuItems

View file

@ -0,0 +1,40 @@
import { get } from "svelte/store"
import { componentStore } from "stores/builder"
import { notifications } from "@budibase/bbui"
const getContextMenuItems = (component, showCopy) => {
const noPaste = !get(componentStore).componentToPaste
const storeComponentForCopy = (cut = false) => {
componentStore.copy(component, cut)
}
const pasteComponent = mode => {
try {
componentStore.paste(component, mode)
} catch (error) {
notifications.error("Error saving component")
}
}
return [
{
icon: "Copy",
name: "Copy",
keyBind: "Ctrl+C",
visible: showCopy,
disabled: false,
callback: () => storeComponentForCopy(false),
},
{
icon: "LayersSendToBack",
name: "Paste",
keyBind: "Ctrl+V",
visible: true,
disabled: noPaste,
callback: () => pasteComponent("inside"),
},
]
}
export default getContextMenuItems

View file

@ -7,14 +7,15 @@
componentStore,
userSelectedResourceMap,
hoverStore,
contextMenuStore,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import ComponentTree from "./ComponentTree.svelte"
import { dndStore, DropPosition } from "./dndStore.js"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
import getScreenContextMenuItems from "./getScreenContextMenuItems"
let scrolling = false
@ -43,6 +44,32 @@
}
const hover = hoverStore.hover
// showCopy is used to hide the copy button when the user right-clicks the empty
// background of their component tree. Pasting in the empty space makes sense,
// but copying it doesn't
const openScreenContextMenu = (e, showCopy) => {
const screenComponent = $selectedScreen?.props
const definition = componentStore.getDefinition(screenComponent?._component)
// "editable" has been repurposed for inline text editing.
// It remains here for legacy compatibility.
// Future components should define "static": true for indicate they should
// not show a context menu.
if (definition?.editable !== false && definition?.static !== true) {
e.preventDefault()
e.stopPropagation()
const items = getScreenContextMenuItems(screenComponent, showCopy)
contextMenuStore.open(
`${showCopy ? "background-" : ""}screenComponent._id`,
items,
{
x: e.clientX,
y: e.clientY,
}
)
}
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
@ -56,8 +83,11 @@
</div>
<div class="list-panel">
<ComponentScrollWrapper on:scroll={handleScroll}>
<ul>
<li>
<ul
class="componentTree"
on:contextmenu={e => openScreenContextMenu(e, false)}
>
<li on:contextmenu={e => openScreenContextMenu(e, true)}>
<NavItem
text="Screen"
indentLevel={0}
@ -70,14 +100,22 @@
on:click={() => {
componentStore.select(`${$screenStore.selectedScreenId}-screen`)
}}
hovering={$hoverStore.componentId === screenComponentId}
hovering={$hoverStore.componentId === screenComponentId ||
$selectedScreen?.props._id === $contextMenuStore.id}
on:mouseenter={() => hover(screenComponentId)}
on:mouseleave={() => hover(null)}
id="component-screen"
selectedBy={$userSelectedResourceMap[screenComponentId]}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
<Icon
size="S"
hoverable
name="MoreSmallList"
on:click={e => openScreenContextMenu(e, $selectedScreen?.props)}
/>
</NavItem>
</li>
<li on:contextmenu|stopPropagation>
<NavItem
text="Navigation"
indentLevel={0}
@ -165,6 +203,10 @@
flex: 1;
}
.componentTree {
min-height: 100%;
}
ul {
list-style: none;
padding-left: 0;

View file

@ -1,39 +1,25 @@
<script>
import { screenStore, componentStore, navigationStore } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Modal, Helpers, notifications, Icon } from "@budibase/bbui"
import {
ActionMenu,
MenuItem,
Icon,
Modal,
Helpers,
notifications,
} from "@budibase/bbui"
navigationStore,
screenStore,
userSelectedResourceMap,
contextMenuStore,
componentStore,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte"
import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl"
import { makeComponentUnique } from "helpers/components"
import { capitalise } from "helpers"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
export let screenId
export let screen
let confirmDeleteDialog
let screenDetailsModal
$: screen = $screenStore.screens.find(screen => screen._id === screenId)
$: noPaste = !$componentStore.componentToPaste
const pasteComponent = mode => {
try {
componentStore.paste(screen.props, mode, screen)
} catch (error) {
notifications.error("Error saving component")
}
}
const duplicateScreen = () => {
screenDetailsModal.show()
}
const createDuplicateScreen = async ({ screenName, screenUrl }) => {
// Create a dupe and ensure it is unique
let duplicateScreen = Helpers.cloneDeep(screen)
@ -69,22 +55,75 @@
notifications.error("Error deleting screen")
}
}
$: noPaste = !$componentStore.componentToPaste
const pasteComponent = mode => {
try {
componentStore.paste(screen.props, mode, screen)
} catch (error) {
notifications.error("Error saving component")
}
}
const openContextMenu = (e, screen) => {
e.preventDefault()
e.stopPropagation()
const items = [
{
icon: "ShowOneLayer",
name: "Paste inside",
keyBind: null,
visible: true,
disabled: noPaste,
callback: () => pasteComponent("inside"),
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: false,
callback: screenDetailsModal.show,
},
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
},
]
contextMenuStore.open(screen._id, items, { x: e.clientX, y: e.clientY })
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
<NavItem
on:contextmenu={e => openContextMenu(e, screen)}
scrollable
icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0}
selected={$screenStore.selectedScreenId === screen._id}
hovering={screen._id === $contextMenuStore.id}
text={screen.routing.route}
on:click={() => screenStore.select(screen._id)}
rightAlignIcon
showTooltip
selectedBy={$userSelectedResourceMap[screen._id]}
>
<Icon
on:click={e => openContextMenu(e, screen)}
size="S"
hoverable
name="MoreSmallList"
/>
<div slot="icon" class="icon">
<RoleIndicator roleId={screen.routing.roleId} />
</div>
<MenuItem
icon="ShowOneLayer"
on:click={() => pasteComponent("inside")}
disabled={noPaste}
>
Paste inside
</MenuItem>
<MenuItem icon="Duplicate" on:click={duplicateScreen}>Duplicate</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>
</NavItem>
<ConfirmDialog
bind:this={confirmDeleteDialog}
@ -105,7 +144,7 @@
<style>
.icon {
display: grid;
place-items: center;
margin-left: 4px;
margin-right: 4px;
}
</style>

View file

@ -1,13 +1,7 @@
<script>
import { Layout } from "@budibase/bbui"
import {
screenStore,
sortedScreens,
userSelectedResourceMap,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte"
import DropdownMenu from "./DropdownMenu.svelte"
import { sortedScreens } from "stores/builder"
import ScreenNavItem from "./ScreenNavItem.svelte"
import { goto } from "@roxi/routify"
import { getVerticalResizeActions } from "components/common/resizable"
import NavHeader from "components/common/NavHeader.svelte"
@ -55,22 +49,7 @@
<div on:scroll={handleScroll} bind:this={screensContainer} class="content">
{#if filteredScreens?.length}
{#each filteredScreens as screen (screen._id)}
<NavItem
scrollable
icon={screen.routing.homeScreen ? "Home" : null}
indentLevel={0}
selected={$screenStore.selectedScreenId === screen._id}
text={screen.routing.route}
on:click={() => screenStore.select(screen._id)}
rightAlignIcon
showTooltip
selectedBy={$userSelectedResourceMap[screen._id]}
>
<DropdownMenu screenId={screen._id} />
<div slot="icon" class="icon">
<RoleIndicator roleId={screen.routing.roleId} />
</div>
</NavItem>
<ScreenNavItem {screen} />
{/each}
{:else}
<Layout paddingY="none" paddingX="L">
@ -129,11 +108,6 @@
padding-right: 8px !important;
}
.icon {
margin-left: 4px;
margin-right: 4px;
}
.no-results {
color: var(--spectrum-global-color-gray-600);
}

View file

@ -7,7 +7,8 @@
sideBarCollapsed,
enrichedApps,
} from "stores/portal"
import AppRowContext from "components/start/AppRowContext.svelte"
import AppContextMenuModals from "components/start/AppContextMenuModals.svelte"
import getAppContextMenuItems from "components/start/getAppContextMenuItems.js"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import {
Link,
@ -21,12 +22,14 @@
import { API } from "api"
import ErrorSVG from "./ErrorSVG.svelte"
import { getBaseTheme, ClientAppSkeleton } from "@budibase/frontend-core"
import { contextMenuStore } from "stores/builder"
$: app = $enrichedApps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
let loading = true
let appContextMenuModals
const getIframeURL = app => {
loading = true
@ -62,6 +65,24 @@
onDestroy(() => {
window.removeEventListener("message", receiveMessage)
})
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getAppContextMenuItems({
app,
onDuplicate: appContextMenuModals.showDuplicateModal,
onExportDev: appContextMenuModals.showExportDevModal,
onExportProd: appContextMenuModals.showExportProdModal,
onDelete: appContextMenuModals.showDeleteModal,
})
contextMenuStore.open(`${app.appId}-view`, items, {
x: e.clientX,
y: e.clientY,
})
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -116,10 +137,15 @@
size="S"
/>
</div>
<AppRowContext
{app}
options={["duplicate", "delete", "exportDev", "exportProd"]}
align="left"
<Icon
color={`${app.appId}-view` === $contextMenuStore.id
? "var(--hover-color)"
: null}
on:contextmenu={openContextMenu}
on:click={openContextMenu}
size="S"
hoverable
name="MoreSmallList"
/>
</div>
{#if noScreens}
@ -155,6 +181,7 @@
/>
{/if}
</div>
<AppContextMenuModals {app} bind:this={appContextMenuModals} />
<style>
.headerButton {

View file

@ -0,0 +1,91 @@
<script>
import { auth } from "stores/portal"
import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte"
import AppContextMenuModals from "components/start/AppContextMenuModals.svelte"
import getAppContextMenuItems from "components/start/getAppContextMenuItems.js"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { sdk } from "@budibase/shared-core"
import { Icon } from "@budibase/bbui"
import { contextMenuStore } from "stores/builder"
export let app
let opened
let appContextMenuModals
$: contextMenuOpen = `${app.appId}-sideBar` === $contextMenuStore.id
const openContextMenu = (e, app) => {
e.preventDefault()
e.stopPropagation()
const items = getAppContextMenuItems({
app,
onDuplicate: appContextMenuModals.showDuplicateModal,
onExportDev: appContextMenuModals.showExportDevModal,
onExportProd: appContextMenuModals.showExportProdModal,
onDelete: appContextMenuModals.showDeleteModal,
})
contextMenuStore.open(`${app.appId}-sideBar`, items, {
x: e.clientX,
y: e.clientY,
})
}
</script>
<span
class="side-bar-app-entry"
class:favourite={app.favourite}
class:actionsOpen={opened == app.appId || contextMenuOpen}
>
<NavItem
on:contextmenu={e => openContextMenu(e, app)}
text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
hovering={contextMenuOpen}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)}
withActions
showActions
>
<div class="app-entry-actions">
{#if sdk.users.isBuilder($auth.user, app?.devId)}
<Icon
on:click={e => openContextMenu(e, app)}
size="S"
hoverable
name="MoreSmallList"
/>
{/if}
</div>
<div class="favourite-icon">
<FavouriteAppButton {app} size="XS" />
</div>
</NavItem>
</span>
<AppContextMenuModals {app} bind:this={appContextMenuModals} />
<style>
.side-bar-app-entry :global(.nav-item-content .actions) {
width: auto;
display: flex;
gap: var(--spacing-s);
}
.side-bar-app-entry:hover .app-entry-actions,
.side-bar-app-entry:hover .favourite-icon,
.side-bar-app-entry.favourite .favourite-icon,
.side-bar-app-entry.actionsOpen .app-entry-actions,
.side-bar-app-entry.actionsOpen .favourite-icon {
opacity: 1;
}
.side-bar-app-entry .app-entry-actions,
.side-bar-app-entry .favourite-icon {
opacity: 0;
}
</style>

View file

@ -1,11 +1,9 @@
<script>
import { sideBarCollapsed, enrichedApps, auth } from "stores/portal"
import { sideBarCollapsed, enrichedApps } from "stores/portal"
import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte"
import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { sdk } from "@budibase/shared-core"
import AppNavItem from "./AppNavItem.svelte"
let searchString
let opened
@ -40,34 +38,7 @@
class:favourite={app.favourite}
class:actionsOpen={opened == app.appId}
>
<NavItem
text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)}
withActions
showActions
>
<div class="app-entry-actions">
{#if sdk.users.isBuilder($auth.user, app?.devId)}
<AppRowContext
{app}
align="left"
on:open={() => {
opened = app.appId
}}
on:close={() => {
opened = null
}}
/>
{/if}
</div>
<div class="favourite-icon">
<FavouriteAppButton {app} size="XS" />
</div>
</NavItem>
<AppNavItem {app} />
</span>
{/each}
</div>
@ -117,17 +88,4 @@
display: flex;
gap: var(--spacing-s);
}
.side-bar-app-entry:hover .app-entry-actions,
.side-bar-app-entry:hover .favourite-icon,
.side-bar-app-entry.favourite .favourite-icon,
.side-bar-app-entry.actionsOpen .app-entry-actions,
.side-bar-app-entry.actionsOpen .favourite-icon {
opacity: 1;
}
.side-bar-app-entry .app-entry-actions,
.side-bar-app-entry .favourite-icon {
opacity: 0;
}
</style>

View file

@ -0,0 +1,28 @@
import { writable } from "svelte/store"
export const INITIAL_CONTEXT_MENU_STATE = {
id: null,
items: [],
position: { x: 0, y: 0 },
visible: false,
}
export function createViewsStore() {
const store = writable({ ...INITIAL_CONTEXT_MENU_STATE })
const open = (id, items, position) => {
store.set({ id, items, position, visible: true })
}
const close = () => {
store.set({ ...INITIAL_CONTEXT_MENU_STATE })
}
return {
subscribe: store.subscribe,
open,
close,
}
}
export const contextMenuStore = createViewsStore()

View file

@ -14,6 +14,7 @@ import {
} from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets"
// Backend
@ -48,6 +49,7 @@ export {
userStore,
isOnlyUser,
deploymentStore,
contextMenuStore,
selectedComponent,
tables,
views,

View file

@ -28,11 +28,9 @@ export class ScreenStore extends BudiStore {
this.reset = this.reset.bind(this)
this.syncAppScreens = this.syncAppScreens.bind(this)
this.validate = this.validate.bind(this)
this.save = this.save.bind(this)
this.patch = this.patch.bind(this)
this.replace = this.replace.bind(this)
this.saveScreen = this.saveScreen.bind(this)
this.delete = this.delete.bind(this)
this.deleteScreen = this.deleteScreen.bind(this)
this.syncScreenData = this.syncScreenData.bind(this)
this.updateSetting = this.updateSetting.bind(this)
@ -52,6 +50,9 @@ export class ScreenStore extends BudiStore {
}
},
})
this.delete = this.history.wrapDeleteDoc(this.deleteScreen)
this.save = this.history.wrapSaveDoc(this.saveScreen)
}
/**
@ -382,25 +383,6 @@ export class ScreenStore extends BudiStore {
return null
}
/**
* {@link deleteScreen} wrapped to enable history tracking
* @param {object | array} screen
*
*/
async delete(screens_x) {
const wrappedFn = this.history.wrapDeleteDoc(this.deleteScreen)
return wrappedFn(screens_x)
}
/**
* {@link saveScreen} wrapped to enable history tracking
* @param {object} screen
*/
async save(screen) {
const wrappedFn = this.history.wrapSaveDoc(this.saveScreen)
return wrappedFn(screen)
}
/**
* Update a screen by deep setting a property value by name
*

View file

@ -124,13 +124,12 @@
return readable([])
}
return derived(
[gridContext.selectedRows, gridContext.rowLookupMap, gridContext.rows],
([$selectedRows, $rowLookupMap, $rows]) => {
[gridContext.selectedRows, gridContext.rowLookupMap],
([$selectedRows, $rowLookupMap]) => {
return Object.entries($selectedRows || {})
.filter(([_, selected]) => selected)
.map(([rowId]) => {
const idx = $rowLookupMap[rowId]
return gridContext.rows.actions.cleanRow($rows[idx])
return gridContext.rows.actions.cleanRow($rowLookupMap[rowId])
})
}
)
@ -172,7 +171,6 @@
canEditColumns={false}
canExpandRows={false}
canSaveSchema={false}
canSelectRows={true}
showControls={false}
notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error}

View file

@ -4,12 +4,21 @@
import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store"
const { rows, focusedCellId, focusedCellAPI, menu, config, validation } =
getContext("grid")
const {
rows,
columns,
focusedCellId,
focusedCellAPI,
menu,
config,
validation,
selectedCells,
selectedCellCount,
} = getContext("grid")
export let highlighted
export let selected
export let rowFocused
export let rowSelected
export let rowIdx
export let topRow = false
export let focused
@ -20,29 +29,32 @@
export let updateValue = rows.actions.updateValue
export let contentLines = 1
export let hidden = false
export let isSelectingCells = false
export let cellSelected = false
const emptyError = writable(null)
let api
// Get the error for this cell if the row is focused
// Get the error for this cell if the cell is focused or selected
$: error = getErrorStore(rowFocused, cellId)
// Determine if the cell is editable
$: readonly =
column.schema.autocolumn ||
column.schema.disabled ||
column.schema.type === "formula" ||
(!$config.canEditRows && !row._isNewRow) ||
column.schema.readonly
columns.actions.isReadonly(column) ||
(!$config.canEditRows && !row._isNewRow)
// Register this cell API if the row is focused
// Register this cell API if this cell is focused
$: {
if (focused) {
focusedCellAPI.set(cellAPI)
}
}
// Callbacks for cell selection
$: updateSelectionCallback = isSelectingCells ? updateSelection : null
$: stopSelectionCallback = isSelectingCells ? stopSelection : null
const getErrorStore = (selected, cellId) => {
if (!selected) {
return emptyError
@ -68,20 +80,55 @@
})
},
}
const startSelection = e => {
if (e.button !== 0 || e.shiftKey) {
return
}
selectedCells.actions.startSelecting(cellId)
}
const updateSelection = e => {
if (e.buttons !== 1) {
selectedCells.actions.stopSelecting()
return
}
selectedCells.actions.updateTarget(cellId)
}
const stopSelection = () => {
selectedCells.actions.stopSelecting()
}
const handleClick = e => {
if (e.shiftKey && $focusedCellId) {
// If we have a focused cell, select the range from that cell to here
selectedCells.actions.selectRange($focusedCellId, cellId)
} else if (e.shiftKey && $selectedCellCount) {
// If we already have a selected range of cell, update it
selectedCells.actions.updateTarget(cellId)
} else {
// Otherwise just select this cell
focusedCellId.set(cellId)
}
}
</script>
<GridCell
{highlighted}
{selected}
{rowIdx}
{topRow}
{focused}
{selectedUser}
{readonly}
{hidden}
selected={rowSelected || cellSelected}
error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
on:mousedown={startSelection}
on:mouseenter={updateSelectionCallback}
on:mouseup={stopSelectionCallback}
on:click={handleClick}
width={column.width}
>
<svelte:component

View file

@ -46,6 +46,7 @@
on:touchstart
on:touchend
on:touchcancel
on:mouseenter
{style}
>
{#if error}
@ -155,6 +156,7 @@
.cell.focused.readonly {
--cell-background: var(--cell-background-hover);
}
.cell.selected.focused,
.cell.selected:not(.focused) {
--cell-background: var(--spectrum-global-color-blue-100);
}

View file

@ -16,14 +16,22 @@
const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher()
$: selectionEnabled = $config.canSelectRows || $config.canDeleteRows
const select = e => {
e.stopPropagation()
svelteDispatch("select")
const id = row?._id
if (id) {
selectedRows.actions.toggleRow(id)
// Bulk select with shift
if (e.shiftKey) {
// Prevent default if already selected, to prevent checkbox clearing
if (rowSelected) {
e.preventDefault()
} else {
selectedRows.actions.bulkSelectRows(id)
}
} else {
selectedRows.actions.toggleRow(id)
}
}
}
@ -54,16 +62,14 @@
<div
on:click={select}
class="checkbox"
class:visible={selectionEnabled &&
(disableNumber || rowSelected || rowHovered || rowFocused)}
class:visible={disableNumber || rowSelected || rowHovered || rowFocused}
>
<Checkbox value={rowSelected} {disabled} />
</div>
{#if !disableNumber}
<div
class="number"
class:visible={!selectionEnabled ||
!(rowSelected || rowHovered || rowFocused)}
class:visible={!(rowSelected || rowHovered || rowFocused)}
>
{row.__idx + 1}
</div>

View file

@ -18,7 +18,7 @@
isReordering,
isResizing,
sort,
visibleColumns,
scrollableColumns,
dispatch,
subscribe,
config,
@ -51,7 +51,7 @@
$: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $visibleColumns.length - 1
$: canMoveRight = orderable && idx < $scrollableColumns.length - 1
$: sortingLabels = getSortingLabels(column.schema?.type)
$: searchable = isColumnSearchable(column)
$: resetSearchValue(column.name)
@ -270,7 +270,7 @@
on:touchcancel={onMouseUp}
on:contextmenu={onContextMenu}
width={column.width}
left={column.left}
left={column.__left}
defaultHeight
center
>

View file

@ -1,35 +1,120 @@
<script>
import { Modal, ModalContent } from "@budibase/bbui"
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { parseCellID } from "../lib/utils"
import { sleep } from "../../../utils/utils"
const { selectedRows, rows, subscribe, notifications } = getContext("grid")
const {
selectedRows,
rows,
subscribe,
notifications,
menu,
selectedCellCount,
selectedRowCount,
selectedCells,
rowLookupMap,
config,
} = getContext("grid")
const duration = 260
let modal
let rowsModal
let cellsModal
let processing = false
let progressPercentage = 0
let promptQuantity = 0
$: selectedRowCount = Object.values($selectedRows).length
$: rowsToDelete = Object.entries($selectedRows)
.map(entry => $rows.find(x => x._id === entry[0]))
$: rowsToDelete = Object.keys($selectedRows)
.map(rowId => $rowLookupMap[rowId])
.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"}`)
const handleBulkDeleteRequest = () => {
progressPercentage = 0
menu.actions.close()
if ($selectedRowCount && $config.canDeleteRows) {
if ($selectedRowCount === 1) {
bulkDeleteRows()
} else {
promptQuantity = $selectedRowCount
rowsModal?.show()
}
} else if ($selectedCellCount && $config.canEditRows) {
promptQuantity = $selectedCellCount
cellsModal?.show()
}
}
onMount(() => subscribe("request-bulk-delete", () => modal?.show()))
const bulkDeleteRows = async () => {
processing = true
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete)
// This is a real bulk delete endpoint so we don't need progress.
// We just animate it uo to 100 when we're done for consistency with other
// prompts.
progressPercentage = 100
await sleep(duration)
$notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
processing = false
}
const bulkDeleteCells = async () => {
processing = true
let changeMap = {}
for (let row of $selectedCells) {
for (let cellId of row) {
const { rowId, field } = parseCellID(cellId)
if (!changeMap[rowId]) {
changeMap[rowId] = {}
}
changeMap[rowId][field] = null
}
}
await rows.actions.bulkUpdate(changeMap, progress => {
progressPercentage = progress * 100
})
await sleep(duration)
processing = false
}
onMount(() => subscribe("request-bulk-delete", handleBulkDeleteRequest))
</script>
<Modal bind:this={modal}>
<Modal bind:this={rowsModal}>
<ModalContent
title="Delete rows"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performDeletion}
onConfirm={bulkDeleteRows}
size="M"
>
Are you sure you want to delete {selectedRowCount}
row{selectedRowCount === 1 ? "" : "s"}?
Are you sure you want to delete {promptQuantity} rows?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>
<Modal bind:this={cellsModal}>
<ModalContent
title="Delete cells"
confirmText="Continue"
cancelText="Cancel"
onConfirm={bulkDeleteCells}
size="M"
>
Are you sure you want to delete {promptQuantity} cells?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View file

@ -0,0 +1,79 @@
<script>
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { getCellID } from "../lib/utils"
import { sleep } from "../../../utils/utils"
const {
selectedRows,
rows,
subscribe,
selectedRowCount,
visibleColumns,
selectedCells,
rowLookupMap,
} = getContext("grid")
const duration = 260
let modal
let progressPercentage = 0
let processing = false
let promptQuantity = 0
// Deletion callback when confirmed
const performDuplication = async () => {
progressPercentage = 0
processing = true
// duplicate rows
const rowsToDuplicate = Object.keys($selectedRows).map(id => {
return $rowLookupMap[id]
})
const newRows = await rows.actions.bulkDuplicate(
rowsToDuplicate,
progress => {
progressPercentage = progress * 100
}
)
await sleep(duration)
// Select new cells to highlight them
if (newRows.length) {
const firstRow = newRows[0]
const lastRow = newRows[newRows.length - 1]
const firstCol = $visibleColumns[0]
const lastCol = $visibleColumns[$visibleColumns.length - 1]
const startCellId = getCellID(firstRow._id, firstCol.name)
const endCellId = getCellID(lastRow._id, lastCol.name)
selectedCells.actions.selectRange(startCellId, endCellId)
}
processing = false
}
const handleBulkDuplicateRequest = () => {
promptQuantity = $selectedRowCount
modal?.show()
}
onMount(() => subscribe("request-bulk-duplicate", handleBulkDuplicateRequest))
</script>
<Modal bind:this={modal}>
<ModalContent
title="Duplicate rows"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performDuplication}
size="M"
>
Are you sure you want to duplicate {promptQuantity} rows?
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View file

@ -0,0 +1,67 @@
<script>
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { sleep } from "../../../utils/utils"
const { clipboard, subscribe, copyAllowed, pasteAllowed, selectedCellCount } =
getContext("grid")
const duration = 260
let modal
let progressPercentage = 0
let processing = false
const handleCopyRequest = () => {
if (!$copyAllowed) {
return
}
clipboard.actions.copy()
}
const handlePasteRequest = async () => {
progressPercentage = 0
if (!$pasteAllowed) {
return
}
// Prompt if paste will update multiple cells
const multiCellPaste = $selectedCellCount > 1
const prompt = $clipboard.multiCellCopy || multiCellPaste
if (prompt) {
modal?.show()
} else {
clipboard.actions.paste()
}
}
const performBulkPaste = async () => {
processing = true
await clipboard.actions.paste(progress => {
progressPercentage = progress * 100
})
await sleep(duration)
processing = false
}
onMount(() => subscribe("copy", handleCopyRequest))
onMount(() => subscribe("paste", handlePasteRequest))
</script>
<Modal bind:this={modal}>
<ModalContent
title="Confirm paste"
confirmText="Continue"
cancelText="Cancel"
onConfirm={performBulkPaste}
size="M"
>
Are you sure you want to paste? This will update multiple values.
{#if processing}
<ProgressBar
size="L"
value={progressPercentage}
{duration}
width="100%"
/>
{/if}
</ModalContent>
</Modal>

View file

@ -7,14 +7,12 @@
export let allowViewReadonlyColumns = false
const { columns, datasource, stickyColumn, dispatch } = getContext("grid")
const { columns, datasource, dispatch } = getContext("grid")
let open = false
let anchor
$: allColumns = $stickyColumn ? [$stickyColumn, ...$columns] : $columns
$: restrictedColumns = allColumns.filter(col => !col.visible || col.readonly)
$: restrictedColumns = $columns.filter(col => !col.visible || col.readonly)
$: anyRestricted = restrictedColumns.length
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
@ -43,12 +41,9 @@
HIDDEN: "hidden",
}
$: displayColumns = allColumns.map(c => {
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const isDisplayColumn = $stickyColumn === c
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
@ -74,9 +69,9 @@
options.push({
icon: "VisibilityOff",
value: PERMISSION_OPTIONS.HIDDEN,
disabled: isDisplayColumn || isRequired,
disabled: c.primaryDisplay || isRequired,
tooltip:
(isDisplayColumn && "Display column cannot be hidden") ||
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})

View file

@ -8,14 +8,8 @@
SmallRowHeight,
} from "../lib/constants"
const {
stickyColumn,
columns,
rowHeight,
definition,
fixedRowHeight,
datasource,
} = getContext("grid")
const { columns, rowHeight, definition, fixedRowHeight, datasource } =
getContext("grid")
// Some constants for column width options
const smallColSize = 120
@ -42,10 +36,9 @@
let anchor
// Column width sizes
$: allCols = $columns.concat($stickyColumn ? [$stickyColumn] : [])
$: allSmall = allCols.every(col => col.width === smallColSize)
$: allMedium = allCols.every(col => col.width === mediumColSize)
$: allLarge = allCols.every(col => col.width === largeColSize)
$: allSmall = $columns.every(col => col.width === smallColSize)
$: allMedium = $columns.every(col => col.width === mediumColSize)
$: allLarge = $columns.every(col => col.width === largeColSize)
$: custom = !allSmall && !allMedium && !allLarge
$: columnSizeOptions = [
{
@ -80,7 +73,7 @@
size="M"
on:click={() => (open = !open)}
selected={open}
disabled={!allCols.length}
disabled={!$columns.length}
>
Size
</ActionButton>

View file

@ -3,34 +3,20 @@
import { ActionButton, Popover, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/shared-core"
const { sort, columns, stickyColumn } = getContext("grid")
const { sort, columns } = getContext("grid")
let open = false
let anchor
$: columnOptions = getColumnOptions($stickyColumn, $columns)
$: columnOptions = $columns
.map(col => ({
label: col.label || col.name,
value: col.name,
type: col.schema?.type,
}))
.filter(col => canBeSortColumn(col.type))
$: orderOptions = getOrderOptions($sort.column, columnOptions)
const getColumnOptions = (stickyColumn, columns) => {
let options = []
if (stickyColumn) {
options.push({
label: stickyColumn.label || stickyColumn.name,
value: stickyColumn.name,
type: stickyColumn.schema?.type,
})
}
options = [
...options,
...columns.map(col => ({
label: col.label || col.name,
value: col.name,
type: col.schema?.type,
})),
]
return options.filter(col => canBeSortColumn(col.type))
}
const getOrderOptions = (column, columnOptions) => {
const type = columnOptions.find(col => col.value === column)?.type
return [

View file

@ -13,8 +13,8 @@
rows,
focusedRow,
selectedRows,
visibleColumns,
scroll,
scrollableColumns,
scrollLeft,
isDragging,
buttonColumnWidth,
showVScrollbar,
@ -24,12 +24,13 @@
let container
$: buttons = $props.buttons?.slice(0, 3) || []
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)
$: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - $buttonColumnWidth, end)
$: columnEnd = columnsWidth - $scrollLeft - 1
$: gridEnd = $width - $buttonColumnWidth - 1
$: left = Math.min(columnEnd, gridEnd)
const handleClick = async (button, row) => {
await button.onClick?.(rows.actions.cleanRow(row))
@ -40,7 +41,7 @@
onMount(() => {
const observer = new ResizeObserver(entries => {
const width = entries?.[0]?.contentRect?.width ?? 0
buttonColumnWidth.set(width)
buttonColumnWidth.set(Math.floor(width) - 1)
})
observer.observe(container)
})
@ -51,6 +52,7 @@
class="button-column"
style="left:{left}px"
class:hidden={$buttonColumnWidth === 0}
class:right-border={left !== gridEnd}
>
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<GridScrollWrapper scrollVertically attachHandlers bind:ref={container}>
@ -150,4 +152,7 @@
.button-column :global(.cell) {
border-left: var(--cell-border);
}
.button-column:not(.right-border) :global(.cell) {
border-right-color: transparent;
}
</style>

View file

@ -7,6 +7,8 @@
import { createAPIClient } from "../../../api"
import { attachStores } from "../stores"
import BulkDeleteHandler from "../controls/BulkDeleteHandler.svelte"
import BulkDuplicationHandler from "../controls/BulkDuplicationHandler.svelte"
import ClipboardHandler from "../controls/ClipboardHandler.svelte"
import GridBody from "./GridBody.svelte"
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
import ReorderOverlay from "../overlays/ReorderOverlay.svelte"
@ -42,7 +44,6 @@
export let canDeleteRows = true
export let canEditColumns = true
export let canSaveSchema = true
export let canSelectRows = false
export let stripeRows = false
export let quiet = false
export let collaboration = true
@ -99,7 +100,6 @@
canDeleteRows,
canEditColumns,
canSaveSchema,
canSelectRows,
stripeRows,
quiet,
collaboration,
@ -209,9 +209,11 @@
<ProgressCircle />
</div>
{/if}
{#if $config.canDeleteRows}
<BulkDeleteHandler />
{#if $config.canAddRows}
<BulkDuplicationHandler />
{/if}
<BulkDeleteHandler />
<ClipboardHandler />
<KeyboardManager />
</div>

View file

@ -9,7 +9,7 @@
const {
bounds,
renderedRows,
visibleColumns,
scrollableColumns,
hoveredRowId,
dispatch,
isDragging,
@ -19,7 +19,7 @@
let body
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)

View file

@ -10,19 +10,23 @@
focusedCellId,
reorder,
selectedRows,
visibleColumns,
scrollableColumns,
hoveredRowId,
selectedCellMap,
focusedRow,
contentLines,
isDragging,
dispatch,
rows,
columnRenderMap,
userCellMap,
isSelectingCells,
selectedCellMap,
selectedCellCount,
} = getContext("grid")
$: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id
$: rowHovered =
$hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells)
$: rowFocused = $focusedRow?._id === row._id
$: reorderSource = $reorder.sourceColumn
</script>
@ -36,22 +40,24 @@
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
{#each $visibleColumns as column}
{#each $scrollableColumns as column}
{@const cellId = getCellID(row._id, column.name)}
<DataCell
{cellId}
{column}
{row}
{rowFocused}
{rowSelected}
cellSelected={$selectedCellMap[cellId]}
highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected}
rowIdx={row.__idx}
topRow={top}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
selectedUser={$userCellMap[cellId]}
width={column.width}
contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]}
isSelectingCells={$isSelectingCells}
/>
{/each}
</div>

View file

@ -5,7 +5,7 @@
const {
rowHeight,
scroll,
focusedCellId,
ui,
renderedRows,
maxScrollTop,
maxScrollLeft,
@ -13,6 +13,8 @@
hoveredRowId,
menu,
focusedCellAPI,
scrollTop,
scrollLeft,
} = getContext("grid")
export let scrollVertically = false
@ -24,11 +26,11 @@
let initialTouchX
let initialTouchY
$: style = generateStyle($scroll, $rowHeight)
$: style = generateStyle($scrollLeft, $scrollTop, $rowHeight)
const generateStyle = (scroll, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scroll.left : 0
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
const generateStyle = (scrollLeft, scrollTop, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scrollLeft : 0
const offsetY = scrollVertically ? -1 * (scrollTop % rowHeight) : 0
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}
@ -108,7 +110,7 @@
on:wheel={attachHandlers ? handleWheel : null}
on:touchstart={attachHandlers ? handleTouchStart : null}
on:touchmove={attachHandlers ? handleTouchMove : null}
on:click|self={() => ($focusedCellId = null)}
on:click|self={ui.actions.blur}
>
<div {style} class="inner" bind:this={ref}>
<slot />

View file

@ -5,14 +5,14 @@
import HeaderCell from "../cells/HeaderCell.svelte"
import { TempTooltip, TooltipType } from "@budibase/bbui"
const { visibleColumns, config, hasNonAutoColumn, datasource, loading } =
const { scrollableColumns, config, hasNonAutoColumn, datasource, loading } =
getContext("grid")
</script>
<div class="header">
<GridScrollWrapper scrollHorizontally>
<div class="row">
{#each $visibleColumns as column, idx}
{#each $scrollableColumns as column, idx}
<HeaderCell {column} {idx}>
<slot name="edit-column" />
</HeaderCell>

View file

@ -3,17 +3,23 @@
import { Icon } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
const { visibleColumns, scroll, width, subscribe, ui, keyboardBlocked } =
getContext("grid")
const {
scrollableColumns,
scrollLeft,
width,
subscribe,
ui,
keyboardBlocked,
} = getContext("grid")
let anchor
let isOpen = false
$: columnsWidth = $visibleColumns.reduce(
$: columnsWidth = $scrollableColumns.reduce(
(total, col) => (total += col.width),
0
)
$: end = columnsWidth - 1 - $scroll.left
$: end = columnsWidth - 1 - $scrollLeft
$: left = Math.min($width - 40, end)
$: keyboardBlocked.set(isOpen)
@ -43,7 +49,7 @@
{#if isOpen}
<GridPopover
{anchor}
align={$visibleColumns.length ? "right" : "left"}
align={$scrollableColumns.length ? "right" : "left"}
on:close={close}
maxHeight={null}
resizable

View file

@ -12,7 +12,7 @@
const {
hoveredRowId,
focusedCellId,
stickyColumn,
displayColumn,
scroll,
dispatch,
rows,
@ -20,7 +20,7 @@
datasource,
subscribe,
renderedRows,
visibleColumns,
scrollableColumns,
rowHeight,
hasNextPage,
maxScrollTop,
@ -31,6 +31,7 @@
filter,
inlineFilters,
columnRenderMap,
visibleColumns,
scrollTop,
} = getContext("grid")
@ -39,8 +40,8 @@
let newRow
let offset = 0
$: firstColumn = $stickyColumn || $visibleColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: firstColumn = $visibleColumns[0]
$: width = GutterWidth + ($displayColumn?.width || 0)
$: $datasource, (visible = false)
$: selectedRowCount = Object.values($selectedRows).length
$: hasNoRows = !$rows.length
@ -70,7 +71,10 @@
const newRowIndex = offset ? undefined : 0
let rowToCreate = { ...newRow }
delete rowToCreate._isNewRow
const savedRow = await rows.actions.addRow(rowToCreate, newRowIndex)
const savedRow = await rows.actions.addRow({
row: rowToCreate,
idx: newRowIndex,
})
if (savedRow) {
// Reset state
clear()
@ -167,7 +171,7 @@
class="new-row-fab"
on:click={() => dispatch("add-row-inline")}
transition:fade|local={{ duration: 130 }}
class:offset={!$stickyColumn}
class:offset={!$displayColumn}
>
<Icon name="Add" size="S" />
</div>
@ -191,19 +195,19 @@
<div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if}
</GutterCell>
{#if $stickyColumn}
{@const cellId = getCellID(NewRowID, $stickyColumn.name)}
{#if $displayColumn}
{@const cellId = getCellID(NewRowID, $displayColumn.name)}
<DataCell
{cellId}
rowFocused
column={$stickyColumn}
column={$displayColumn}
row={newRow}
focused={$focusedCellId === cellId}
width={$stickyColumn.width}
width={$displayColumn.width}
{updateValue}
topRow={offset === 0}
>
{#if $stickyColumn?.schema?.autocolumn}
{#if $displayColumn?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div>
{/if}
{#if isAdding}
@ -216,7 +220,7 @@
<div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row">
{#each $visibleColumns as column}
{#each $scrollableColumns as column}
{@const cellId = getCellID(NewRowID, column.name)}
<DataCell
{cellId}

View file

@ -13,22 +13,25 @@
const {
rows,
selectedRows,
stickyColumn,
displayColumn,
renderedRows,
focusedCellId,
hoveredRowId,
config,
selectedCellMap,
userCellMap,
focusedRow,
scrollLeft,
dispatch,
contentLines,
isDragging,
isSelectingCells,
selectedCellCount,
} = getContext("grid")
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).length
$: width = GutterWidth + ($stickyColumn?.width || 0)
$: width = GutterWidth + ($displayColumn?.width || 0)
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
@ -57,8 +60,8 @@
rowSelected={selectedRowCount && selectedRowCount === rowCount}
disabled={!$renderedRows.length}
/>
{#if $stickyColumn}
<HeaderCell column={$stickyColumn} orderable={false} idx="sticky">
{#if $displayColumn}
<HeaderCell column={$displayColumn} orderable={false} idx="sticky">
<slot name="edit-column" />
</HeaderCell>
{/if}
@ -69,9 +72,11 @@
<GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const rowHovered =
$hoveredRowId === row._id &&
(!$selectedCellCount || !$isSelectingCells)}
{@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = getCellID(row._id, $stickyColumn?.name)}
{@const cellId = getCellID(row._id, $displayColumn?.name)}
<div
class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
@ -79,20 +84,22 @@
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn}
{#if $displayColumn}
<DataCell
{row}
{cellId}
{rowFocused}
selected={rowSelected}
{rowSelected}
cellSelected={$selectedCellMap[cellId]}
highlighted={rowHovered || rowFocused}
rowIdx={row.__idx}
topRow={idx === 0}
focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
width={$stickyColumn.width}
column={$stickyColumn}
selectedUser={$userCellMap[cellId]}
width={$displayColumn.width}
column={$displayColumn}
contentLines={$contentLines}
isSelectingCells={$isSelectingCells}
/>
{/if}
</div>
@ -107,9 +114,9 @@
<GutterCell rowHovered={$hoveredRowId === BlankRowID}>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
</GutterCell>
{#if $stickyColumn}
{#if $displayColumn}
<GridCell
width={$stickyColumn.width}
width={$displayColumn.width}
highlighted={$hoveredRowId === BlankRowID}
>
<KeyboardShortcut padded keybind="Ctrl+Enter" />

View file

@ -1,17 +1,17 @@
import { helpers } from "@budibase/shared-core"
import { TypeIconMap } from "../../../constants"
// we can't use "-" for joining the ID/field, as this can be present in the ID or column name
// using something very unusual to avoid this problem
// We can't use "-" as a separator as this can be present in the ID
// or column name, so we use something very unusual to avoid this problem
const JOINING_CHARACTER = "‽‽"
export const parseCellID = cellId => {
if (!cellId) {
return { id: undefined, field: undefined }
return { rowId: undefined, field: undefined }
}
const parts = cellId.split(JOINING_CHARACTER)
const field = parts.pop()
return { id: parts.join(JOINING_CHARACTER), field }
return { rowId: parts.join(JOINING_CHARACTER), field }
}
export const getCellID = (rowId, fieldName) => {

View file

@ -1,23 +1,26 @@
<script>
import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils"
import { NewRowID } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { NewRowID } from "../lib/constants"
const {
rows,
focusedCellId,
visibleColumns,
focusedRow,
stickyColumn,
rowLookupMap,
focusedCellAPI,
clipboard,
dispatch,
selectedRows,
selectedRowCount,
config,
menu,
gridFocused,
keyboardBlocked,
selectedCellCount,
selectedCells,
cellSelection,
columnLookupMap,
focusedRowId,
} = getContext("grid")
const ignoredOriginSelectors = [
@ -43,23 +46,51 @@
}
}
// Handle certain key presses regardless of selection state
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && $config.canAddRows) {
// Sugar for preventing default
const handle = fn => {
e.preventDefault()
dispatch("add-row-inline")
return
fn()
}
// If nothing selected avoid processing further key presses
// Handle certain key presses regardless of selection state
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "c":
return handle(() => dispatch("copy"))
case "v":
return handle(() => dispatch("paste"))
case "Enter":
return handle(() => {
if ($config.canAddRows) {
dispatch("add-row-inline")
}
})
}
}
// Handle certain key presses if we have cells selected
if ($selectedCellCount) {
switch (e.key) {
case "Escape":
return handle(selectedCells.actions.clear)
case "Delete":
case "Backspace":
return handle(() => dispatch("request-bulk-delete"))
}
}
// Handle certain key presses only if no cell focused
if (!$focusedCellId) {
if (e.key === "Tab" || e.key?.startsWith("Arrow")) {
e.preventDefault()
focusFirstCell()
handle(focusFirstCell)
} else if (e.key === "Delete" || e.key === "Backspace") {
if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete")
}
handle(() => {
if ($selectedRowCount && $config.canDeleteRows) {
dispatch("request-bulk-delete")
}
})
}
// Avoid processing anything else
return
}
@ -69,18 +100,19 @@
// By setting a tiny timeout here we can ensure that other listeners
// which depend on being able to read cell state on an escape keypress
// get a chance to observe the true state before we blur
if (api?.isActive()) {
setTimeout(api?.blur, 10)
} else {
$focusedCellId = null
}
menu.actions.close()
return
return handle(() => {
if (api?.isActive()) {
setTimeout(api?.blur, 10)
} else {
$focusedCellId = null
}
menu.actions.close()
})
} else if (e.key === "Tab") {
e.preventDefault()
api?.blur?.()
changeFocusedColumn(1)
return
return handle(() => {
api?.blur?.()
changeFocusedColumn(1)
})
}
// Pass the key event to the selected cell and let it decide whether to
@ -91,57 +123,33 @@
return
}
}
e.preventDefault()
// Handle the key ourselves
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "c":
clipboard.actions.copy()
break
case "v":
if (!api?.isReadonly()) {
clipboard.actions.paste()
}
break
case "Enter":
if ($config.canAddRows) {
dispatch("add-row-inline")
}
}
//
} else {
switch (e.key) {
case "ArrowLeft":
changeFocusedColumn(-1)
break
return handle(() => changeFocusedColumn(-1, e.shiftKey))
case "ArrowRight":
changeFocusedColumn(1)
break
return handle(() => changeFocusedColumn(1, e.shiftKey))
case "ArrowUp":
changeFocusedRow(-1)
break
return handle(() => changeFocusedRow(-1, e.shiftKey))
case "ArrowDown":
changeFocusedRow(1)
break
return handle(() => changeFocusedRow(1, e.shiftKey))
case "Delete":
case "Backspace":
if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete")
} else {
deleteSelectedCell()
}
break
return handle(() => {
if ($selectedRowCount && $config.canDeleteRows) {
dispatch("request-bulk-delete")
} else {
deleteSelectedCell()
}
})
case "Enter":
focusCell()
break
case " ":
case "Space":
if ($config.canDeleteRows) {
toggleSelectRow()
}
break
return handle(focusCell)
default:
startEnteringValue(e.key, e.which)
return handle(() => startEnteringValue(e.key, e.which))
}
}
}
@ -152,7 +160,7 @@
if (!firstRow) {
return
}
const firstColumn = $stickyColumn || $visibleColumns[0]
const firstColumn = $visibleColumns[0]
if (!firstColumn) {
return
}
@ -160,38 +168,87 @@
}
// Changes the focused cell by moving it left or right to a different column
const changeFocusedColumn = delta => {
if (!$focusedCellId) {
const changeFocusedColumn = (delta, shiftKey) => {
// Determine which cell we are working with
let sourceCellId = $focusedCellId
if (shiftKey && $selectedCellCount) {
sourceCellId = $cellSelection.targetCellId
}
if (!sourceCellId) {
return
}
const cols = $visibleColumns
const { id, field: columnName } = parseCellID($focusedCellId)
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
}
// Determine the new position for this cell
const { rowId, field } = parseCellID(sourceCellId)
const colIdx = $columnLookupMap[field].__idx
const nextColumn = $visibleColumns[colIdx + delta]
if (!nextColumn) {
return
}
if (newColumnName) {
$focusedCellId = getCellID(id, newColumnName)
const targetCellId = getCellID(rowId, nextColumn.name)
// Apply change
if (shiftKey) {
if ($selectedCellCount) {
// We have selected cells and still are holding shift - update selection
selectedCells.actions.updateTarget(targetCellId)
// Restore focused cell if this removes the selection
if (!$selectedCellCount) {
focusedCellId.set(targetCellId)
}
} else {
// We have no selection but are holding shift - select these cells
selectedCells.actions.selectRange(sourceCellId, targetCellId)
}
} else {
// We aren't holding shift - just focus this cell
focusedCellId.set(targetCellId)
}
}
// Changes the focused cell by moving it up or down to a new row
const changeFocusedRow = delta => {
if (!$focusedRow) {
const changeFocusedRow = (delta, shiftKey) => {
// Ignore for new row component
if ($focusedRowId === NewRowID) {
return
}
const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) {
const { field } = parseCellID($focusedCellId)
$focusedCellId = getCellID(newRow._id, field)
// Determine which cell we are working with
let sourceCellId = $focusedCellId
if (shiftKey && $selectedCellCount) {
sourceCellId = $cellSelection.targetCellId
}
if (!sourceCellId) {
return
}
// Determine the new position for this cell
const { rowId, field } = parseCellID(sourceCellId)
const rowIdx = $rowLookupMap[rowId].__idx
const newRow = $rows[rowIdx + delta]
if (!newRow) {
return
}
const targetCellId = getCellID(newRow._id, field)
// Apply change
if (shiftKey) {
if ($selectedCellCount) {
// We have selected cells and still are holding shift - update selection
selectedCells.actions.updateTarget(targetCellId)
// Restore focused cell if this removes the selection
if (!$selectedCellCount) {
focusedCellId.set(targetCellId)
}
} else {
// We have no selection but are holding shift - select these cells
selectedCells.actions.selectRange(sourceCellId, targetCellId)
}
} else {
// We aren't holding shift - just focus this cell
focusedCellId.set(targetCellId)
}
}
@ -234,14 +291,6 @@
}
}
const toggleSelectRow = () => {
const id = $focusedRow?._id
if (!id || id === NewRowID) {
return
}
selectedRows.actions.toggleRow(id)
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
return () => {

View file

@ -9,17 +9,17 @@
focusedRow,
menu,
rows,
columns,
focusedCellId,
stickyColumn,
config,
copiedCell,
clipboard,
dispatch,
focusedCellAPI,
focusedRowId,
notifications,
hasBudibaseIdentifiers,
selectedRowCount,
copyAllowed,
pasteAllowed,
selectedCellCount,
visibleColumns,
selectedCells,
} = getContext("grid")
let anchor
@ -32,17 +32,20 @@
}
const deleteRow = () => {
rows.actions.deleteRows([$focusedRow])
menu.actions.close()
rows.actions.deleteRows([$focusedRow])
$notifications.success("Deleted 1 row")
}
const duplicate = async () => {
const duplicateRow = async () => {
menu.actions.close()
const newRow = await rows.actions.duplicateRow($focusedRow)
if (newRow) {
const column = $stickyColumn?.name || $columns[0].name
$focusedCellId = getCellID(newRow._id, column)
const firstCol = $visibleColumns[0]
const lastCol = $visibleColumns[$visibleColumns.length - 1]
const startCellId = getCellID(newRow._id, firstCol.name)
const endCellId = getCellID(newRow._id, lastCol.name)
selectedCells.actions.selectRange(startCellId, endCellId)
}
}
@ -58,59 +61,107 @@
{#key style}
<GridPopover {anchor} on:close={menu.actions.close} maxHeight={null}>
<Menu>
<MenuItem
icon="Copy"
on:click={clipboard.actions.copy}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()}
on:click={clipboard.actions.paste}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Maximize"
disabled={isNewRow || !$config.canEditRows || !$config.canExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._id || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close}
>
Copy row _id
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._rev || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close}
>
Copy row _rev
</MenuItem>
<MenuItem
icon="Duplicate"
disabled={isNewRow || !$config.canAddRows}
on:click={duplicate}
>
Duplicate row
</MenuItem>
<MenuItem
icon="Delete"
disabled={isNewRow || !$config.canDeleteRows}
on:click={deleteRow}
>
Delete row
</MenuItem>
{#if $menu.multiRowMode}
<MenuItem
icon="Duplicate"
disabled={!$config.canAddRows || $selectedRowCount > 50}
on:click={() => dispatch("request-bulk-duplicate")}
on:click={menu.actions.close}
>
Duplicate {$selectedRowCount} rows
</MenuItem>
<MenuItem
icon="Delete"
disabled={!$config.canDeleteRows}
on:click={() => dispatch("request-bulk-delete")}
on:click={menu.actions.close}
>
Delete {$selectedRowCount} rows
</MenuItem>
{:else if $menu.multiCellMode}
<MenuItem
icon="Copy"
disabled={!$copyAllowed}
on:click={() => dispatch("copy")}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={!$pasteAllowed}
on:click={() => dispatch("paste")}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Delete"
disabled={!$config.canEditRows}
on:click={() => dispatch("request-bulk-delete")}
>
Delete {$selectedCellCount} cells
</MenuItem>
{:else}
<MenuItem
icon="Copy"
disabled={!$copyAllowed}
on:click={() => dispatch("copy")}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={!$pasteAllowed}
on:click={() => dispatch("paste")}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem
icon="Maximize"
disabled={isNewRow ||
!$config.canEditRows ||
!$config.canExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._id || !$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close}
>
Copy row _id
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow ||
!$focusedRow?._rev ||
!$hasBudibaseIdentifiers}
on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close}
>
Copy row _rev
</MenuItem>
<MenuItem
icon="Duplicate"
disabled={isNewRow || !$config.canAddRows}
on:click={duplicateRow}
>
Duplicate row
</MenuItem>
<MenuItem
icon="Delete"
disabled={isNewRow || !$config.canDeleteRows}
on:click={deleteRow}
>
Delete row
</MenuItem>
{/if}
</Menu>
</GridPopover>
{/key}

View file

@ -1,37 +1,33 @@
<script>
import { getContext } from "svelte"
import GridScrollWrapper from "../layout/GridScrollWrapper.svelte"
import { DefaultRowHeight, GutterWidth } from "../lib/constants"
import { DefaultRowHeight } from "../lib/constants"
const {
isReordering,
reorder,
visibleColumns,
stickyColumn,
columnLookupMap,
rowHeight,
renderedRows,
scrollLeft,
stickyWidth,
} = getContext("grid")
$: targetColumn = $reorder.targetColumn
$: minLeft = GutterWidth + ($stickyColumn?.width || 0)
$: left = getLeft(targetColumn, $stickyColumn, $visibleColumns, $scrollLeft)
$: targetColumn = $columnLookupMap[$reorder.targetColumn]
$: insertAfter = $reorder.insertAfter
$: left = getLeft(targetColumn, insertAfter, $scrollLeft)
$: height = $rowHeight * $renderedRows.length + DefaultRowHeight
$: style = `left:${left}px; height:${height}px;`
$: visible = $isReordering && left >= minLeft
$: visible = $isReordering && left >= $stickyWidth
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
const getLeft = (targetColumn, insertAfter, scrollLeft) => {
if (!targetColumn) {
return 0
}
let left = targetColumn.__left - scrollLeft
if (insertAfter) {
left += targetColumn.width
}
return left
}
</script>

View file

@ -1,41 +1,28 @@
<script>
import { getContext } from "svelte"
import { GutterWidth } from "../lib/constants"
const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } =
const { resize, visibleColumns, isReordering, scrollLeft } =
getContext("grid")
$: offset = GutterWidth + ($stickyColumn?.width || 0)
$: activeColumn = $resize.column
const getStyle = (column, offset, scrollLeft) => {
const left = offset + column.left + column.width - scrollLeft
const getStyle = (column, scrollLeft) => {
let left = column.__left + column.width
if (!column.primaryDisplay) {
left -= scrollLeft
}
return `left:${left}px;`
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !$isReordering}
{#if $stickyColumn}
<div
class="resize-slider"
class:visible={activeColumn === $stickyColumn.name}
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
on:touchstart={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 $visibleColumns as column}
<div
class="resize-slider"
class:visible={activeColumn === column.name}
class:visible={$resize.column === column.name}
on:mousedown={e => resize.actions.startResizing(column, e)}
on:touchstart={e => resize.actions.startResizing(column, e)}
on:dblclick={() => resize.actions.resetSize(column)}
style={getStyle(column, offset, $scrollLeft)}
style={getStyle(column, $scrollLeft)}
>
<div class="resize-indicator" />
</div>

View file

@ -1,40 +1,237 @@
import { writable, get } from "svelte/store"
import { derived, writable, get } from "svelte/store"
import { Helpers } from "@budibase/bbui"
import { parseCellID, getCellID } from "../lib/utils"
import { NewRowID } from "../lib/constants"
export const createStores = () => {
const copiedCell = writable(null)
const clipboard = writable({
value: null,
multiCellCopy: false,
})
return {
clipboard,
}
}
export const deriveStores = context => {
const { clipboard, focusedCellAPI, selectedCellCount, config, focusedRowId } =
context
// Derive whether or not we're able to copy
const copyAllowed = derived(focusedCellAPI, $focusedCellAPI => {
return $focusedCellAPI != null
})
// Derive whether or not we're able to paste
const pasteAllowed = derived(
[clipboard, focusedCellAPI, selectedCellCount, config, focusedRowId],
([
$clipboard,
$focusedCellAPI,
$selectedCellCount,
$config,
$focusedRowId,
]) => {
if (
$clipboard.value == null ||
!$config.canEditRows ||
!$focusedCellAPI ||
$focusedRowId === NewRowID
) {
return false
}
// Prevent single-single pasting if the cell is readonly
const multiCellPaste = $selectedCellCount > 1
if (
!$clipboard.multiCellCopy &&
!multiCellPaste &&
$focusedCellAPI.isReadonly()
) {
return false
}
return true
}
)
return {
copiedCell,
copyAllowed,
pasteAllowed,
}
}
export const createActions = context => {
const { copiedCell, focusedCellAPI } = context
const {
clipboard,
focusedCellAPI,
copyAllowed,
pasteAllowed,
selectedCells,
selectedCellCount,
rowLookupMap,
rowChangeCache,
rows,
focusedCellId,
columnLookupMap,
visibleColumns,
} = context
// Copies the currently selected value (or values)
const copy = () => {
const value = get(focusedCellAPI)?.getValue()
copiedCell.set(value)
// Also copy a stringified version to the clipboard
let stringified = ""
if (value != null && value !== "") {
// Only conditionally stringify to avoid redundant quotes around text
stringified = typeof value === "object" ? JSON.stringify(value) : value
if (!get(copyAllowed)) {
return
}
const $selectedCells = get(selectedCells)
const $focusedCellAPI = get(focusedCellAPI)
const $selectedCellCount = get(selectedCellCount)
const multiCellCopy = $selectedCellCount > 1
// Multiple values to copy
if (multiCellCopy) {
const $rowLookupMap = get(rowLookupMap)
const $rowChangeCache = get(rowChangeCache)
// Extract value of each selected cell, accounting for the change cache
let value = []
for (let row of $selectedCells) {
const rowValues = []
for (let cellId of row) {
const { rowId, field } = parseCellID(cellId)
const row = {
...$rowLookupMap[rowId],
...$rowChangeCache[rowId],
}
rowValues.push(row[field])
}
value.push(rowValues)
}
// Update state
clipboard.set({
value,
multiCellCopy: true,
})
} else {
// Single value to copy
const value = $focusedCellAPI.getValue()
clipboard.set({
value,
multiCellCopy,
})
// Also copy a stringified version to the clipboard
let stringified = ""
if (value != null && value !== "") {
// Only conditionally stringify to avoid redundant quotes around text
stringified = typeof value === "object" ? JSON.stringify(value) : value
}
Helpers.copyToClipboard(stringified)
}
Helpers.copyToClipboard(stringified)
}
const paste = () => {
const $copiedCell = get(copiedCell)
const $focusedCellAPI = get(focusedCellAPI)
if ($copiedCell != null && $focusedCellAPI) {
$focusedCellAPI.setValue($copiedCell)
// Pastes the previously copied value(s) into the selected cell(s)
const paste = async progressCallback => {
if (!get(pasteAllowed)) {
return
}
const { value, multiCellCopy } = get(clipboard)
const multiCellPaste = get(selectedCellCount) > 1
// Choose paste strategy
if (multiCellCopy) {
if (multiCellPaste) {
// Multi to multi - try pasting into all selected cells
let newValue = value
// If we are pasting into more rows than we copied, but the number of
// columns match, then repeat the copied values as required
const $selectedCells = get(selectedCells)
const selectedRows = $selectedCells.length
const selectedColumns = $selectedCells[0].length
const copiedRows = value.length
const copiedColumns = value[0].length
if (selectedRows > copiedRows && selectedColumns === copiedColumns) {
newValue = []
for (let i = 0; i < selectedRows; i++) {
newValue.push(value[i % copiedRows])
}
}
// Paste the new value
await pasteIntoSelectedCells(newValue, progressCallback)
} else {
// Multi to single - expand to paste all values
// Get indices of focused cell
const $focusedCellId = get(focusedCellId)
const { rowId, field } = parseCellID($focusedCellId)
const $rowLookupMap = get(rowLookupMap)
const $columnLookupMap = get(columnLookupMap)
const rowIdx = $rowLookupMap[rowId].__idx
const colIdx = $columnLookupMap[field].__idx
// Get limits of how many rows and columns we're able to paste into
const $rows = get(rows)
const $visibleColumns = get(visibleColumns)
const colCount = $visibleColumns.length
const rowCount = $rows.length
const selectedRows = value.length
const selectedColumns = value[0].length
const rowExtent = Math.min(selectedRows, rowCount - rowIdx) - 1
const colExtent = Math.min(selectedColumns, colCount - colIdx) - 1
// Get the target cell ID (bottom right of our pastable extent)
const targetRowId = $rows[rowIdx + rowExtent]._id
const targetColName = $visibleColumns[colIdx + colExtent].name
const targetCellId = getCellID(targetRowId, targetColName)
// Paste into target cell range
if (targetCellId === $focusedCellId) {
// Single cell edge case
get(focusedCellAPI).setValue(value[0][0])
} else {
// Select the new cells to paste into, then paste
selectedCells.actions.selectRange($focusedCellId, targetCellId)
await pasteIntoSelectedCells(value, progressCallback)
}
}
} else {
if (multiCellPaste) {
// Single to multi - duplicate value to all selected cells
const newValue = get(selectedCells).map(row => row.map(() => value))
await pasteIntoSelectedCells(newValue, progressCallback)
} else {
// Single to single - just update the cell's value
get(focusedCellAPI).setValue(value)
}
}
}
// Paste the specified value into the currently selected cells
const pasteIntoSelectedCells = async (value, progressCallback) => {
const $selectedCells = get(selectedCells)
// Find the extent at which we can paste
const rowExtent = Math.min(value.length, $selectedCells.length)
const colExtent = Math.min(value[0].length, $selectedCells[0].length)
// Build change map
let changeMap = {}
for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) {
for (let colIdx = 0; colIdx < colExtent; colIdx++) {
const cellId = $selectedCells[rowIdx][colIdx]
const { rowId, field } = parseCellID(cellId)
if (!changeMap[rowId]) {
changeMap[rowId] = {}
}
changeMap[rowId][field] = value[rowIdx][colIdx]
}
}
await rows.actions.bulkUpdate(changeMap, progressCallback)
}
return {
clipboard: {
...clipboard,
actions: {
copy,
paste,

View file

@ -1,74 +1,73 @@
import { derived, get, writable } from "svelte/store"
import { GutterWidth, DefaultColumnWidth } from "../lib/constants"
import { DefaultColumnWidth, GutterWidth } 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)
},
[]
)
// Enrich columns with metadata about their display position
const enrichedColumns = derived(columns, $columns => {
let offset = GutterWidth
let idx = 0
return $columns.map(col => {
const enriched = {
...col,
__idx: idx,
__left: offset,
}
if (col.visible) {
idx++
offset += col.width
}
return enriched
})
})
return {
columns: {
...columns,
subscribe: enrichedColumns.subscribe,
},
stickyColumn,
visibleColumns,
}
}
export const deriveStores = context => {
const { columns, stickyColumn } = context
const { columns } = context
// Quick access to all columns
const allColumns = derived(
[columns, stickyColumn],
([$columns, $stickyColumn]) => {
let allCols = $columns || []
if ($stickyColumn) {
allCols = [...allCols, $stickyColumn]
}
return allCols
}
)
// Derive a lookup map for all columns by name
const columnLookupMap = derived(columns, $columns => {
let map = {}
$columns.forEach(column => {
map[column.name] = column
})
return map
})
// Derived list of columns which have not been explicitly hidden
const visibleColumns = derived(columns, $columns => {
return $columns.filter(col => col.visible)
})
// Split visible columns into their discrete types
const displayColumn = derived(visibleColumns, $visibleColumns => {
return $visibleColumns.find(col => col.primaryDisplay)
})
const scrollableColumns = derived(visibleColumns, $visibleColumns => {
return $visibleColumns.filter(col => !col.primaryDisplay)
})
// Derive if we have any normal columns
const hasNonAutoColumn = derived(allColumns, $allColumns => {
const normalCols = $allColumns.filter(column => {
const hasNonAutoColumn = derived(columns, $columns => {
const normalCols = $columns.filter(column => {
return !column.schema?.autocolumn
})
return normalCols.length > 0
})
return {
allColumns,
displayColumn,
columnLookupMap,
visibleColumns,
scrollableColumns,
hasNonAutoColumn,
}
}
@ -87,60 +86,57 @@ export const createActions = context => {
await datasource.actions.saveSchemaMutations()
}
// Checks if a column is readonly
const isReadonly = column => {
if (!column?.schema) {
return false
}
return (
column.schema.autocolumn ||
column.schema.disabled ||
column.schema.type === "formula" ||
column.schema.readonly
)
}
return {
columns: {
...columns,
actions: {
changeAllColumnWidths,
isReadonly,
},
},
}
}
export const initialise = context => {
const {
definition,
columns,
stickyColumn,
allColumns,
enrichedSchema,
compact,
} = context
const { definition, columns, displayColumn, enrichedSchema } = context
// Merge new schema fields with existing schema in order to preserve widths
const processColumns = $enrichedSchema => {
if (!$enrichedSchema) {
columns.set([])
stickyColumn.set(null)
return
}
const $definition = get(definition)
const $allColumns = get(allColumns)
const $stickyColumn = get(stickyColumn)
const $compact = get(compact)
const $columns = get(columns)
const $displayColumn = get(displayColumn)
// Find primary display
let primaryDisplay
const candidatePD = $definition.primaryDisplay || $stickyColumn?.name
const candidatePD = $definition.primaryDisplay || $displayColumn?.name
if (candidatePD && $enrichedSchema[candidatePD]) {
primaryDisplay = candidatePD
}
// Get field list
let fields = []
Object.keys($enrichedSchema).forEach(field => {
if ($compact || field !== primaryDisplay) {
fields.push(field)
}
})
// Update columns, removing extraneous columns and adding missing ones
columns.set(
fields
Object.keys($enrichedSchema)
.map(field => {
const fieldSchema = $enrichedSchema[field]
const oldColumn = $allColumns?.find(x => x.name === field)
return {
const oldColumn = $columns?.find(col => col.name === field)
let column = {
name: field,
label: fieldSchema.displayName || field,
schema: fieldSchema,
@ -148,19 +144,24 @@ export const initialise = context => {
visible: fieldSchema.visible ?? true,
readonly: fieldSchema.readonly,
order: fieldSchema.order ?? oldColumn?.order,
primaryDisplay: field === primaryDisplay,
}
// Override a few properties for primary display
if (field === primaryDisplay) {
column.visible = true
column.order = 0
column.primaryDisplay = true
}
return column
})
.sort((a, b) => {
// If we don't have a pinned column then primary display will be in
// the normal columns list, and should be first
// Display column should always come first
if (a.name === primaryDisplay) {
return -1
} else if (b.name === primaryDisplay) {
return 1
}
// Sort by order first
// Then sort by order
const orderA = a.order
const orderB = b.order
if (orderA != null && orderB != null) {
@ -180,29 +181,8 @@ export const initialise = context => {
return autoColA ? 1 : -1
})
)
// Update sticky column
if ($compact || !primaryDisplay) {
stickyColumn.set(null)
return
}
const stickySchema = $enrichedSchema[primaryDisplay]
const oldStickyColumn = $allColumns?.find(x => x.name === primaryDisplay)
stickyColumn.set({
name: primaryDisplay,
label: stickySchema.displayName || primaryDisplay,
schema: stickySchema,
width: stickySchema.width || oldStickyColumn?.width || DefaultColumnWidth,
visible: true,
order: 0,
left: GutterWidth,
primaryDisplay: true,
})
}
// Process columns when schema changes
enrichedSchema.subscribe(processColumns)
// Process columns when compact flag changes
compact.subscribe(() => processColumns(get(enrichedSchema)))
}

View file

@ -1,7 +1,7 @@
import { get } from "svelte/store"
export const createActions = context => {
const { columns, stickyColumn, table, viewV2 } = context
const { columns, table, viewV2 } = context
const saveDefinition = async () => {
throw "This datasource does not support updating the definition"
@ -30,9 +30,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return $columns.some(col => col.name === name) || $sticky?.name === name
return get(columns).some(col => col.name === name)
}
return {

View file

@ -3,14 +3,17 @@ import { get } from "svelte/store"
const SuppressErrors = true
export const createActions = context => {
const { API, datasource, columns, stickyColumn } = context
const { API, datasource, columns } = context
const saveDefinition = async newDefinition => {
await API.saveTable(newDefinition)
}
const saveRow = async row => {
row.tableId = get(datasource)?.tableId
row = {
...row,
tableId: get(datasource)?.tableId,
}
return await API.saveRow(row, SuppressErrors)
}
@ -40,9 +43,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return $columns.some(col => col.name === name) || $sticky?.name === name
return get(columns).some(col => col.name === name)
}
return {

View file

@ -3,7 +3,7 @@ import { get } from "svelte/store"
const SuppressErrors = true
export const createActions = context => {
const { API, datasource, columns, stickyColumn } = context
const { API, datasource, columns } = context
const saveDefinition = async newDefinition => {
await API.viewV2.update(newDefinition)
@ -11,8 +11,11 @@ export const createActions = context => {
const saveRow = async row => {
const $datasource = get(datasource)
row.tableId = $datasource?.tableId
row._viewId = $datasource?.id
row = {
...row,
tableId: $datasource?.tableId,
_viewId: $datasource?.id,
}
return {
...(await API.saveRow(row, SuppressErrors)),
_viewId: row._viewId,
@ -37,12 +40,7 @@ export const createActions = context => {
}
const canUseColumn = name => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return (
$columns.some(col => col.name === name && col.visible) ||
$sticky?.name === name
)
return get(columns).some(col => col.name === name && col.visible)
}
return {

View file

@ -25,23 +25,23 @@ const DependencyOrderedStores = [
Sort,
Filter,
Bounds,
Scroll,
Table,
ViewV2,
NonPlus,
Datasource,
Columns,
Scroll,
Validation,
Rows,
UI,
Validation,
Resize,
Viewport,
Reorder,
Users,
Menu,
Pagination,
Clipboard,
Config,
Clipboard,
Notifications,
Cache,
]

View file

@ -1,11 +1,13 @@
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import { parseCellID } from "../lib/utils"
export const createStores = () => {
const menu = writable({
x: 0,
y: 0,
left: 0,
top: 0,
visible: false,
selectedRow: null,
multiRowMode: false,
multiCellMode: false,
})
return {
menu,
@ -13,7 +15,15 @@ export const createStores = () => {
}
export const createActions = context => {
const { menu, focusedCellId, gridID } = context
const {
menu,
focusedCellId,
gridID,
selectedRows,
selectedRowCount,
selectedCellMap,
selectedCellCount,
} = context
const open = (cellId, e) => {
e.preventDefault()
@ -29,11 +39,35 @@ export const createActions = context => {
// Compute bounds of cell relative to outer data node
const targetBounds = e.target.getBoundingClientRect()
const dataBounds = dataNode.getBoundingClientRect()
focusedCellId.set(cellId)
// Check if there are multiple rows selected, and if this is one of them
let multiRowMode = false
if (get(selectedRowCount) > 1) {
const { rowId } = parseCellID(cellId)
if (get(selectedRows)[rowId]) {
multiRowMode = true
}
}
// Check if there are multiple cells selected, and if this is one of them
let multiCellMode = false
if (!multiRowMode && get(selectedCellCount) > 1) {
if (get(selectedCellMap)[cellId]) {
multiCellMode = true
}
}
// Only focus this cell if not in multi row mode
if (!multiRowMode && !multiCellMode) {
focusedCellId.set(cellId)
}
menu.set({
left: targetBounds.left - dataBounds.left + e.offsetX,
top: targetBounds.top - dataBounds.top + e.offsetY,
visible: true,
multiRowMode,
multiCellMode,
})
}

View file

@ -1,4 +1,4 @@
import { derived, get } from "svelte/store"
import { derived } from "svelte/store"
export const initialise = context => {
const { scrolledRowCount, rows, visualRowCapacity } = context
@ -15,8 +15,14 @@ export const initialise = context => {
)
// Fetch next page when fewer than 25 remaining rows to scroll
remainingRows.subscribe(remaining => {
if (remaining < 25 && get(rowCount)) {
const needsNewPage = derived(
[remainingRows, rowCount],
([$remainingRows, $rowCount]) => {
return $remainingRows < 25 && $rowCount
}
)
needsNewPage.subscribe($needsNewPage => {
if ($needsNewPage) {
rows.actions.loadNextPage()
}
})

View file

@ -4,10 +4,10 @@ import { parseEventLocation } from "../lib/utils"
const reorderInitialState = {
sourceColumn: null,
targetColumn: null,
insertAfter: false,
breakpoints: [],
gridLeft: 0,
width: 0,
latestX: 0,
increment: 0,
}
@ -28,38 +28,41 @@ export const createActions = context => {
const {
reorder,
columns,
visibleColumns,
columnLookupMap,
scrollableColumns,
scroll,
bounds,
stickyColumn,
maxScrollLeft,
width,
visibleColumns,
datasource,
stickyWidth,
width,
scrollLeft,
maxScrollLeft,
} = context
let latestX = 0
let autoScrollInterval
let isAutoScrolling
// Callback when dragging on a colum header and starting reordering
const startReordering = (column, e) => {
const $visibleColumns = get(visibleColumns)
const $scrollableColumns = get(scrollableColumns)
const $bounds = get(bounds)
const $stickyColumn = get(stickyColumn)
const $stickyWidth = get(stickyWidth)
// Generate new breakpoints for the current columns
let breakpoints = $visibleColumns.map(col => ({
x: col.left + col.width,
const breakpoints = $scrollableColumns.map(col => ({
x: col.__left - $stickyWidth,
column: col.name,
insertAfter: false,
}))
if ($stickyColumn) {
breakpoints.unshift({
x: 0,
column: $stickyColumn.name,
})
} else if (!$visibleColumns[0].primaryDisplay) {
breakpoints.unshift({
x: 0,
column: null,
// Add a very left breakpoint as well
const lastCol = $scrollableColumns[$scrollableColumns.length - 1]
if (lastCol) {
breakpoints.push({
x: lastCol.__left + lastCol.width - $stickyWidth,
column: lastCol.name,
insertAfter: true,
})
}
@ -87,24 +90,23 @@ export const createActions = context => {
const onReorderMouseMove = e => {
// Immediately handle the current position
const { x } = parseEventLocation(e)
reorder.update(state => ({
...state,
latestX: x,
}))
latestX = x
considerReorderPosition()
// Check if we need to start auto-scrolling
const $scrollLeft = get(scrollLeft)
const $maxScrollLeft = get(maxScrollLeft)
const $reorder = get(reorder)
const proximityCutoff = Math.min(140, get(width) / 6)
const speedFactor = 16
const rightProximity = Math.max(0, $reorder.gridLeft + $reorder.width - x)
const leftProximity = Math.max(0, x - $reorder.gridLeft)
if (rightProximity < proximityCutoff) {
if (rightProximity < proximityCutoff && $scrollLeft < $maxScrollLeft) {
const weight = proximityCutoff - rightProximity
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
startAutoScroll()
} else if (leftProximity < proximityCutoff) {
} else if (leftProximity < proximityCutoff && $scrollLeft > 0) {
const weight = -1 * (proximityCutoff - leftProximity)
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
@ -117,23 +119,28 @@ export const createActions = context => {
// Actual logic to consider the current position and determine the new order
const considerReorderPosition = () => {
const $reorder = get(reorder)
const $scroll = get(scroll)
const $scrollLeft = get(scrollLeft)
// Compute the closest breakpoint to the current position
let targetColumn
let breakpoint
let minDistance = Number.MAX_SAFE_INTEGER
const mouseX = $reorder.latestX - $reorder.gridLeft + $scroll.left
const mouseX = latestX - $reorder.gridLeft + $scrollLeft
$reorder.breakpoints.forEach(point => {
const distance = Math.abs(point.x - mouseX)
if (distance < minDistance) {
minDistance = distance
targetColumn = point.column
breakpoint = point
}
})
if (targetColumn !== $reorder.targetColumn) {
if (
breakpoint &&
(breakpoint.column !== $reorder.targetColumn ||
breakpoint.insertAfter !== $reorder.insertAfter)
) {
reorder.update(state => ({
...state,
targetColumn,
targetColumn: breakpoint.column,
insertAfter: breakpoint.insertAfter,
}))
}
}
@ -175,20 +182,29 @@ export const createActions = context => {
document.removeEventListener("touchcancel", stopReordering)
// Ensure there's actually a change before saving
const { sourceColumn, targetColumn } = get(reorder)
const { sourceColumn, targetColumn, insertAfter } = get(reorder)
reorder.set(reorderInitialState)
if (sourceColumn !== targetColumn) {
await moveColumn(sourceColumn, targetColumn)
await moveColumn({ sourceColumn, targetColumn, insertAfter })
}
}
// Moves a column after another columns.
// An undefined target column will move the source to index 0.
const moveColumn = async (sourceColumn, targetColumn) => {
let $columns = get(columns)
let sourceIdx = $columns.findIndex(x => x.name === sourceColumn)
let targetIdx = $columns.findIndex(x => x.name === targetColumn)
targetIdx++
const moveColumn = async ({
sourceColumn,
targetColumn,
insertAfter = false,
}) => {
// Find the indices in the overall columns array
const $columns = get(columns)
let sourceIdx = $columns.findIndex(col => col.name === sourceColumn)
let targetIdx = $columns.findIndex(col => col.name === targetColumn)
if (insertAfter) {
targetIdx++
}
// Reorder columns
columns.update(state => {
const removed = state.splice(sourceIdx, 1)
if (--targetIdx < sourceIdx) {
@ -209,18 +225,27 @@ export const createActions = context => {
// 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)
await moveColumn(column, $visibleColumns[sourceIdx - 2]?.name)
const $columnLookupMap = get(columnLookupMap)
const sourceIdx = $columnLookupMap[column].__idx
await moveColumn({
sourceColumn: column,
targetColumn: $visibleColumns[sourceIdx - 1]?.name,
})
}
// 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)
const $columnLookupMap = get(columnLookupMap)
const sourceIdx = $columnLookupMap[column].__idx
if (sourceIdx === $visibleColumns.length - 1) {
return
}
await moveColumn(column, $visibleColumns[sourceIdx + 1]?.name)
await moveColumn({
sourceColumn: column,
targetColumn: $visibleColumns[sourceIdx + 1]?.name,
insertAfter: true,
})
}
return {

View file

@ -34,7 +34,7 @@ export const createActions = context => {
// Set initial store state
resize.set({
width: column.width,
left: column.left,
left: column.__left,
initialWidth: column.width,
initialMouseX: x,
column: column.name,

View file

@ -4,6 +4,7 @@ import { NewRowID, RowPageSize } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { tick } from "svelte"
import { Helpers } from "@budibase/bbui"
import { sleep } from "../../../utils/utils"
export const createStores = () => {
const rows = writable([])
@ -16,11 +17,23 @@ export const createStores = () => {
const error = writable(null)
const fetch = writable(null)
// Enrich rows with an index property and any pending changes
const enrichedRows = derived(
[rows, rowChangeCache],
([$rows, $rowChangeCache]) => {
return $rows.map((row, idx) => ({
...row,
...$rowChangeCache[row._id],
__idx: idx,
}))
}
)
// Generate a lookup map to quick find a row by ID
const rowLookupMap = derived(rows, $rows => {
const rowLookupMap = derived(enrichedRows, $enrichedRows => {
let map = {}
for (let i = 0; i < $rows.length; i++) {
map[$rows[i]._id] = i
for (let i = 0; i < $enrichedRows.length; i++) {
map[$enrichedRows[i]._id] = $enrichedRows[i]
}
return map
})
@ -35,18 +48,6 @@ export const createStores = () => {
}
})
// Enrich rows with an index property and any pending changes
const enrichedRows = derived(
[rows, rowChangeCache],
([$rows, $rowChangeCache]) => {
return $rows.map((row, idx) => ({
...row,
...$rowChangeCache[row._id],
__idx: idx,
}))
}
)
return {
rows: {
...rows,
@ -86,6 +87,7 @@ export const createActions = context => {
fetch,
hasBudibaseIdentifiers,
refreshing,
columnLookupMap,
} = context
const instanceLoaded = writable(false)
@ -188,12 +190,6 @@ export const createActions = context => {
fetch.set(newFetch)
})
// Gets a row by ID
const getRow = id => {
const index = get(rowLookupMap)[id]
return index >= 0 ? get(rows)[index] : null
}
// Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => {
@ -263,22 +259,15 @@ export const createActions = context => {
for (let column of missingColumns) {
get(notifications).error(`${column} is required but is missing`)
}
// Focus the first cell with an error
if (erroredColumns.length) {
focusedCellId.set(getCellID(rowId, erroredColumns[0]))
}
} else {
get(notifications).error(errorString || "An unknown error occurred")
}
}
// Adds a new row
const addRow = async (row, idx, bubble = false) => {
const addRow = async ({ row, idx, bubble = false, notify = true }) => {
try {
// Create row. Spread row so we can mutate and enrich safely.
let newRow = { ...row }
newRow = await datasource.actions.addRow(newRow)
const newRow = await datasource.actions.addRow(row)
// Update state
if (idx != null) {
@ -291,38 +280,94 @@ export const createActions = context => {
handleNewRows([newRow])
}
// Refresh row to ensure data is in the correct format
get(notifications).success("Row created successfully")
if (notify) {
get(notifications).success("Row created successfully")
}
return newRow
} catch (error) {
if (bubble) {
throw error
} else {
handleValidationError(NewRowID, error)
validation.actions.focusFirstRowError(NewRowID)
}
}
}
// Duplicates a row, inserting the duplicate row after the existing one
const duplicateRow = async row => {
let clone = { ...row }
let clone = cleanRow(row)
delete clone._id
delete clone._rev
delete clone.__idx
try {
return await addRow(clone, row.__idx + 1, true)
const duped = await addRow({
row: clone,
idx: row.__idx + 1,
bubble: true,
notify: false,
})
get(notifications).success("Duplicated 1 row")
return duped
} catch (error) {
handleValidationError(row._id, error)
validation.actions.focusFirstRowError(row._id)
}
}
// Duplicates multiple rows, inserting them after the last source row
const bulkDuplicate = async (rowsToDupe, progressCallback) => {
// Find index of last row
const $rowLookupMap = get(rowLookupMap)
const indices = rowsToDupe.map(row => $rowLookupMap[row._id]?.__idx)
const index = Math.max(...indices)
const count = rowsToDupe.length
// Clone and clean rows
const clones = rowsToDupe.map(row => {
let clone = cleanRow(row)
delete clone._id
delete clone._rev
return clone
})
// Create rows
let saved = []
let failed = 0
for (let i = 0; i < count; i++) {
try {
saved.push(await datasource.actions.addRow(clones[i]))
rowCacheMap[saved._id] = true
await sleep(50) // Small sleep to ensure we avoid rate limiting
} catch (error) {
failed++
console.error("Duplicating row failed", error)
}
progressCallback?.((i + 1) / count)
}
// Add to state
if (saved.length) {
rows.update(state => {
return state.toSpliced(index + 1, 0, ...saved)
})
}
// Notify user
if (failed) {
get(notifications).error(`Failed to duplicate ${failed} of ${count} rows`)
} else if (saved.length) {
get(notifications).success(`Duplicated ${saved.length} rows`)
}
return saved
}
// Replaces a row in state with the newly defined row, handling updates,
// addition and deletion
const replaceRow = (id, row) => {
// Get index of row to check if it exists
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[id]
const index = $rowLookupMap[id].__idx
// Process as either an update, addition or deletion
if (row) {
@ -371,10 +416,8 @@ export const createActions = context => {
// Patches a row with some changes in local state, and returns whether a
// valid pending change was made or not
const stashRowChanges = (rowId, changes) => {
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
const row = $rowLookupMap[rowId]
// Check this is a valid change
if (!row || !changesAreValid(row, changes)) {
@ -392,15 +435,20 @@ export const createActions = context => {
return true
}
// Saves any pending changes to a row
const applyRowChanges = async rowId => {
const $rows = get(rows)
// Saves any pending changes to a row, as well as any additional changes
// specified
const applyRowChanges = async ({
rowId,
changes = null,
updateState = true,
handleErrors = true,
}) => {
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
const row = $rowLookupMap[rowId]
if (row == null) {
return
}
let savedRow
// Save change
try {
@ -411,33 +459,38 @@ export const createActions = context => {
}))
// Update row
const changes = get(rowChangeCache)[rowId]
const newRow = { ...cleanRow(row), ...changes }
const saved = await datasource.actions.updateRow(newRow)
const stashedChanges = get(rowChangeCache)[rowId]
const newRow = { ...cleanRow(row), ...stashedChanges, ...changes }
savedRow = await datasource.actions.updateRow(newRow)
// Update row state after a successful change
if (saved?._id) {
rows.update(state => {
state[index] = saved
return state.slice()
})
} else if (saved?.id) {
if (savedRow?._id) {
if (updateState) {
rows.update(state => {
state[row.__idx] = savedRow
return state.slice()
})
}
} else if (savedRow?.id) {
// Handle users table edge case
await refreshRow(saved.id)
await refreshRow(savedRow.id)
}
// Wipe row change cache for any values which have been saved
const liveChanges = get(rowChangeCache)[rowId]
rowChangeCache.update(state => {
Object.keys(changes || {}).forEach(key => {
if (changes[key] === liveChanges?.[key]) {
Object.keys(stashedChanges || {}).forEach(key => {
if (stashedChanges[key] === liveChanges?.[key]) {
delete state[rowId][key]
}
})
return state
})
} catch (error) {
handleValidationError(rowId, error)
if (handleErrors) {
handleValidationError(rowId, error)
validation.actions.focusFirstRowError(rowId)
}
}
// Decrement change count for this row
@ -445,13 +498,82 @@ export const createActions = context => {
...state,
[rowId]: (state[rowId] || 1) - 1,
}))
return savedRow
}
// Updates a value of a row
const updateValue = async ({ rowId, column, value, apply = true }) => {
const success = stashRowChanges(rowId, { [column]: value })
if (success && apply) {
await applyRowChanges(rowId)
await applyRowChanges({ rowId })
}
}
const bulkUpdate = async (changeMap, progressCallback) => {
const rowIds = Object.keys(changeMap || {})
const count = rowIds.length
if (!count) {
return
}
// Update rows
const $columnLookupMap = get(columnLookupMap)
let updated = []
let failed = 0
for (let i = 0; i < count; i++) {
const rowId = rowIds[i]
let changes = changeMap[rowId] || {}
// Strip any readonly fields from the change set
for (let field of Object.keys(changes)) {
const column = $columnLookupMap[field]
if (columns.actions.isReadonly(column)) {
delete changes[field]
}
}
if (!Object.keys(changes).length) {
progressCallback?.((i + 1) / count)
continue
}
try {
const updatedRow = await applyRowChanges({
rowId,
changes: changeMap[rowId],
updateState: false,
handleErrors: false,
})
if (updatedRow) {
updated.push(updatedRow)
} else {
failed++
}
await sleep(50) // Small sleep to ensure we avoid rate limiting
} catch (error) {
failed++
console.error("Failed to update row", error)
}
progressCallback?.((i + 1) / count)
}
// Update state
if (updated.length) {
const $rowLookupMap = get(rowLookupMap)
rows.update(state => {
for (let row of updated) {
const index = $rowLookupMap[row._id].__idx
state[index] = row
}
return state.slice()
})
}
// Notify user
if (failed) {
const unit = `row${count === 1 ? "" : "s"}`
get(notifications).error(`Failed to update ${failed} of ${count} ${unit}`)
} else if (updated.length) {
const unit = `row${updated.length === 1 ? "" : "s"}`
get(notifications).success(`Updated ${updated.length} ${unit}`)
}
}
@ -516,14 +638,6 @@ export const createActions = context => {
get(fetch)?.nextPage()
}
// Checks if we have a row with a certain ID
const hasRow = id => {
if (id === NewRowID) {
return true
}
return get(rowLookupMap)[id] != null
}
// Cleans a row by removing any internal grid metadata from it.
// Call this before passing a row to any sort of external flow.
const cleanRow = row => {
@ -541,16 +655,16 @@ export const createActions = context => {
actions: {
addRow,
duplicateRow,
getRow,
bulkDuplicate,
updateValue,
applyRowChanges,
deleteRows,
hasRow,
loadNextPage,
refreshRow,
replaceRow,
refreshData,
cleanRow,
bulkUpdate,
},
},
}
@ -569,10 +683,12 @@ export const initialise = context => {
// Wipe the row change cache when changing row
previousFocusedRowId.subscribe(id => {
if (id && !get(inProgressChanges)[id]) {
rowChangeCache.update(state => {
delete state[id]
return state
})
if (Object.keys(get(rowChangeCache)[id] || {}).length) {
rowChangeCache.update(state => {
delete state[id]
return state
})
}
}
})
@ -581,12 +697,12 @@ export const initialise = context => {
if (!id) {
return
}
const { id: rowId, field } = parseCellID(id)
const { rowId, field } = parseCellID(id)
const hasChanges = field in (get(rowChangeCache)[rowId] || {})
const hasErrors = validation.actions.rowHasErrors(rowId)
const isSavingChanges = get(inProgressChanges)[rowId]
if (rowId && !hasErrors && hasChanges && !isSavingChanges) {
await rows.actions.applyRowChanges(rowId)
await rows.actions.applyRowChanges({ rowId })
}
})
}

View file

@ -16,8 +16,8 @@ export const createStores = () => {
})
// Derive height and width as primitives to avoid wasted computation
const scrollTop = derived(scroll, $scroll => $scroll.top, 0)
const scrollLeft = derived(scroll, $scroll => $scroll.left, 0)
const scrollTop = derived(scroll, $scroll => Math.round($scroll.top))
const scrollLeft = derived(scroll, $scroll => Math.round($scroll.left))
return {
scroll,
@ -30,7 +30,7 @@ export const deriveStores = context => {
const {
rows,
visibleColumns,
stickyColumn,
displayColumn,
rowHeight,
width,
height,
@ -38,31 +38,32 @@ export const deriveStores = context => {
} = context
// Memoize store primitives
const stickyColumnWidth = derived(stickyColumn, $col => $col?.width || 0, 0)
const stickyWidth = derived(displayColumn, $displayColumn => {
return ($displayColumn?.width || 0) + GutterWidth
})
// Derive horizontal limits
const contentWidth = derived(
[visibleColumns, stickyColumnWidth, buttonColumnWidth],
([$visibleColumns, $stickyColumnWidth, $buttonColumnWidth]) => {
let width = GutterWidth + $buttonColumnWidth + $stickyColumnWidth
[visibleColumns, buttonColumnWidth],
([$visibleColumns, $buttonColumnWidth]) => {
let width = GutterWidth + Math.max($buttonColumnWidth, HPadding)
$visibleColumns.forEach(col => {
width += col.width
})
return width + HPadding
},
0
return width
}
)
const screenWidth = derived(
[width, stickyColumnWidth],
([$width, $stickyColumnWidth]) => $width + GutterWidth + $stickyColumnWidth,
0
[width, stickyWidth],
([$width, $stickyWidth]) => {
return $width + $stickyWidth
}
)
const maxScrollLeft = derived(
[contentWidth, screenWidth],
([$contentWidth, $screenWidth]) => {
return Math.max($contentWidth - $screenWidth, 0)
},
0
return Math.round(Math.max($contentWidth - $screenWidth, 0))
}
)
const showHScrollbar = derived(
[contentWidth, screenWidth],
@ -80,13 +81,12 @@ export const deriveStores = context => {
height += ScrollBarSize * 2
}
return height
},
0
}
)
const maxScrollTop = derived(
[height, contentHeight],
([$height, $contentHeight]) => Math.max($contentHeight - $height, 0),
0
([$height, $contentHeight]) =>
Math.round(Math.max($contentHeight - $height, 0))
)
const showVScrollbar = derived(
[contentHeight, height],
@ -96,6 +96,7 @@ export const deriveStores = context => {
)
return {
stickyWidth,
contentHeight,
contentWidth,
screenWidth,
@ -113,12 +114,13 @@ export const initialise = context => {
scroll,
bounds,
rowHeight,
visibleColumns,
stickyWidth,
scrollTop,
maxScrollTop,
scrollLeft,
maxScrollLeft,
buttonColumnWidth,
columnLookupMap,
} = context
// Ensure scroll state never goes invalid, which can happen when changing
@ -186,15 +188,16 @@ export const initialise = context => {
// Ensure horizontal position is viewable
// Check horizontal position of columns next
const $visibleColumns = get(visibleColumns)
const { field: columnName } = parseCellID($focusedCellId)
const column = $visibleColumns.find(col => col.name === columnName)
if (!column) {
const { field } = parseCellID($focusedCellId)
const column = get(columnLookupMap)[field]
if (!column || column.primaryDisplay) {
return
}
// Ensure column is not cutoff on left edge
let delta = $scroll.left - column.left + FocusedCellMinOffset
const $stickyWidth = get(stickyWidth)
let delta =
$scroll.left - column.__left + FocusedCellMinOffset + $stickyWidth
if (delta > 0) {
scroll.update(state => ({
...state,
@ -205,10 +208,10 @@ export const initialise = context => {
// Ensure column is not cutoff on right edge
else {
const $buttonColumnWidth = get(buttonColumnWidth)
const rightEdge = column.left + column.width
const rightEdge = column.__left + column.width
const rightBound =
$bounds.width + $scroll.left - FocusedCellMinOffset - $buttonColumnWidth
delta = rightEdge - rightBound
delta = rightEdge - rightBound - $stickyWidth
if (delta > 0) {
scroll.update(state => ({
...state,

View file

@ -2,12 +2,11 @@ import { writable, get, derived } from "svelte/store"
import { tick } from "svelte"
import {
DefaultRowHeight,
GutterWidth,
LargeRowHeight,
MediumRowHeight,
NewRowID,
} from "../lib/constants"
import { parseCellID } from "../lib/utils"
import { getCellID, parseCellID } from "../lib/utils"
export const createStores = context => {
const { props } = context
@ -22,34 +21,15 @@ export const createStores = context => {
const keyboardBlocked = writable(false)
const isDragging = writable(false)
const buttonColumnWidth = writable(0)
// Derive the current focused row ID
const focusedRowId = derived(
focusedCellId,
$focusedCellId => {
return parseCellID($focusedCellId)?.id
},
null
)
// Toggles whether a certain row ID is selected or not
const toggleSelectedRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
const cellSelection = writable({
active: false,
sourceCellId: null,
targetCellId: null,
})
return {
focusedCellId,
focusedCellAPI,
focusedRowId,
previousFocusedRowId,
previousFocusedCellId,
hoveredRowId,
@ -58,35 +38,38 @@ export const createStores = context => {
keyboardBlocked,
isDragging,
buttonColumnWidth,
selectedRows: {
...selectedRows,
actions: {
toggleRow: toggleSelectedRow,
},
},
selectedRows,
cellSelection,
}
}
export const deriveStores = context => {
const { focusedCellId, rows, rowLookupMap, rowHeight, stickyColumn, width } =
context
const {
focusedCellId,
rows,
rowLookupMap,
rowHeight,
width,
selectedRows,
cellSelection,
columnLookupMap,
visibleColumns,
} = context
// Derive the current focused row ID
const focusedRowId = derived(focusedCellId, $focusedCellId => {
return parseCellID($focusedCellId).rowId
})
// Derive the row that contains the selected cell
const focusedRow = derived(
[focusedCellId, rowLookupMap, rows],
([$focusedCellId, $rowLookupMap, $rows]) => {
const rowId = parseCellID($focusedCellId)?.id
// Edge case for new rows
if (rowId === NewRowID) {
[focusedRowId, rowLookupMap],
([$focusedRowId, $rowLookupMap]) => {
if ($focusedRowId === NewRowID) {
return { _id: NewRowID }
}
// All normal rows
const index = $rowLookupMap[rowId]
return $rows[index]
},
null
return $rowLookupMap[$focusedRowId]
}
)
// Derive the amount of content lines to show in cells depending on row height
@ -100,24 +83,200 @@ export const deriveStores = context => {
})
// Derive whether we should use the compact UI, depending on width
const compact = derived([stickyColumn, width], ([$stickyColumn, $width]) => {
return ($stickyColumn?.width || 0) + $width + GutterWidth < 800
const compact = derived(width, $width => {
return $width < 600
})
// Derive we have any selected rows or not
const selectedRowCount = derived(selectedRows, $selectedRows => {
return Object.keys($selectedRows).length
})
// Derive whether or not we're actively selecting cells
const isSelectingCells = derived(cellSelection, $cellSelection => {
return $cellSelection.active
})
// Derive the full extent of all selected cells
const selectedCells = derived(
[cellSelection, rowLookupMap, columnLookupMap],
([$cellSelection, $rowLookupMap, $columnLookupMap]) => {
const { sourceCellId, targetCellId } = $cellSelection
if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) {
return []
}
const $rows = get(rows)
const $visibleColumns = get(visibleColumns)
// Get source and target row and column indices
const sourceInfo = parseCellID(sourceCellId)
const targetInfo = parseCellID(targetCellId)
if (sourceInfo.rowId === NewRowID) {
return []
}
// Row indices
const sourceRowIndex = $rowLookupMap[sourceInfo.rowId]?.__idx
const targetRowIndex = $rowLookupMap[targetInfo.rowId]?.__idx
if (sourceRowIndex == null || targetRowIndex == null) {
return []
}
const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex)
let upperRowIndex = Math.max(sourceRowIndex, targetRowIndex)
// Cap rows at 50
upperRowIndex = Math.min(upperRowIndex, lowerRowIndex + 49)
// Column indices
const sourceColIndex = $columnLookupMap[sourceInfo.field].__idx
const targetColIndex = $columnLookupMap[targetInfo.field].__idx
const lowerColIndex = Math.min(sourceColIndex, targetColIndex)
const upperColIndex = Math.max(sourceColIndex, targetColIndex)
// Build 2 dimensional array of all cells inside these bounds
let cells = []
let rowId, colName
for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) {
let rowCells = []
for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) {
rowId = $rows[rowIdx]._id
colName = $visibleColumns[colIdx].name
rowCells.push(getCellID(rowId, colName))
}
cells.push(rowCells)
}
return cells
}
)
// Derive a quick lookup map of the selected cells
const selectedCellMap = derived(selectedCells, $selectedCells => {
let map = {}
for (let row of $selectedCells) {
for (let cell of row) {
map[cell] = true
}
}
return map
})
// Derive the count of the selected cells
const selectedCellCount = derived(selectedCellMap, $selectedCellMap => {
return Object.keys($selectedCellMap).length
})
return {
focusedRowId,
focusedRow,
contentLines,
compact,
selectedRowCount,
isSelectingCells,
selectedCells,
selectedCellMap,
selectedCellCount,
}
}
export const createActions = context => {
const { focusedCellId, hoveredRowId } = context
const {
focusedCellId,
hoveredRowId,
selectedRows,
rowLookupMap,
rows,
selectedRowCount,
cellSelection,
selectedCells,
} = context
// Keep the last selected index to use with bulk selection
let lastSelectedIndex = null
// Callback when leaving the grid, deselecting all focussed or selected items
const blur = () => {
focusedCellId.set(null)
hoveredRowId.set(null)
clearCellSelection()
}
// Toggles whether a certain row ID is selected or not
const toggleSelectedRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
} else {
lastSelectedIndex = get(rowLookupMap)[id].__idx
}
return newState
})
}
const bulkSelectRows = id => {
if (!get(selectedRowCount)) {
toggleSelectedRow(id)
return
}
if (lastSelectedIndex == null) {
return
}
const thisIndex = get(rowLookupMap)[id].__idx
// Skip if indices are the same
if (lastSelectedIndex === thisIndex) {
return
}
const from = Math.min(lastSelectedIndex, thisIndex)
const to = Math.max(lastSelectedIndex, thisIndex)
const $rows = get(rows)
selectedRows.update(state => {
for (let i = from; i <= to; i++) {
state[$rows[i]._id] = true
}
return state
})
}
const startCellSelection = sourceCellId => {
cellSelection.set({
active: true,
sourceCellId,
targetCellId: sourceCellId,
})
}
const updateCellSelection = targetCellId => {
cellSelection.update(state => ({
...state,
targetCellId,
}))
}
const stopCellSelection = () => {
cellSelection.update(state => ({
...state,
active: false,
}))
}
const selectCellRange = (source, target) => {
cellSelection.set({
active: false,
sourceCellId: source,
targetCellId: target,
})
}
const clearCellSelection = () => {
cellSelection.set({
active: false,
sourceCellId: null,
targetCellId: null,
})
}
return {
@ -126,6 +285,23 @@ export const createActions = context => {
blur,
},
},
selectedRows: {
...selectedRows,
actions: {
toggleRow: toggleSelectedRow,
bulkSelectRows,
},
},
selectedCells: {
...selectedCells,
actions: {
startSelecting: startCellSelection,
updateTarget: updateCellSelection,
stopSelecting: stopCellSelection,
selectRange: selectCellRange,
clear: clearCellSelection,
},
},
}
}
@ -134,28 +310,32 @@ export const initialise = context => {
focusedRowId,
previousFocusedRowId,
previousFocusedCellId,
rows,
rowLookupMap,
focusedCellId,
selectedRows,
hoveredRowId,
definition,
rowHeight,
fixedRowHeight,
selectedRowCount,
menu,
selectedCellCount,
selectedCells,
cellSelection,
} = context
// Ensure we clear invalid rows from state if they disappear
rows.subscribe(async () => {
rowLookupMap.subscribe(async $rowLookupMap => {
// We tick here to ensure other derived stores have properly updated.
// We depend on the row lookup map which is a derived store,
await tick()
const $focusedCellId = get(focusedCellId)
const $focusedRowId = get(focusedRowId)
const $selectedRows = get(selectedRows)
const $hoveredRowId = get(hoveredRowId)
const hasRow = rows.actions.hasRow
const hasRow = id => $rowLookupMap[id] != null
// Check selected cell
const selectedRowId = parseCellID($focusedCellId)?.id
if (selectedRowId && !hasRow(selectedRowId)) {
// Check focused cell
if ($focusedRowId && !hasRow($focusedRowId)) {
focusedCellId.set(null)
}
@ -165,17 +345,19 @@ export const initialise = context => {
}
// Check selected rows
let newSelectedRows = { ...$selectedRows }
let selectedRowsNeedsUpdate = false
const selectedIds = Object.keys($selectedRows)
for (let i = 0; i < selectedIds.length; i++) {
if (!hasRow(selectedIds[i])) {
delete newSelectedRows[selectedIds[i]]
selectedRowsNeedsUpdate = true
if (selectedIds.length) {
let newSelectedRows = { ...$selectedRows }
let selectedRowsNeedsUpdate = false
for (let i = 0; i < selectedIds.length; i++) {
if (!hasRow(selectedIds[i])) {
delete newSelectedRows[selectedIds[i]]
selectedRowsNeedsUpdate = true
}
}
if (selectedRowsNeedsUpdate) {
selectedRows.set(newSelectedRows)
}
}
if (selectedRowsNeedsUpdate) {
selectedRows.set(newSelectedRows)
}
})
@ -186,18 +368,29 @@ export const initialise = context => {
lastFocusedRowId = id
})
// Remember the last focused cell ID so that we can store the previous one
let lastFocusedCellId = null
focusedCellId.subscribe(id => {
// Remember the last focused cell ID so that we can store the previous one
previousFocusedCellId.set(lastFocusedCellId)
lastFocusedCellId = id
})
// Remove hovered row when a cell is selected
focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) {
// Remove hovered row when a cell is selected
if (id && get(hoveredRowId)) {
hoveredRowId.set(null)
}
// Clear row selection when focusing a cell
if (id && get(selectedRowCount)) {
selectedRows.set({})
}
// Clear cell selection when focusing a cell
if (id && get(selectedCellCount)) {
selectedCells.actions.clear()
}
// Close the menu if it was open
menu.actions.close()
})
// Pull row height from table as long as we don't have a fixed height
@ -215,4 +408,36 @@ export const initialise = context => {
rowHeight.set(get(definition)?.rowHeight || DefaultRowHeight)
}
})
// Clear focused cell when selecting rows
selectedRowCount.subscribe(count => {
if (count) {
if (get(focusedCellId)) {
focusedCellId.set(null)
}
if (get(selectedCellCount)) {
selectedCells.actions.clear()
}
}
})
// Clear state when selecting cells
selectedCellCount.subscribe($selectedCellCount => {
if ($selectedCellCount) {
if (get(selectedRowCount)) {
selectedRows.set({})
}
}
})
// Ensure the source of cell selection is focused
cellSelection.subscribe(async ({ sourceCellId, targetCellId }) => {
if (
sourceCellId &&
sourceCellId !== targetCellId &&
get(focusedCellId) !== sourceCellId
) {
focusedCellId.set(sourceCellId)
}
})
}

View file

@ -25,7 +25,7 @@ export const deriveStores = context => {
// Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside cells extremely fast
const selectedCellMap = derived(
const userCellMap = derived(
[users, focusedCellId],
([$users, $focusedCellId]) => {
let map = {}
@ -40,7 +40,7 @@ export const deriveStores = context => {
)
return {
selectedCellMap,
userCellMap,
}
}

View file

@ -1,5 +1,5 @@
import { writable, get, derived } from "svelte/store"
import { getCellID, parseCellID } from "../lib/utils"
import { parseCellID } from "../lib/utils"
// Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into
@ -7,18 +7,38 @@ import { getCellID, parseCellID } from "../lib/utils"
export const createStores = () => {
const validation = writable({})
return {
validation,
}
}
export const deriveStores = context => {
const { validation } = context
// Derive which rows have errors so that we can use that info later
const rowErrorMap = derived(validation, $validation => {
const validationRowLookupMap = derived(validation, $validation => {
let map = {}
Object.entries($validation).forEach(([key, error]) => {
// Extract row ID from all errored cell IDs
if (error) {
map[parseCellID(key).id] = true
const { rowId } = parseCellID(key)
if (!map[rowId]) {
map[rowId] = []
}
map[rowId].push(key)
}
})
return map
})
return {
validationRowLookupMap,
}
}
export const createActions = context => {
const { validation, focusedCellId, validationRowLookupMap } = context
const setError = (cellId, error) => {
if (!cellId) {
return
@ -30,7 +50,15 @@ export const createStores = () => {
}
const rowHasErrors = rowId => {
return get(rowErrorMap)[rowId]
return get(validationRowLookupMap)[rowId]?.length > 0
}
const focusFirstRowError = rowId => {
const errorCells = get(validationRowLookupMap)[rowId]
const cellId = errorCells?.[0]
if (cellId) {
focusedCellId.set(cellId)
}
}
return {
@ -39,28 +67,27 @@ export const createStores = () => {
actions: {
setError,
rowHasErrors,
focusFirstRowError,
},
},
}
}
export const initialise = context => {
const { validation, previousFocusedRowId, columns, stickyColumn } = context
const { validation, previousFocusedRowId, validationRowLookupMap } = context
// Remove validation errors from previous focused row
// Remove validation errors when changing rows
previousFocusedRowId.subscribe(id => {
if (id) {
const $columns = get(columns)
const $stickyColumn = get(stickyColumn)
validation.update(state => {
$columns.forEach(column => {
state[getCellID(id, column.name)] = null
const errorCells = get(validationRowLookupMap)[id]
if (errorCells?.length) {
validation.update(state => {
for (let cellId of errorCells) {
delete state[cellId]
}
return state
})
if ($stickyColumn) {
state[getCellID(id, stickyColumn.name)] = null
}
return state
})
}
}
})
}

View file

@ -4,7 +4,7 @@ import { MinColumnWidth } from "../lib/constants"
export const deriveStores = context => {
const {
rowHeight,
visibleColumns,
scrollableColumns,
rows,
scrollTop,
scrollLeft,
@ -46,33 +46,31 @@ export const deriveStores = context => {
return Math.round($scrollLeft / interval) * interval
})
const columnRenderMap = derived(
[visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width]) => {
if (!$visibleColumns.length) {
[scrollableColumns, scrollLeftRounded, width],
([$scrollableColumns, $scrollLeft, $width]) => {
if (!$scrollableColumns.length) {
return {}
}
let startColIdx = 0
let rightEdge = $visibleColumns[0].width
let rightEdge = $scrollableColumns[0].width
while (
rightEdge < $scrollLeft &&
startColIdx < $visibleColumns.length - 1
startColIdx < $scrollableColumns.length - 1
) {
startColIdx++
rightEdge += $visibleColumns[startColIdx].width
rightEdge += $scrollableColumns[startColIdx].width
}
let endColIdx = startColIdx + 1
let leftEdge = rightEdge
while (
leftEdge < $width + $scrollLeft &&
endColIdx < $visibleColumns.length
endColIdx < $scrollableColumns.length
) {
leftEdge += $visibleColumns[endColIdx].width
leftEdge += $scrollableColumns[endColIdx].width
endColIdx++
}
// Only update the store if different
let next = {}
$visibleColumns
$scrollableColumns
.slice(Math.max(0, startColIdx), endColIdx)
.forEach(col => {
next[col.name] = true

View file

@ -1,20 +1,20 @@
.spectrum--midnight {
--hue: 220;
--sat: 10%;
--spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%);
--spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%);
--spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%);
--spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%);
--spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%);
--spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%);
--spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%);
--spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%);
--spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%);
--spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%);
--spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%);
--hue: 220;
--sat: 10%;
--spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%);
--spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%);
--spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%);
--spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%);
--spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%);
--spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%);
--spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%);
--spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%);
--spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%);
--spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%);
--spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%);
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.25) !important;
--spectrum-global-color-blue-100: rgba(35, 40, 50) !important;
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.25) !important;
--spectrum-global-color-blue-100: rgba(36, 44, 64) !important;
}

View file

@ -49,5 +49,5 @@
/* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.15) !important;
--spectrum-global-color-blue-100: rgb(56, 65, 84) !important;
--spectrum-global-color-blue-100: rgb(56, 65, 90) !important;
}

View file

@ -1,6 +1,8 @@
import { makePropSafe as safe } from "@budibase/string-templates"
import { Helpers } from "@budibase/bbui"
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
/**
* Utility to wrap an async function and ensure all invocations happen
* sequentially.

View file

@ -10,7 +10,7 @@ import {
isExternalTableID,
isSQL,
} from "../../../integrations/utils"
import { events } from "@budibase/backend-core"
import { events, HTTPError } from "@budibase/backend-core"
import {
BulkImportRequest,
BulkImportResponse,
@ -29,6 +29,7 @@ import sdk from "../../../sdk"
import { jsonFromCsvString } from "../../../utilities/csv"
import { builderSocket } from "../../../websockets"
import { cloneDeep, isEqual } from "lodash"
import { helpers } from "@budibase/shared-core"
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
if (table && isExternalTable(table)) {
@ -40,6 +41,20 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
return internal
}
function checkDefaultFields(table: Table) {
for (const [key, field] of Object.entries(table.schema)) {
if (!("default" in field) || field.default == null) {
continue
}
if (helpers.schema.isRequired(field.constraints)) {
throw new HTTPError(
`Cannot make field "${key}" required, it has a default value.`,
400
)
}
}
}
// covers both internal and external
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
const internal = await sdk.tables.getAllInternalTables()
@ -76,6 +91,8 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
const isImport = table.rows
const renaming = ctx.request.body._rename
checkDefaultFields(table)
const api = pickApi({ table })
let savedTable = await api.save(ctx, renaming)
if (!table._id) {

View file

@ -550,6 +550,239 @@ describe.each([
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
})
describe("default values", () => {
let table: Table
describe("string column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
description: {
name: "description",
type: FieldType.STRING,
default: "default description",
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.description).toEqual("default description")
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
description: "specified description",
})
expect(row.description).toEqual("specified description")
})
it("uses the default value if value is null", async () => {
const row = await config.api.row.save(table._id!, {
description: null,
})
expect(row.description).toEqual("default description")
})
it("uses the default value if value is undefined", async () => {
const row = await config.api.row.save(table._id!, {
description: undefined,
})
expect(row.description).toEqual("default description")
})
})
describe("number column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
age: {
name: "age",
type: FieldType.NUMBER,
default: "25",
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.age).toEqual(25)
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
age: 30,
})
expect(row.age).toEqual(30)
})
})
describe("date column", () => {
it("creates a row with a default value successfully", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
date: {
name: "date",
type: FieldType.DATETIME,
default: "2023-01-26T11:48:57.000Z",
},
},
})
)
const row = await config.api.row.save(table._id!, {})
expect(row.date).toEqual("2023-01-26T11:48:57.000Z")
})
it("gives an error if the default value is invalid", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
date: {
name: "date",
type: FieldType.DATETIME,
default: "invalid",
},
},
})
)
await config.api.row.save(
table._id!,
{},
{
status: 400,
body: {
message: `Invalid default value for field 'date' - Invalid date value: "invalid"`,
},
}
)
})
})
describe("bindings", () => {
describe("string column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
description: {
name: "description",
type: FieldType.STRING,
default: `{{ date now "YYYY-MM-DDTHH:mm:ss" }}`,
},
},
})
)
})
it("can use bindings in default values", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.description).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
)
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
description: "specified description",
})
expect(row.description).toEqual("specified description")
})
it("can bind the current user", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
user: {
name: "user",
type: FieldType.STRING,
default: `{{ [Current User]._id }}`,
},
},
})
)
const row = await config.api.row.save(table._id!, {})
expect(row.user).toEqual(config.getUser()._id)
})
it("cannot access current user password", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
user: {
name: "user",
type: FieldType.STRING,
default: `{{ user.password }}`,
},
},
})
)
const row = await config.api.row.save(table._id!, {})
// For some reason it's null for internal tables, and undefined for
// external.
expect(row.user == null).toBe(true)
})
})
describe("number column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
age: {
name: "age",
type: FieldType.NUMBER,
default: `{{ sum 10 10 5 }}`,
},
},
})
)
})
it("can use bindings in default values", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.age).toEqual(25)
})
describe("invalid default value", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
age: {
name: "age",
type: FieldType.NUMBER,
default: `{{ capitalize "invalid" }}`,
},
},
})
)
})
it("throws an error when invalid default value", async () => {
await config.api.row.save(
table._id!,
{},
{
status: 400,
body: {
message:
"Invalid default value for field 'age' - Invalid number value \"Invalid\"",
},
}
)
})
})
})
})
})
})
describe("get", () => {

View file

@ -49,7 +49,7 @@ describe.each([
const isSqs = name === "sqs"
const isLucene = name === "lucene"
const isInMemory = name === "in-memory"
const isInternal = isSqs || isLucene
const isInternal = isSqs || isLucene || isInMemory
const config = setup.getConfig()
let envCleanup: (() => void) | undefined
@ -115,10 +115,7 @@ describe.each([
if (isInMemory) {
return dataFilters.search(_.cloneDeep(rows), this.query)
} else {
return config.api.row.search(table._id!, {
...this.query,
tableId: table._id!,
})
return config.api.row.search(this.query.tableId, this.query)
}
}
@ -2182,8 +2179,7 @@ describe.each([
}).toContainExactly([{ name: "baz", productCat: undefined }])
})
})
isInternal &&
;(isSqs || isLucene) &&
describe("relations to same table", () => {
let relatedTable: Table, relatedRows: Row[]
@ -2371,6 +2367,7 @@ describe.each([
beforeAll(async () => {
await config.api.application.addSampleData(config.appId!)
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
rows = await config.api.row.fetch(table._id!)
})
it("should be able to search sample data", async () => {
@ -2455,4 +2452,76 @@ describe.each([
}).toContainExactly([{ [name]: "a" }])
})
})
// This is currently not supported in external datasources, it produces SQL
// errors at time of writing. We supported it (potentially by accident) in
// Lucene, though, so we need to make sure it's supported in SQS as well. We
// found real cases in production of column names ending in a space.
isInternal &&
describe("space at end of column name", () => {
beforeAll(async () => {
table = await createTable({
"name ": {
name: "name ",
type: FieldType.STRING,
},
})
await createRows([{ ["name "]: "foo" }, { ["name "]: "bar" }])
})
it("should be able to query a column that ends with a space", async () => {
await expectSearch({
query: {
string: {
"name ": "foo",
},
},
}).toContainExactly([{ ["name "]: "foo" }])
})
it("should be able to query a column that ends with a space using numeric notation", async () => {
await expectSearch({
query: {
string: {
"1:name ": "foo",
},
},
}).toContainExactly([{ ["name "]: "foo" }])
})
})
// This was never actually supported in Lucene but SQS does support it, so may
// as well have a test for it.
;(isSqs || isInMemory) &&
describe("space at start of column name", () => {
beforeAll(async () => {
table = await createTable({
" name": {
name: " name",
type: FieldType.STRING,
},
})
await createRows([{ [" name"]: "foo" }, { [" name"]: "bar" }])
})
it("should be able to query a column that starts with a space", async () => {
await expectSearch({
query: {
string: {
" name": "foo",
},
},
}).toContainExactly([{ [" name"]: "foo" }])
})
it("should be able to query a column that starts with a space using numeric notation", async () => {
await expectSearch({
query: {
string: {
"1: name": "foo",
},
},
}).toContainExactly([{ [" name"]: "foo" }])
})
})
})

View file

@ -86,6 +86,30 @@ describe.each([
expect(events.rows.imported).toHaveBeenCalledWith(res, 1)
})
it("should not allow a column to have a default value and be required", async () => {
await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
default: "default",
constraints: {
presence: true,
},
},
},
}),
{
status: 400,
body: {
message:
'Cannot make field "name" required, it has a default value.',
},
}
)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
@ -225,6 +249,142 @@ describe.each([
})
})
describe("default field validation", () => {
it("should error if an existing column is set to required and has a default value", async () => {
const table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
default: "default",
},
},
})
)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
default: "default",
constraints: {
presence: true,
},
},
},
},
{
status: 400,
body: {
message:
'Cannot make field "name" required, it has a default value.',
},
}
)
})
it("should error if an existing column is given a default value and is required", async () => {
const table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
},
})
)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
default: "default",
constraints: {
presence: true,
},
},
},
},
{
status: 400,
body: {
message:
'Cannot make field "name" required, it has a default value.',
},
}
)
})
it("should be able to set an existing column to have a default value if it's not required", async () => {
const table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
},
})
)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
default: "default",
},
},
},
{ status: 200 }
)
})
it("should be able to remove a default value if the column is not required", async () => {
const table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
default: "default",
},
},
})
)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
},
},
},
{ status: 200 }
)
})
})
describe("external table validation", () => {
!isInternal &&
it("should error if column is of type auto", async () => {

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