Merge branch 'develop' into feat/relationship-configuration

This commit is contained in:
Michael Drury 2023-10-10 16:49:50 +01:00 committed by GitHub
commit 5937f2139a
38 changed files with 489 additions and 197 deletions

View File

@ -20,6 +20,7 @@ env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
NX_BASE_BRANCH: origin/${{ github.base_ref }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' && github.base_ref != 'master'}}
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
jobs:
lint:

View File

@ -4,6 +4,7 @@ on:
pull_request:
branches:
- develop
- master
jobs:
release:

View File

@ -138,6 +138,8 @@ To develop the Budibase platform you'll need [Docker](https://www.docker.com/) a
`yarn setup` will check that all necessary components are installed and setup the repo for usage.
If you have access to the `@budibase/pro` submodule then please follow the Pro section of this guide before running the above command.
##### Manual method
The following commands can be executed to manually get Budibase up and running (assuming Docker/Docker Compose has been installed).
@ -146,6 +148,8 @@ The following commands can be executed to manually get Budibase up and running (
`yarn build` will build all budibase packages.
If you have access to the `@budibase/pro` submodule then please follow the Pro section of this guide before running the above commands.
#### 4. Running
To run the budibase server and builder in dev mode (i.e. with live reloading):

View File

@ -1,5 +1,5 @@
{
"version": "2.11.5-alpha.2",
"version": "2.11.15-alpha.2",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -6,6 +6,7 @@ import {
AutomationStepIdArray,
AutomationIOType,
AutomationCustomIOType,
DatasourceFeature,
} from "@budibase/types"
import joi from "joi"
@ -67,9 +68,27 @@ function validateDatasource(schema: any) {
version: joi.string().optional(),
schema: joi.object({
docs: joi.string(),
plus: joi.boolean().optional(),
isSQL: joi.boolean().optional(),
auth: joi
.object({
type: joi.string().required(),
})
.optional(),
features: joi
.object(
Object.fromEntries(
Object.values(DatasourceFeature).map(key => [
key,
joi.boolean().optional(),
])
)
)
.optional(),
relationships: joi.boolean().optional(),
description: joi.string().required(),
friendlyName: joi.string().required(),
type: joi.string().allow(...DATASOURCE_TYPES),
description: joi.string().required(),
datasource: joi.object().pattern(joi.string(), fieldValidator).required(),
query: joi
.object()

View File

@ -21,14 +21,6 @@
"hsla(240, 90%, 75%, 0.3)",
"hsla(320, 90%, 75%, 0.3)",
]
$: {
if (constraints.inclusion.length) {
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
}
}
const removeInput = idx => {
delete optionColors[options[idx].name]
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
@ -80,6 +72,11 @@
// Initialize anchor arrays on mount, assuming 'options' is already populated
colorPopovers = constraints.inclusion.map(() => undefined)
anchors = constraints.inclusion.map(() => undefined)
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
})
</script>

View File

@ -110,6 +110,16 @@
<div class="schema-fields">
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"}
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
/>
{:else}
<DrawerBindableSlot
fillWidth
title={value.title}
@ -134,6 +144,7 @@
/>
</DrawerBindableSlot>
{/if}
{/if}
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox

View File

@ -13,7 +13,13 @@
let modal
$: tempValue = filters || []
$: schemaFields = Object.values(schema || {})
$: schemaFields = Object.entries(schema || {}).map(
([fieldName, fieldSchema]) => ({
name: fieldName, // Using the key as name if not defined in the schema, for example in some autogenerated columns
...fieldSchema,
})
)
$: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0

View File

@ -660,7 +660,8 @@
>Open schema editor</Button
>
{:else if editableColumn.type === USER_REFRENCE_TYPE}
<Toggle
<!-- Disabled temporally -->
<!-- <Toggle
value={editableColumn.relationshipType === RelationshipType.MANY_TO_MANY}
on:change={e =>
(editableColumn.relationshipType = e.detail
@ -669,7 +670,7 @@
disabled={!isCreating}
thin
text="Allow multiple users"
/>
/> -->
{/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select

View File

@ -1,91 +0,0 @@
<script>
import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnEditor/ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { getFields } from "helpers/searchFields"
export let componentInstance
export let value = []
export let allowCellEditing = true
export let subject = "Table"
const dispatch = createEventDispatcher()
let drawer
let boundValue
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchema($currentAsset, datasource)
$: options = allowCellEditing
? Object.keys(schema || {})
: enrichedSchemaFields?.map(field => field.name)
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
$: enrichedSchemaFields = getFields(Object.values(schema || {}), {
allowLinks: true,
})
const getSchema = (asset, datasource) => {
const schema = getSchemaForDatasource(asset, datasource).schema
// Don't show ID and rev in tables
if (schema) {
delete schema._id
delete schema._rev
}
return schema
}
const updateBoundValue = value => {
boundValue = cloneDeep(value)
}
const getValidColumns = (columns, options) => {
if (!Array.isArray(columns) || !columns.length) {
return []
}
// We need to account for legacy configs which would just be an array
// of strings
if (typeof columns[0] === "string") {
columns = columns.map(col => ({
name: col,
displayName: col,
}))
}
return columns.filter(column => {
return options.includes(column.name)
})
}
const open = () => {
updateBoundValue(sanitisedValue)
drawer.show()
}
const save = () => {
dispatch("change", getValidColumns(boundValue, options))
drawer.hide()
}
</script>
<ActionButton on:click={open}>Configure columns</ActionButton>
<Drawer bind:this={drawer} title="{subject} Columns">
<svelte:fragment slot="description">
Configure the columns in your {subject.toLowerCase()}.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer
slot="body"
bind:columns={boundValue}
{options}
{schema}
{allowCellEditing}
/>
</Drawer>

View File

@ -3,21 +3,24 @@
Body,
Button,
Combobox,
Multiselect,
DatePicker,
DrawerContent,
Icon,
Input,
Layout,
Select,
Label,
Layout,
Multiselect,
Select,
} from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid"
import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { Constants, LuceneUtils } from "@budibase/frontend-core"
import { getFields } from "helpers/searchFields"
import { FieldType } from "@budibase/types"
import { createEventDispatcher, onMount } from "svelte"
import FilterUsers from "./FilterUsers.svelte"
import { RelationshipType } from "constants/backend"
export let schemaFields
export let filters = []
@ -29,7 +32,6 @@
const dispatch = createEventDispatcher()
const { OperatorOptions } = Constants
const { getValidOperatorsForType } = LuceneUtils
const KeyedFieldRegex = /\d[0-9]*:/g
const behaviourOptions = [
{ value: "and", label: "Match all filters" },
@ -120,7 +122,7 @@
return enrichedSchemaFields.find(field => field.name === filter.field)
}
const santizeTypes = filter => {
const sanitizeTypes = filter => {
// Update type based on field
const fieldSchema = enrichedSchemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type
@ -129,13 +131,9 @@
filter.externalType = getSchema(filter)?.externalType
}
const santizeOperator = filter => {
const sanitizeOperator = filter => {
// Ensure a valid operator is selected
const operators = getValidOperatorsForType(
filter.type,
filter.field,
datasource
).map(x => x.value)
const operators = getValidOperatorsForType(filter).map(x => x.value)
if (!operators.includes(filter.operator)) {
filter.operator = operators[0] ?? OperatorOptions.Equals.value
}
@ -148,7 +146,7 @@
filter.noValue = noValueOptions.includes(filter.operator)
}
const santizeValue = filter => {
const sanitizeValue = (filter, previousType) => {
// Check if the operator allows a value at all
if (filter.noValue) {
filter.value = null
@ -162,28 +160,47 @@
}
} else if (filter.type === "array" && filter.valueType === "Value") {
filter.value = []
} else if (
previousType !== filter.type &&
(previousType === FieldType.BB_REFERENCE ||
filter.type === FieldType.BB_REFERENCE)
) {
filter.value = filter.type === "array" ? [] : null
}
}
const onFieldChange = filter => {
santizeTypes(filter)
santizeOperator(filter)
santizeValue(filter)
const previousType = filter.type
sanitizeTypes(filter)
sanitizeOperator(filter)
sanitizeValue(filter, previousType)
}
const onOperatorChange = filter => {
santizeOperator(filter)
santizeValue(filter)
sanitizeOperator(filter)
sanitizeValue(filter, filter.type)
}
const onValueTypeChange = filter => {
santizeValue(filter)
sanitizeValue(filter)
}
const getFieldOptions = field => {
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getValidOperatorsForType = filter => {
if (!filter?.field) {
return []
}
return LuceneUtils.getValidOperatorsForType(
filter.type,
filter.field,
datasource
)
}
</script>
<DrawerContent>
@ -228,11 +245,7 @@
/>
<Select
disabled={!filter.field}
options={getValidOperatorsForType(
filter.type,
filter.field,
datasource
)}
options={getValidOperatorsForType(filter)}
bind:value={filter.operator}
on:change={() => onOperatorChange(filter)}
placeholder={null}
@ -285,6 +298,14 @@
timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.BB_REFERENCE}
<FilterUsers
bind:value={filter.value}
multiselect={getSchema(filter).relationshipType ===
RelationshipType.MANY_TO_MANY ||
filter.operator === OperatorOptions.In.value}
disabled={filter.noValue}
/>
{:else}
<DrawerBindableInput disabled />
{/if}

View File

@ -0,0 +1,34 @@
<script>
import { Select, Multiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
export let value = null
export let disabled
export let multiselect = false
$: fetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
limit: 100,
},
})
$: options = $fetch.rows
$: component = multiselect ? Multiselect : Select
</script>
<svelte:component
this={component}
bind:value
autocomplete
{options}
getOptionLabel={option => option.email}
getOptionValue={option => option._id}
{disabled}
/>

View File

@ -20,7 +20,9 @@
const getSortableFields = schema => {
return Object.entries(schema || {})
.filter(entry => !UNSORTABLE_TYPES.includes(entry[1].type))
.filter(
entry => !UNSORTABLE_TYPES.includes(entry[1].type) && entry[1].sortable
)
.map(entry => entry[0])
}

View File

@ -62,7 +62,14 @@
</div>
{/if}
<div class="truncate">
<Body>{getSubtitle(datasource)}</Body>
<Body>
{@const subtitle = getSubtitle(datasource)}
{#if subtitle}
{subtitle}
{:else}
{Object.values(datasource.config).join(" / ")}
{/if}
</Body>
</div>
</div>
<div class="right">

View File

@ -136,6 +136,7 @@ export function createDatasourcesStore() {
config,
name: `${integration.friendlyName}${nameModifier}`,
plus: integration.plus && integration.name !== IntegrationTypes.REST,
isSQL: integration.isSQL,
}
if (await checkDatasourceValidity(integration, datasource)) {

View File

@ -57,7 +57,8 @@ export async function checkDockerConfigured() {
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
const composeV2 = await lookpath("docker compose")
if (!docker || (!compose && !composeV2)) {
throw error
}
}

View File

@ -12,6 +12,10 @@ if (!process.argv[0].includes("node")) {
checkForBinaries()
}
function localPrebuildPath() {
return join(process.execPath, "..", PREBUILDS)
}
function checkForBinaries() {
const readDir = join(__filename, "..", "..", "..", "cli", PREBUILDS, ARCH)
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
@ -19,17 +23,21 @@ function checkForBinaries() {
}
const natives = fs.readdirSync(readDir)
if (fs.existsSync(readDir)) {
const writePath = join(process.execPath, PREBUILDS, ARCH)
const writePath = join(localPrebuildPath(), ARCH)
fs.mkdirSync(writePath, { recursive: true })
for (let native of natives) {
const filename = `${native.split(".fake")[0]}.node`
fs.cpSync(join(readDir, native), join(writePath, filename))
}
console.log("copied something")
}
}
function cleanup(evt?: number) {
// cleanup prebuilds first
const path = localPrebuildPath()
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true })
}
if (evt && !isNaN(evt)) {
return
}
@ -41,10 +49,6 @@ function cleanup(evt?: number) {
)
console.error(error(evt))
}
const path = join(process.execPath, PREBUILDS)
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true })
}
}
const events = ["exit", "SIGINT", "SIGUSR1", "SIGUSR2", "uncaughtException"]

View File

@ -5598,6 +5598,21 @@
}
]
},
{
"type": "event",
"label": "On row click",
"key": "onRowClick",
"context": [
{
"label": "Clicked row",
"key": "row"
}
],
"dependsOn": {
"setting": "allowEditRows",
"value": false
}
},
{
"type": "boolean",
"label": "Add rows",

View File

@ -14,12 +14,14 @@
export let initialSortOrder = null
export let fixedRowHeight = null
export let columns = null
export let onRowClick = null
const component = getContext("component")
const { styleable, API, builderStore, notificationStore } = getContext("sdk")
$: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns)
$: handleRowClick = allowEditRows ? undefined : onRowClick
const getSchemaOverrides = columns => {
let overrides = {}
@ -56,6 +58,7 @@
showControls={false}
notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error}
on:rowclick={e => handleRowClick?.({ row: e.detail })}
/>
</div>

View File

@ -17,13 +17,24 @@
const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher()
const select = () => {
const select = e => {
e.stopPropagation()
svelteDispatch("select")
const id = row?._id
if (id) {
selectedRows.actions.toggleRow(id)
}
}
const bulkDelete = e => {
e.stopPropagation()
dispatch("request-bulk-delete")
}
const expand = e => {
e.stopPropagation()
svelteDispatch("expand")
}
</script>
<GridCell
@ -56,7 +67,7 @@
{/if}
{/if}
{#if rowSelected && $config.canDeleteRows}
<div class="delete" on:click={() => dispatch("request-bulk-delete")}>
<div class="delete" on:click={bulkDelete}>
<Icon
name="Delete"
size="S"
@ -65,12 +76,7 @@
</div>
{:else}
<div class="expand" class:visible={$config.canExpandRows && expandable}>
<Icon
size="S"
name="Maximize"
hoverable
on:click={() => svelteDispatch("expand")}
/>
<Icon size="S" name="Maximize" hoverable on:click={expand} />
</div>
{/if}
</div>

View File

@ -35,7 +35,7 @@
</script>
<div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive>
<GridScrollWrapper scrollHorizontally scrollVertically attachHandlers>
{#each $renderedRows as row, idx}
<GridRow
{row}

View File

@ -17,6 +17,7 @@
columnHorizontalInversionIndex,
contentLines,
isDragging,
dispatch,
} = getContext("grid")
$: rowSelected = !!$selectedRows[row._id]
@ -30,6 +31,7 @@
on:focus
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", row)}
>
{#each $renderedColumns as column, columnIdx (column.name)}
{@const cellId = `${row._id}-${column.name}`}

View File

@ -17,7 +17,11 @@
export let scrollVertically = false
export let scrollHorizontally = false
export let wheelInteractive = false
export let attachHandlers = false
// Used for tracking touch events
let initialTouchX
let initialTouchY
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
@ -27,17 +31,47 @@
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
}
// Handles a wheel even and updates the scroll offsets
// Handles a mouse wheel event and updates scroll state
const handleWheel = e => {
e.preventDefault()
debouncedHandleWheel(e.deltaX, e.deltaY, e.clientY)
updateScroll(e.deltaX, e.deltaY, e.clientY)
// If a context menu was visible, hide it
if ($menu.visible) {
menu.actions.close()
}
}
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
// Handles touch start events
const handleTouchStart = e => {
if (!e.touches?.[0]) return
initialTouchX = e.touches[0].clientX
initialTouchY = e.touches[0].clientY
}
// Handles touch move events and updates scroll state
const handleTouchMove = e => {
if (!e.touches?.[0]) return
e.preventDefault()
// Compute delta from previous event, and update scroll
const deltaX = initialTouchX - e.touches[0].clientX
const deltaY = initialTouchY - e.touches[0].clientY
updateScroll(deltaX, deltaY)
// Store position to reference in next event
initialTouchX = e.touches[0].clientX
initialTouchY = e.touches[0].clientY
// If a context menu was visible, hide it
if ($menu.visible) {
menu.actions.close()
}
}
// Updates the scroll offset by a certain delta, and ensure scrolling
// stays within sensible bounds. Debounced for performance.
const updateScroll = domDebounce((deltaX, deltaY, clientY) => {
const { top, left } = $scroll
// Calculate new scroll top
@ -55,15 +89,19 @@
})
// Hover row under cursor
if (clientY != null) {
const y = clientY - $bounds.top + (newScrollTop % $rowHeight)
const hoveredRow = $renderedRows[Math.floor(y / $rowHeight)]
hoveredRowId.set(hoveredRow?._id)
}
})
</script>
<div
class="outer"
on:wheel={wheelInteractive ? handleWheel : null}
on:wheel={attachHandlers ? handleWheel : null}
on:touchstart={attachHandlers ? handleTouchStart : null}
on:touchmove={attachHandlers ? handleTouchMove : null}
on:click|self={() => ($focusedCellId = null)}
>
<div {style} class="inner">

View File

@ -205,7 +205,7 @@
{/if}
</div>
<div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally wheelInteractive>
<GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row">
{#each $renderedColumns as column, columnIdx}
{@const cellId = `new-${column.name}`}

View File

@ -64,7 +64,7 @@
</div>
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<GridScrollWrapper scrollVertically wheelInteractive>
<GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
@ -74,6 +74,7 @@
class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", row)}
>
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
{#if $stickyColumn}

View File

@ -53,18 +53,27 @@
}
}
const getLocation = e => {
return {
y: e.touches?.[0]?.clientY ?? e.clientY,
x: e.touches?.[0]?.clientX ?? e.clientX,
}
}
// V scrollbar drag handlers
const startVDragging = e => {
e.preventDefault()
initialMouse = e.clientY
initialMouse = getLocation(e).y
initialScroll = $scrollTop
document.addEventListener("mousemove", moveVDragging)
document.addEventListener("touchmove", moveVDragging)
document.addEventListener("mouseup", stopVDragging)
document.addEventListener("touchend", stopVDragging)
isDraggingV = true
closeMenu()
}
const moveVDragging = domDebounce(e => {
const delta = e.clientY - initialMouse
const delta = getLocation(e).y - initialMouse
const weight = delta / availHeight
const newScrollTop = initialScroll + weight * $maxScrollTop
scroll.update(state => ({
@ -74,22 +83,26 @@
})
const stopVDragging = () => {
document.removeEventListener("mousemove", moveVDragging)
document.removeEventListener("touchmove", moveVDragging)
document.removeEventListener("mouseup", stopVDragging)
document.removeEventListener("touchend", stopVDragging)
isDraggingV = false
}
// H scrollbar drag handlers
const startHDragging = e => {
e.preventDefault()
initialMouse = e.clientX
initialMouse = getLocation(e).x
initialScroll = $scrollLeft
document.addEventListener("mousemove", moveHDragging)
document.addEventListener("touchmove", moveHDragging)
document.addEventListener("mouseup", stopHDragging)
document.addEventListener("touchend", stopHDragging)
isDraggingH = true
closeMenu()
}
const moveHDragging = domDebounce(e => {
const delta = e.clientX - initialMouse
const delta = getLocation(e).x - initialMouse
const weight = delta / availWidth
const newScrollLeft = initialScroll + weight * $maxScrollLeft
scroll.update(state => ({
@ -99,7 +112,9 @@
})
const stopHDragging = () => {
document.removeEventListener("mousemove", moveHDragging)
document.removeEventListener("touchmove", moveHDragging)
document.removeEventListener("mouseup", stopHDragging)
document.removeEventListener("touchend", stopHDragging)
isDraggingH = false
}
</script>
@ -109,6 +124,7 @@
class="v-scrollbar"
style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;"
on:mousedown={startVDragging}
on:touchstart={startVDragging}
class:dragging={isDraggingV}
/>
{/if}
@ -117,6 +133,7 @@
class="h-scrollbar"
style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;"
on:mousedown={startHDragging}
on:touchstart={startHDragging}
class:dragging={isDraggingH}
/>
{/if}

View File

@ -1,4 +1,5 @@
import { writable, get } from "svelte/store"
import { Helpers } from "@budibase/bbui"
export const createStores = () => {
const copiedCell = writable(null)
@ -12,7 +13,16 @@ export const createActions = context => {
const { copiedCell, focusedCellAPI } = context
const copy = () => {
copiedCell.set(get(focusedCellAPI)?.getValue())
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
}
Helpers.copyToClipboard(stringified)
}
const paste = () => {

View File

@ -2,7 +2,7 @@ version: "3.8"
services:
db:
container_name: postgres
image: postgres
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: root

View File

@ -308,12 +308,19 @@ class LinkController {
}
})
)
// remove schema from other table
try {
// remove schema from other table, if it exists
let linkedTable = await this._db.get<Table>(field.tableId)
if (field.fieldName) {
delete linkedTable.schema[field.fieldName]
}
await this._db.put(linkedTable)
} catch (error: any) {
// ignore missing to ensure broken relationship columns can be deleted
if (error.statusCode !== 404) {
throw error
}
}
}
/**

View File

@ -233,4 +233,19 @@ describe("test the link controller", () => {
}
await config.updateTable(table)
})
it("should be able to remove a linked field from a table, even if the linked table does not exist", async () => {
await createLinkedRow()
await createLinkedRow("link2")
table1.schema["link"].tableId = "not_found"
const controller = await createLinkController(table1, null, table1)
await context.doInAppContext(appId, async () => {
let before = await controller.getTableLinkDocs()
await controller.removeFieldFromTable("link")
let after = await controller.getTableLinkDocs()
expect(before.length).toEqual(2)
// shouldn't delete the other field
expect(after.length).toEqual(1)
})
})
})

View File

@ -16,6 +16,7 @@ import { cleanExportRows } from "../utils"
import { utils } from "@budibase/shared-core"
import { ExportRowsParams, ExportRowsResult } from "../search"
import { HTTPError, db } from "@budibase/backend-core"
import { searchInputMapping } from "./utils"
import pick from "lodash/pick"
import { outputProcessing } from "../../../../utilities/rowProcessor"
@ -50,7 +51,10 @@ export async function search(options: SearchParams) {
[params.sort]: { direction },
}
}
try {
const table = await sdk.tables.getTable(tableId)
options = searchInputMapping(table, options)
let rows = (await handleRequest(Operation.READ, tableId, {
filters: query,
sort,
@ -76,7 +80,6 @@ export async function search(options: SearchParams) {
rows = rows.map((r: any) => pick(r, fields))
}
const table = await sdk.tables.getTable(tableId)
rows = await outputProcessing(table, rows, { preserveLinks: true })
// need wrapper object for bookmarks etc when paginating

View File

@ -29,6 +29,7 @@ import {
} from "../../../../api/controllers/view/utils"
import sdk from "../../../../sdk"
import { ExportRowsParams, ExportRowsResult } from "../search"
import { searchInputMapping } from "./utils"
import pick from "lodash/pick"
export async function search(options: SearchParams) {
@ -47,9 +48,9 @@ export async function search(options: SearchParams) {
disableEscaping: options.disableEscaping,
}
let table
let table = await sdk.tables.getTable(tableId)
options = searchInputMapping(table, options)
if (params.sort && !params.sortType) {
table = await sdk.tables.getTable(tableId)
const schema = table.schema
const sortField = schema[params.sort]
params.sortType = sortField.type === "number" ? "number" : "string"
@ -68,7 +69,6 @@ export async function search(options: SearchParams) {
if (tableId === InternalTables.USER_METADATA) {
response.rows = await getGlobalUsersFromMetadata(response.rows)
}
table = table || (await sdk.tables.getTable(tableId))
if (options.fields) {
const fields = [...options.fields, ...db.CONSTANT_INTERNAL_ROW_COLS]

View File

@ -0,0 +1,77 @@
import { searchInputMapping } from "../utils"
import { db as dbCore } from "@budibase/backend-core"
import {
FieldType,
FieldTypeSubtypes,
Table,
SearchParams,
} from "@budibase/types"
const tableId = "ta_a"
const tableWithUserCol: Table = {
_id: tableId,
name: "table",
schema: {
user: {
name: "user",
type: FieldType.BB_REFERENCE,
subtype: FieldTypeSubtypes.BB_REFERENCE.USER,
},
},
}
describe("searchInputMapping", () => {
const globalUserId = dbCore.generateGlobalUserID()
const userMedataId = dbCore.generateUserMetadataID(globalUserId)
it("should be able to map ro_ to global user IDs", () => {
const params: SearchParams = {
tableId,
query: {
equal: {
"1:user": userMedataId,
},
},
}
const output = searchInputMapping(tableWithUserCol, params)
expect(output.query.equal!["1:user"]).toBe(globalUserId)
})
it("should handle array of user IDs", () => {
const params: SearchParams = {
tableId,
query: {
oneOf: {
"1:user": [userMedataId, globalUserId],
},
},
}
const output = searchInputMapping(tableWithUserCol, params)
expect(output.query.oneOf!["1:user"]).toStrictEqual([
globalUserId,
globalUserId,
])
})
it("shouldn't change any other input", () => {
const email = "test@test.com"
const params: SearchParams = {
tableId,
query: {
equal: {
"1:user": email,
},
},
}
const output = searchInputMapping(tableWithUserCol, params)
expect(output.query.equal!["1:user"]).toBe(email)
})
it("shouldn't error if no query supplied", () => {
const params: any = {
tableId,
}
const output = searchInputMapping(tableWithUserCol, params)
expect(output.query).toBeUndefined()
})
})

View File

@ -0,0 +1,76 @@
import {
FieldType,
FieldTypeSubtypes,
SearchParams,
Table,
DocumentType,
SEPARATOR,
} from "@budibase/types"
import { db as dbCore } from "@budibase/backend-core"
function findColumnInQueries(
column: string,
options: SearchParams,
callback: (filter: any) => any
) {
if (!options.query) {
return
}
for (let filterBlock of Object.values(options.query)) {
if (typeof filterBlock !== "object") {
continue
}
for (let [key, filter] of Object.entries(filterBlock)) {
if (key.endsWith(column)) {
filterBlock[key] = callback(filter)
}
}
}
}
function userColumnMapping(column: string, options: SearchParams) {
findColumnInQueries(column, options, (filterValue: any): any => {
const isArray = Array.isArray(filterValue),
isString = typeof filterValue === "string"
if (!isString && !isArray) {
return filterValue
}
const processString = (input: string) => {
const rowPrefix = DocumentType.ROW + SEPARATOR
if (input.startsWith(rowPrefix)) {
return dbCore.getGlobalIDFromUserMetadataID(input)
} else {
return input
}
}
if (isArray) {
return filterValue.map(el => {
if (typeof el === "string") {
return processString(el)
} else {
return el
}
})
} else {
return processString(filterValue)
}
})
}
// maps through the search parameters to check if any of the inputs are invalid
// based on the table schema, converts them to something that is valid.
export function searchInputMapping(table: Table, options: SearchParams) {
if (!table?.schema) {
return options
}
for (let [key, column] of Object.entries(table.schema)) {
switch (column.type) {
case FieldType.BB_REFERENCE:
if (column.subtype === FieldTypeSubtypes.BB_REFERENCE.USER) {
userColumnMapping(key, options)
}
break
}
}
return options
}

View File

@ -14,7 +14,6 @@ const HBS_REGEX = /{{([^{].*?)}}/g
/**
* Returns the valid operator options for a certain data type
* @param type the data type
*/
export const getValidOperatorsForType = (
type: FieldType,
@ -44,22 +43,24 @@ export const getValidOperatorsForType = (
value: string
label: string
}[] = []
if (type === "string") {
if (type === FieldType.STRING) {
ops = stringOps
} else if (type === "number" || type === "bigint") {
} else if (type === FieldType.NUMBER || type === FieldType.BIGINT) {
ops = numOps
} else if (type === "options") {
} else if (type === FieldType.OPTIONS) {
ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In]
} else if (type === "array") {
} else if (type === FieldType.ARRAY) {
ops = [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty, Op.ContainsAny]
} else if (type === "boolean") {
} else if (type === FieldType.BOOLEAN) {
ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
} else if (type === FieldType.LONGFORM) {
ops = stringOps
} else if (type === "datetime") {
} else if (type === FieldType.DATETIME) {
ops = numOps
} else if (type === "formula") {
} else if (type === FieldType.FORMULA) {
ops = stringOps.concat([Op.MoreThan, Op.LessThan])
} else if (type === FieldType.BB_REFERENCE) {
ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In]
}
// Only allow equal/not equal for _id in SQL tables

View File

@ -14,5 +14,5 @@ export function isSQL(datasource: Datasource): boolean {
SourceName.MYSQL,
SourceName.ORACLE,
]
return SQL.indexOf(datasource.source) !== -1
return SQL.indexOf(datasource.source) !== -1 || datasource.isSQL === true
}

View File

@ -9,6 +9,7 @@ export interface Datasource extends Document {
// the config is defined by the schema
config?: Record<string, any>
plus?: boolean
isSQL?: boolean
entities?: {
[key: string]: Table
}

View File

@ -140,6 +140,7 @@ export interface DatasourceConfig {
export interface Integration {
docs: string
plus?: boolean
isSQL?: boolean
auth?: { type: string }
features?: Partial<Record<DatasourceFeature, boolean>>
relationships?: boolean