Merge branch 'master' into dean-fixes
This commit is contained in:
commit
27e4b4e3b4
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.29.21",
|
||||
"version": "2.29.22",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
>
|
||||
<div class="icon" class:newStyles>
|
||||
<svg
|
||||
on:contextmenu
|
||||
on:click
|
||||
class:hoverable
|
||||
class:disabled
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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%">
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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)) {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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>
|
|
@ -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} />
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
|
@ -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>
|
|
@ -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} />
|
|
@ -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"
|
||||
/>
|
|
@ -83,6 +83,7 @@
|
|||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:click={onClick}
|
||||
on:contextmenu
|
||||
ondragover="return false"
|
||||
ondragenter="return false"
|
||||
{id}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -84,6 +84,8 @@
|
|||
showPopover={drawers.length === 0}
|
||||
clickOutsideOverride={drawers.length > 0}
|
||||
maxHeight={600}
|
||||
minWidth={360}
|
||||
maxWidth={360}
|
||||
offset={18}
|
||||
>
|
||||
<span class="popover-wrap">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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",
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue