Merge branch 'master' of github.com:Budibase/budibase into state-and-bindings-panels

This commit is contained in:
Andrew Kingston 2025-01-17 15:44:19 +00:00
commit ea8a746fc4
No known key found for this signature in database
112 changed files with 1845 additions and 1135 deletions

View File

@ -8,41 +8,15 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
with:
days-before-stale: 330
operations-per-run: 1
# stale rules for PRs
days-before-pr-stale: 7
stale-issue-label: stale
exempt-pr-labels: pinned,security,roadmap
days-before-pr-close: 7
days-before-issue-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for high priority bugs
days-before-stale: 30
only-issue-labels: bug,High priority
stale-issue-label: warn
days-before-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for medium priority bugs
days-before-stale: 90
only-issue-labels: bug,Medium priority
stale-issue-label: warn
days-before-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for all bugs
days-before-stale: 180
stale-issue-label: stale
only-issue-labels: bug
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for six months."
days-before-close: 30
- uses: actions/stale@v8
with:
# Issues
days-before-stale: 180
stale-issue-label: stale
days-before-close: 30
stale-issue-message: "This issue has been automatically marked as stale as there has been no activity for 6 months."
# Pull requests
days-before-pr-stale: 7
days-before-pr-close: 14
exempt-pr-labels: pinned,security,roadmap
operations-per-run: 100

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.40",
"version": "3.2.46",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

@ -21,7 +21,7 @@
"scripts": {
"prebuild": "rimraf dist/",
"prepack": "cp package.json dist",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null && tsc -p tsconfig.test.json --paths null",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"build:oss": "node ./scripts/build.js",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",

View File

@ -1,6 +1,6 @@
import env from "../../environment"
export const getCouchInfo = (connection?: string) => {
export const getCouchInfo = (connection?: string | null) => {
// clean out any auth credentials
const urlInfo = getUrlInfo(connection)
let username
@ -45,7 +45,7 @@ export const getCouchInfo = (connection?: string) => {
}
}
export const getUrlInfo = (url = env.COUCH_DB_URL) => {
export const getUrlInfo = (url: string | null = env.COUCH_DB_URL) => {
let cleanUrl, username, password, host
if (url) {
// Ensure the URL starts with a protocol

View File

@ -1,5 +1,6 @@
require("../../../tests")
const getUrlInfo = require("../couch").getUrlInfo
import { getUrlInfo } from "../couch"
describe("pouch", () => {
describe("Couch DB URL parsing", () => {

View File

@ -1172,20 +1172,22 @@ class InternalBuilder {
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
const composite = `${aliased}.${key}`
let identifier
if (this.isAggregateField(key)) {
query = query.orderBy(key, direction, nulls)
identifier = this.rawQuotedIdentifier(key)
} else if (this.client === SqlClient.ORACLE) {
identifier = this.convertClobs(composite)
} else {
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderByRaw(`?? ?? nulls ??`, [
this.convertClobs(composite),
this.knex.raw(direction),
this.knex.raw(nulls as string),
])
} else {
query = query.orderBy(composite, direction, nulls)
}
identifier = this.rawQuotedIdentifier(composite)
}
query = query.orderByRaw(`?? ?? ${nulls ? "nulls ??" : ""}`, [
identifier,
this.knex.raw(direction),
...(nulls ? [this.knex.raw(nulls as string)] : []),
])
}
}
@ -1344,14 +1346,16 @@ class InternalBuilder {
// add the correlation to the overall query
subQuery = subQuery.where(
correlatedTo,
this.rawQuotedIdentifier(correlatedTo),
"=",
this.rawQuotedIdentifier(correlatedFrom)
)
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
subQuery = subQuery
.select(relationshipFields)
.select(
relationshipFields.map(field => this.rawQuotedIdentifier(field))
)
.limit(getRelationshipLimit())
// @ts-ignore - the from alias syntax isn't in Knex typing
return knex.select(select).from({

View File

@ -1,17 +1,17 @@
const _ = require("lodash/fp")
const { structures } = require("../../../tests")
import { range } from "lodash/fp"
import { structures } from "../.."
jest.mock("../../../src/context")
jest.mock("../../../src/db")
const context = require("../../../src/context")
const db = require("../../../src/db")
import * as context from "../../../src/context"
import * as db from "../../../src/db"
const { getCreatorCount } = require("../../../src/users/users")
import { getCreatorCount } from "../../../src/users/users"
describe("Users", () => {
let getGlobalDBMock
let paginationMock
let getGlobalDBMock: jest.SpyInstance
let paginationMock: jest.SpyInstance
beforeEach(() => {
jest.resetAllMocks()
@ -22,11 +22,10 @@ describe("Users", () => {
jest.spyOn(db, "getGlobalUserParams")
})
it("Retrieves the number of creators", async () => {
const getUsers = (offset, limit, creators = false) => {
const range = _.range(offset, limit)
it("retrieves the number of creators", async () => {
const getUsers = (offset: number, limit: number, creators = false) => {
const opts = creators ? { builder: { global: true } } : undefined
return range.map(() => structures.users.user(opts))
return range(offset, limit).map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "dist",
"sourceMap": true
},
"include": ["tests/**/*.js", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -3,7 +3,7 @@
"description": "A UI solution used in the different Budibase projects.",
"version": "0.0.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"svelte": "src/index.ts",
"module": "dist/bbui.mjs",
"exports": {
".": {
@ -14,7 +14,8 @@
"./spectrum-icons-vite.js": "./src/spectrum-icons-vite.js"
},
"scripts": {
"build": "vite build"
"build": "vite build",
"dev": "vite build --watch --mode=dev"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "1.4.0",

View File

@ -10,12 +10,12 @@
export let size = "M"
export let hoverable = false
export let disabled = false
export let color
export let hoverColor
export let tooltip
export let color = undefined
export let hoverColor = undefined
export let tooltip = undefined
export let tooltipPosition = TooltipPosition.Bottom
export let tooltipType = TooltipType.Default
export let tooltipColor
export let tooltipColor = undefined
export let tooltipWrap = true
export let newStyles = false
</script>

View File

@ -4,7 +4,7 @@
export let size = "M"
export let tooltip = ""
export let muted
export let muted = undefined
</script>
<TooltipWrapper {tooltip} {size}>

View File

@ -2,10 +2,10 @@
import "@spectrum-css/typography/dist/index-vars.css"
// Sizes
export let size = "M"
export let textAlign = undefined
export let noPadding = false
export let weight = "default" // light, heavy, default
export let size: "XS" | "S" | "M" | "L" = "M"
export let textAlign: string | undefined = undefined
export let noPadding: boolean = false
export let weight: "light" | "heavy" | "default" = "default"
</script>
<h1

View File

@ -1,3 +0,0 @@
declare module "./helpers" {
export const cloneDeep: <T>(obj: T) => T
}

View File

@ -6,9 +6,8 @@ export const deepGet = helpers.deepGet
/**
* Generates a DOM safe UUID.
* Starting with a letter is important to make it DOM safe.
* @return {string} a random DOM safe UUID
*/
export function uuid() {
export function uuid(): string {
return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
@ -18,22 +17,18 @@ export function uuid() {
/**
* Capitalises a string
* @param string the string to capitalise
* @return {string} the capitalised string
*/
export const capitalise = string => {
export const capitalise = (string?: string | null): string => {
if (!string) {
return string
return ""
}
return string.substring(0, 1).toUpperCase() + string.substring(1)
}
/**
* Computes a short hash of a string
* @param string the string to compute a hash of
* @return {string} the hash string
*/
export const hashString = string => {
export const hashString = (string?: string | null): string => {
if (!string) {
return "0"
}
@ -54,11 +49,12 @@ export const hashString = string => {
* will override the value "foo" rather than "bar".
* If a deep path is specified and the parent keys don't exist then these will
* be created.
* @param obj the object
* @param key the key
* @param value the value
*/
export const deepSet = (obj, key, value) => {
export const deepSet = (
obj: Record<string, any> | null,
key: string | null,
value: any
): void => {
if (!obj || !key) {
return
}
@ -82,9 +78,8 @@ export const deepSet = (obj, key, value) => {
/**
* Deeply clones an object. Functions are not supported.
* @param obj the object to clone
*/
export const cloneDeep = obj => {
export const cloneDeep = <T>(obj: T): T => {
if (!obj) {
return obj
}
@ -93,9 +88,8 @@ export const cloneDeep = obj => {
/**
* Copies a value to the clipboard
* @param value the value to copy
*/
export const copyToClipboard = value => {
export const copyToClipboard = (value: any): Promise<void> => {
return new Promise(res => {
if (navigator.clipboard && window.isSecureContext) {
// Try using the clipboard API first
@ -117,9 +111,12 @@ export const copyToClipboard = value => {
})
}
// Parsed a date value. This is usually an ISO string, but can be a
// Parse a date value. This is usually an ISO string, but can be a
// bunch of different formats and shapes depending on schema flags.
export const parseDate = (value, { enableTime = true }) => {
export const parseDate = (
value: string | dayjs.Dayjs | null,
{ enableTime = true }
): dayjs.Dayjs | null => {
// If empty then invalid
if (!value) {
return null
@ -128,7 +125,7 @@ export const parseDate = (value, { enableTime = true }) => {
// Certain string values need transformed
if (typeof value === "string") {
// Check for time only values
if (!isNaN(new Date(`0-${value}`))) {
if (!isNaN(new Date(`0-${value}`).valueOf())) {
value = `0-${value}`
}
@ -153,9 +150,9 @@ export const parseDate = (value, { enableTime = true }) => {
// Stringifies a dayjs object to create an ISO string that respects the various
// schema flags
export const stringifyDate = (
value,
value: null | dayjs.Dayjs,
{ enableTime = true, timeOnly = false, ignoreTimezones = false } = {}
) => {
): string | null => {
if (!value) {
return null
}
@ -192,7 +189,7 @@ export const stringifyDate = (
}
// Determine the dayjs-compatible format of the browser's default locale
const getPatternForPart = part => {
const getPatternForPart = (part: Intl.DateTimeFormatPart): string => {
switch (part.type) {
case "day":
return "D".repeat(part.value.length)
@ -214,9 +211,9 @@ const localeDateFormat = new Intl.DateTimeFormat()
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value,
value: dayjs.Dayjs | null,
{ enableTime = true, timeOnly = false } = {}
) => {
): string => {
if (!value?.isValid()) {
return ""
}
@ -229,7 +226,7 @@ export const getDateDisplayValue = (
}
}
export const hexToRGBA = (color, opacity) => {
export const hexToRGBA = (color: string, opacity: number): string => {
if (color.includes("#")) {
color = color.replace("#", "")
}

View File

@ -0,0 +1,7 @@
const { vitePreprocess } = require("@sveltejs/vite-plugin-svelte")
const config = {
preprocess: vitePreprocess(),
}
module.exports = config

View File

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"allowJs": true,
"outDir": "./dist",
"lib": ["ESNext"],
"baseUrl": ".",
"paths": {
"@budibase/*": [
"../*/src/index.ts",
"../*/src/index.js",
"../*",
"../../node_modules/@budibase/*"
]
}
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
}

View File

@ -9,7 +9,7 @@ export default defineConfig(({ mode }) => {
build: {
sourcemap: !isProduction,
lib: {
entry: "src/index.js",
entry: "src/index.ts",
formats: ["es"],
},
},

View File

@ -74,7 +74,6 @@
"dayjs": "^1.10.8",
"downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1",
"json-format-highlight": "^1.0.4",
"lodash": "4.17.21",
"posthog-js": "^1.118.0",
"remixicon": "2.5.0",
@ -94,6 +93,7 @@
"@sveltejs/vite-plugin-svelte": "1.4.0",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/svelte": "^4.1.0",
"@types/sanitize-html": "^2.13.0",
"@types/shortid": "^2.2.0",
"babel-jest": "^29.6.2",
"identity-obj-proxy": "^3.0.0",

View File

@ -9,7 +9,7 @@
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { flags } from "@/stores/builder"
import { featureFlags, licensing } from "@/stores/portal"
import { licensing } from "@/stores/portal"
import { API } from "@/api"
import MagicWand from "../../../../assets/MagicWand.svelte"
@ -27,8 +27,7 @@
let loadingAICronExpression = false
$: aiEnabled =
($featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled) ||
($featureFlags.BUDIBASE_AI && $licensing.budibaseAIEnabled)
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: {
if (cronExpression) {
try {

View File

@ -26,7 +26,7 @@
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "@/stores/builder"
import { featureFlags } from "@/stores/portal"
import { licensing } from "@/stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
import {
FIELDS,
@ -49,7 +49,6 @@
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled } from "@/helpers/featureFlags"
import { getUserBindings } from "@/dataBinding"
export let field
@ -101,7 +100,8 @@
let optionsValid = true
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled = $featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
@ -168,7 +168,6 @@
// used to select what different options can be displayed for column type
$: canBeDisplay =
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
$: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired =
editableColumn?.type !== FieldType.LINK &&
@ -300,7 +299,7 @@
}
// Ensure we don't have a default value if we can't have one
if (!canHaveDefault || !defaultValuesEnabled) {
if (!canHaveDefault) {
delete saveColumn.default
}
@ -848,51 +847,49 @@
</div>
{/if}
{#if defaultValuesEnabled}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
</Layout>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
@ -12,7 +12,6 @@
completionStatus,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
@ -25,6 +24,7 @@
MatchDecorator,
ViewPlugin,
Decoration,
EditorView,
} from "@codemirror/view"
import {
bracketMatching,
@ -44,12 +44,14 @@
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types"
export let label
export let completions = []
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
export let label: string | undefined = undefined
// TODO: work out what best type fits this
export let completions: any[] = []
export let mode: EditorMode = EditorModes.Handlebars
export let value: string | null = ""
export let placeholder: string | null = null
export let autocompleteEnabled = true
export let autofocus = false
export let jsBindingWrapping = true
@ -58,8 +60,8 @@
const dispatch = createEventDispatcher()
let textarea
let editor
let textarea: HTMLDivElement
let editor: EditorView
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
@ -100,15 +102,22 @@
/**
* Will refresh the editor contents only after
* it has been fully initialised
* @param value {string} the editor value
*/
const refresh = (value, initialised, mounted) => {
const refresh = (
value: string | null,
initialised?: boolean,
mounted?: boolean
) => {
if (!initialised || !mounted) {
queuedRefresh = true
return
}
if (editor.state.doc.toString() !== value || queuedRefresh) {
if (
editor &&
value &&
(editor.state.doc.toString() !== value || queuedRefresh)
) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
})
@ -120,12 +129,17 @@
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
start: selection_range?.from,
end: selection_range?.to,
}
}
export const insertAtPos = opts => {
export const insertAtPos = (opts: {
start: number
end?: number
value: string
cursor: { anchor: number }
}) => {
// Updating the value inside.
// Retain focus
editor.dispatch({
@ -192,7 +206,7 @@
const indentWithTabCustom = {
key: "Tab",
run: view => {
run: (view: EditorView) => {
if (completionStatus(view.state) === "active") {
acceptCompletion(view)
return true
@ -200,7 +214,7 @@
indentMore(view)
return true
},
shift: view => {
shift: (view: EditorView) => {
indentLess(view)
return true
},
@ -232,7 +246,8 @@
// None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime
const buildExtensions = base => {
// TODO: work out type for base
const buildExtensions = (base: any[]) => {
let complete = [...base]
if (autocompleteEnabled) {
@ -242,7 +257,7 @@
closeOnBlur: true,
icons: false,
optionClass: completion =>
completion.simple
"simple" in completion && completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
})
@ -347,7 +362,7 @@
{#if label}
<div>
<Label small>{label}</Label>
<Label size="S">{label}</Label>
</div>
{/if}

View File

@ -1,8 +1,15 @@
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash"
import {
BindingCompletion,
EditorModesMap,
Helper,
Snippet,
} from "@budibase/types"
import { CompletionContext } from "@codemirror/autocomplete"
export const EditorModes = {
export const EditorModes: EditorModesMap = {
JS: {
name: "javascript",
json: false,
@ -26,7 +33,7 @@ export const SECTIONS = {
},
}
export const buildHelperInfoNode = (completion, helper) => {
export const buildHelperInfoNode = (completion: any, helper: Helper) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
@ -46,7 +53,7 @@ export const buildHelperInfoNode = (completion, helper) => {
return ele
}
const toSpectrumIcon = name => {
const toSpectrumIcon = (name: string) => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
@ -58,7 +65,12 @@ const toSpectrumIcon = name => {
</svg>`
}
export const buildSectionHeader = (type, sectionName, icon, rank) => {
export const buildSectionHeader = (
type: string,
sectionName: string,
icon: string,
rank: number
) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
if (type) {
@ -72,43 +84,52 @@ export const buildSectionHeader = (type, sectionName, icon, rank) => {
}
}
export const helpersToCompletion = (helpers, mode) => {
export const helpersToCompletion = (
helpers: Record<string, Helper>,
mode: { name: "javascript" | "handlebars" }
) => {
const { type, name: sectionName, icon } = SECTIONS.HB_HELPER
const helperSection = buildSectionHeader(type, sectionName, icon, 99)
return Object.keys(helpers).reduce((acc, key) => {
let helper = helpers[key]
acc.push({
label: key,
info: completion => {
return Object.keys(helpers).flatMap(helperName => {
let helper = helpers[helperName]
return {
label: helperName,
info: (completion: BindingCompletion) => {
return buildHelperInfoNode(completion, helper)
},
type: "helper",
section: helperSection,
detail: "Function",
apply: (view, completion, from, to) => {
insertBinding(view, from, to, key, mode)
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertBinding(view, from, to, helperName, mode)
},
})
return acc
}, [])
}
})
}
export const getHelperCompletions = mode => {
const manifest = getManifest()
return Object.keys(manifest).reduce((acc, key) => {
acc = acc || []
return [...acc, ...helpersToCompletion(manifest[key], mode)]
}, [])
export const getHelperCompletions = (mode: {
name: "javascript" | "handlebars"
}) => {
// TODO: manifest needs to be properly typed
const manifest: any = getManifest()
return Object.keys(manifest).flatMap(key => {
return helpersToCompletion(manifest[key], mode)
})
}
export const snippetAutoComplete = snippets => {
return function myCompletions(context) {
export const snippetAutoComplete = (snippets: Snippet[]) => {
return function myCompletions(context: CompletionContext) {
if (!snippets?.length) {
return null
}
const word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit) {
if (!word || (word.from == word.to && !context.explicit)) {
return null
}
return {
@ -117,7 +138,12 @@ export const snippetAutoComplete = snippets => {
label: `snippets.${snippet.name}`,
type: "text",
simple: true,
apply: (view, completion, from, to) => {
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertSnippet(view, from, to, completion.label)
},
})),
@ -125,7 +151,7 @@ export const snippetAutoComplete = snippets => {
}
}
const bindingFilter = (options, query) => {
const bindingFilter = (options: BindingCompletion[], query: string) => {
return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase()
const label_parsed = completion.label.toLowerCase()
@ -138,8 +164,8 @@ const bindingFilter = (options, query) => {
})
}
export const hbAutocomplete = baseCompletions => {
async function coreCompletion(context) {
export const hbAutocomplete = (baseCompletions: BindingCompletion[]) => {
async function coreCompletion(context: CompletionContext) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match)
let options = baseCompletions || []
@ -149,6 +175,9 @@ export const hbAutocomplete = baseCompletions => {
}
// Accommodate spaces
const match = bindingStart.text.match(/{{[\s]*/)
if (!match) {
return null
}
const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
@ -162,14 +191,17 @@ export const hbAutocomplete = baseCompletions => {
return coreCompletion
}
export const jsAutocomplete = baseCompletions => {
async function coreCompletion(context) {
export const jsAutocomplete = (baseCompletions: BindingCompletion[]) => {
async function coreCompletion(context: CompletionContext) {
let jsBinding = context.matchBefore(/\$\("[\s\w]*/)
let options = baseCompletions || []
if (jsBinding) {
// Accommodate spaces
const match = jsBinding.text.match(/\$\("[\s]*/)
if (!match) {
return null
}
const query = jsBinding.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
@ -185,7 +217,10 @@ export const jsAutocomplete = baseCompletions => {
return coreCompletion
}
export const buildBindingInfoNode = (completion, binding) => {
export const buildBindingInfoNode = (
completion: BindingCompletion,
binding: any
) => {
if (!binding.valueHTML || binding.value == null) {
return null
}
@ -196,7 +231,12 @@ export const buildBindingInfoNode = (completion, binding) => {
}
// Readdress these methods. They shouldn't be used
export const hbInsert = (value, from, to, text) => {
export const hbInsert = (
value: string,
from: number,
to: number,
text: string
) => {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
@ -212,11 +252,14 @@ export const hbInsert = (value, from, to, text) => {
}
export function jsInsert(
value,
from,
to,
text,
{ helper, disableWrapping } = {}
value: string,
from: number,
to: number,
text: string,
{
helper,
disableWrapping,
}: { helper?: boolean; disableWrapping?: boolean } = {}
) {
let parsedInsert = ""
@ -236,7 +279,13 @@ export function jsInsert(
}
// Autocomplete apply behaviour
export const insertBinding = (view, from, to, text, mode) => {
export const insertBinding = (
view: any,
from: number,
to: number,
text: string,
mode: { name: "javascript" | "handlebars" }
) => {
let parsedInsert
if (mode.name == "javascript") {
@ -270,7 +319,12 @@ export const insertBinding = (view, from, to, text, mode) => {
})
}
export const insertSnippet = (view, from, to, text) => {
export const insertSnippet = (
view: any,
from: number,
to: number,
text: string
) => {
let cursorPos = from + text.length
view.dispatch({
changes: {
@ -284,9 +338,13 @@ export const insertSnippet = (view, from, to, text) => {
})
}
export const bindingsToCompletions = (bindings, mode) => {
// TODO: typing in this function isn't great
export const bindingsToCompletions = (
bindings: any,
mode: { name: "javascript" | "handlebars" }
) => {
const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => {
const categoryMeta = bindings?.reduce((acc: any, ele: any) => {
acc[ele.category] = acc[ele.category] || {}
if (ele.icon) {
@ -298,36 +356,46 @@ export const bindingsToCompletions = (bindings, mode) => {
return acc
}, {})
const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => {
const { icon, rank } = categoryMeta[catKey] || {}
const completions = Object.keys(bindingByCategory).reduce(
(comps: any, catKey: string) => {
const { icon, rank } = categoryMeta[catKey] || {}
const bindindSectionHeader = buildSectionHeader(
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
const bindingSectionHeader = buildSectionHeader(
// @ts-ignore something wrong with this - logically this should be dictionary
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label: binding.display?.name || binding.readableBinding || "NO NAME",
info: completion => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindindSectionHeader,
apply: (view, completion, from, to) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
}, [])
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label:
binding.display?.name || binding.readableBinding || "NO NAME",
info: (completion: BindingCompletion) => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindingSectionHeader,
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
},
[]
)
return completions
}

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import {
DrawerContent,
ActionButton,
@ -28,45 +28,45 @@
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils"
import formatHighlight from "json-format-highlight"
import { capitalise } from "@/helpers"
import { Utils } from "@budibase/frontend-core"
import { Utils, JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import { BindingMode, SidePanel } from "@budibase/types"
import type {
EnrichedBinding,
BindingCompletion,
Snippet,
Helper,
CaretPositionFn,
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { CompletionContext } from "@codemirror/autocomplete"
const dispatch = createEventDispatcher()
export let bindings = []
export let value = ""
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHBS = true
export let allowJS = false
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let snippets = null
export let snippets: Snippet[] | null = null
export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const Modes = {
Text: "Text",
JavaScript: "JavaScript",
}
const SidePanels = {
Bindings: "FlashOn",
Evaluation: "Play",
Snippets: "Code",
}
let mode
let sidePanel
let mode: BindingMode | null
let sidePanel: SidePanel | null
let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let getCaretPosition
let insertAtPos
let targetMode = null
let expressionResult
let expressionError
let jsValue: string | null = initialValueJS ? value : null
let hbsValue: string | null = initialValueJS ? null : value
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
let targetMode: BindingMode | null = null
let expressionResult: string | undefined
let expressionError: string | undefined
let evaluating = false
$: useSnippets = allowSnippets && !$licensing.isFreePlan
@ -78,10 +78,12 @@
mode
)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === Modes.JavaScript
$: usingJS = mode === BindingMode.JavaScript
$: editorMode =
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
mode === BindingMode.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = (editorMode === EditorModes.JS ? jsValue : hbsValue) as
| string
| null
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
@ -95,7 +97,7 @@
}
}
const getHBSCompletions = bindingCompletions => {
const getHBSCompletions = (bindingCompletions: BindingCompletion[]) => {
return [
hbAutocomplete([
...bindingCompletions,
@ -104,71 +106,87 @@
]
}
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
const completions = [
const getJSCompletions = (
bindingCompletions: BindingCompletion[],
snippets: Snippet[] | null,
useSnippets?: boolean
) => {
const completions: ((_: CompletionContext) => any)[] = [
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
if (useSnippets) {
if (useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS, allowJS) => {
const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
let options = []
if (allowHBS) {
options.push(Modes.Text)
options.push(BindingMode.Text)
}
if (allowJS) {
options.push(Modes.JavaScript)
options.push(BindingMode.JavaScript)
}
return options
}
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
const getSidePanelOptions = (
bindings: EnrichedBinding[],
context: any,
useSnippets: boolean,
mode: BindingMode | null
) => {
let options = []
if (bindings?.length) {
options.push(SidePanels.Bindings)
options.push(SidePanel.Bindings)
}
if (context && Object.keys(context).length > 0) {
options.push(SidePanels.Evaluation)
options.push(SidePanel.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {
options.push(SidePanels.Snippets)
if (useSnippets && mode === BindingMode.JavaScript) {
options.push(SidePanel.Snippets)
}
return options
}
const debouncedEval = Utils.debounce((expression, context, snippets) => {
try {
expressionError = null
expressionResult = processStringSync(
expression || "",
{
...context,
snippets,
},
{
noThrow: false,
}
)
} catch (err) {
expressionResult = null
expressionError = err
}
evaluating = false
}, 260)
const debouncedEval = Utils.debounce(
(expression: string | null, context: any, snippets: Snippet[]) => {
try {
expressionError = undefined
expressionResult = processStringSync(
expression || "",
{
...context,
snippets,
},
{
noThrow: false,
}
)
} catch (err: any) {
expressionResult = undefined
expressionError = err
}
evaluating = false
},
260
)
const requestEval = (expression, context, snippets) => {
const requestEval = (
expression: string | null,
context: any,
snippets: Snippet[] | null
) => {
evaluating = true
debouncedEval(expression, context, snippets)
}
const highlightJSON = json => {
return formatHighlight(json, {
const highlightJSON = (json: JSONValue) => {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
@ -178,7 +196,11 @@
})
}
const enrichBindings = (bindings, context, snippets) => {
const enrichBindings = (
bindings: EnrichedBinding[],
context: any,
snippets: Snippet[] | null
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
@ -189,17 +211,18 @@
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvauations = processObjectSync(bindingStrings, {
const bindingEvaluations = processObjectSync(bindingStrings, {
...context,
snippets,
})
// Enrich bindings with evaluations and highlighted HTML
return bindings.map((binding, idx) => {
if (!context) {
if (!context || typeof bindingEvaluations !== "object") {
return binding
}
const value = JSON.stringify(bindingEvauations[idx], null, 2)
const evalObj: Record<any, any> = bindingEvaluations
const value = JSON.stringify(evalObj[idx], null, 2)
return {
...binding,
value,
@ -208,29 +231,38 @@
})
}
const updateValue = val => {
const updateValue = (val: any) => {
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val)
requestEval(runtimeExpression, context, snippets)
}
const onSelectHelper = (helper, js) => {
bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, { js })
const onSelectHelper = (helper: Helper, js?: boolean) => {
bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, {
js,
dontDecode: undefined,
})
}
const onSelectBinding = (binding, { forceJS } = {}) => {
const onSelectBinding = (
binding: EnrichedBinding,
{ forceJS }: { forceJS?: boolean } = {}
) => {
const js = usingJS || forceJS
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, {
js,
dontDecode: undefined,
})
}
const changeMode = newMode => {
const changeMode = (newMode: BindingMode) => {
if (targetMode || newMode === mode) {
return
}
// Get the raw editor value to see if we are abandoning changes
let rawValue = editorValue
if (mode === Modes.JavaScript) {
if (mode === BindingMode.JavaScript && rawValue) {
rawValue = decodeJSBinding(rawValue)
}
@ -249,16 +281,16 @@
targetMode = null
}
const changeSidePanel = newSidePanel => {
const changeSidePanel = (newSidePanel: SidePanel) => {
sidePanel = newSidePanel === sidePanel ? null : newSidePanel
}
const onChangeHBSValue = e => {
const onChangeHBSValue = (e: { detail: string }) => {
hbsValue = e.detail
updateValue(hbsValue)
}
const onChangeJSValue = e => {
const onChangeJSValue = (e: { detail: string }) => {
jsValue = encodeJSBinding(e.detail)
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
@ -268,9 +300,14 @@
}
}
const addSnippet = (snippet: Snippet) =>
bindingHelpers.onSelectSnippet(snippet)
onMount(() => {
// Set the initial mode appropriately
const initialValueMode = initialValueJS ? Modes.JavaScript : Modes.Text
const initialValueMode = initialValueJS
? BindingMode.JavaScript
: BindingMode.Text
if (editorModeOptions.includes(initialValueMode)) {
mode = initialValueMode
} else {
@ -314,7 +351,7 @@
</div>
{/if}
<div class="editor">
{#if mode === Modes.Text}
{#if mode === BindingMode.Text}
{#key hbsCompletions}
<CodeEditor
value={hbsValue}
@ -328,10 +365,10 @@
jsBindingWrapping={false}
/>
{/key}
{:else if mode === Modes.JavaScript}
{:else if mode === BindingMode.JavaScript}
{#key jsCompletions}
<CodeEditor
value={decodeJSBinding(jsValue)}
value={jsValue ? decodeJSBinding(jsValue) : jsValue}
on:change={onChangeJSValue}
completions={jsCompletions}
mode={EditorModes.JS}
@ -371,7 +408,7 @@
</div>
</div>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanels.Bindings}
{#if sidePanel === SidePanel.Bindings}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
@ -380,18 +417,15 @@
addBinding={onSelectBinding}
mode={editorMode}
/>
{:else if sidePanel === SidePanels.Evaluation}
{:else if sidePanel === SidePanel.Evaluation}
<EvaluationSidePanel
{expressionResult}
{expressionError}
{evaluating}
expression={editorValue}
/>
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
expression={editorValue ? editorValue : ""}
/>
{:else if sidePanel === SidePanel.Snippets}
<SnippetSidePanel {addSnippet} {snippets} />
{/if}
</div>
</div>

View File

@ -1,28 +1,31 @@
<script>
import formatHighlight from "json-format-highlight"
<script lang="ts">
import { JsonFormatter } from "@budibase/frontend-core"
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { Helpers } from "@budibase/bbui"
import { fade } from "svelte/transition"
import { UserScriptError } from "@budibase/string-templates"
import type { JSONValue } from "@budibase/types"
export let expressionResult
export let expressionError
// this can be essentially any primitive response from the JS function
export let expressionResult: JSONValue | undefined = undefined
export let expressionError: string | undefined = undefined
export let evaluating = false
export let expression = null
export let expression: string | null = null
$: error = expressionError != null
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
const formatError = err => {
const formatError = (err: any) => {
if (err.code === UserScriptError.code) {
return err.userScriptError.toString()
}
return err.toString()
}
const highlight = json => {
// json can be any primitive type
const highlight = (json?: any | null) => {
if (json == null) {
return ""
}
@ -31,10 +34,10 @@
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
// couldn't parse/stringify, just treat it as the raw input
}
return formatHighlight(json, {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
@ -45,11 +48,11 @@
}
const copy = () => {
let clipboardVal = expressionResult.result
let clipboardVal = expressionResult
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
copyToClipboard(clipboardVal)
Helpers.copyToClipboard(clipboardVal)
notifications.success("Value copied to clipboard")
}
</script>

View File

@ -28,7 +28,9 @@
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: defaultName = getSequentialName($snippets, "MySnippet", {
getName: x => x.name,
})
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""

View File

@ -16,7 +16,10 @@ export {
export const AUTO_COLUMN_SUB_TYPES = AutoFieldSubType
export const AUTO_COLUMN_DISPLAY_NAMES = {
export const AUTO_COLUMN_DISPLAY_NAMES: Record<
keyof typeof AUTO_COLUMN_SUB_TYPES,
string
> = {
AUTO_ID: "Auto ID",
CREATED_BY: "Created By",
CREATED_AT: "Created At",
@ -209,13 +212,6 @@ export const Roles = {
BUILDER: "BUILDER",
}
export function isAutoColumnUserRelationship(subtype) {
return (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY ||
subtype === AUTO_COLUMN_SUB_TYPES.UPDATED_BY
)
}
export const PrettyRelationshipDefinitions = {
MANY: "Many rows",
ONE: "One row",

View File

@ -10,13 +10,13 @@
*
* Repl
*/
export const duplicateName = (name, allNames) => {
export const duplicateName = (name: string, allNames: string[]) => {
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
const baseName = name.split(duplicatePattern)[0]
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
// get the sequence from matched names
const sequence = []
const sequence: number[] = []
allNames.filter(n => {
if (n === baseName) {
return true
@ -70,12 +70,18 @@ export const duplicateName = (name, allNames) => {
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (
items,
prefix,
{ getName = x => x, numberFirstItem = false } = {}
export const getSequentialName = <T extends any>(
items: T[] | null,
prefix: string | null,
{
getName,
numberFirstItem,
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
} = {}
) => {
if (!prefix?.length || !getName) {
if (!prefix?.length) {
return null
}
const trimmedPrefix = prefix.trim()
@ -85,7 +91,7 @@ export const getSequentialName = (
}
let max = 0
items.forEach(item => {
const name = getName(item)
const name = getName?.(item) ?? item
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}

View File

@ -1,7 +1,8 @@
import { FeatureFlag } from "@budibase/types"
import { auth } from "../stores/portal"
import { get } from "svelte/store"
export const isEnabled = featureFlag => {
export const isEnabled = (featureFlag: FeatureFlag | `${FeatureFlag}`) => {
const user = get(auth).user
return !!user?.flags?.[featureFlag]
}

View File

@ -1,13 +1,21 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export default function (url) {
const store = writable({ status: "LOADING", data: {}, error: {} })
export default function (url: string) {
const store = writable<{
status: "LOADING" | "SUCCESS" | "ERROR"
data: object
error?: unknown
}>({
status: "LOADING",
data: {},
error: {},
})
async function get() {
store.update(u => ({ ...u, status: "LOADING" }))
try {
const data = await API.get({ url })
const data = await API.get<object>({ url })
store.set({ data, status: "SUCCESS" })
} catch (e) {
store.set({ data: {}, error: e, status: "ERROR" })

View File

@ -1,46 +0,0 @@
import { last, flow } from "lodash/fp"
export const buildStyle = styles => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = str => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg, funcs) => flow(funcs)(arg)
export const capitalise = s => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = s =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = e => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -0,0 +1,50 @@
import type { Many } from "lodash"
import { last, flow } from "lodash/fp"
export const buildStyle = (styles: Record<string, any>) => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = (str: string) => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg: string, funcs: Many<(...args: any[]) => any>) =>
flow(funcs)(arg)
export const capitalise = (s: string) => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = (s: string) =>
s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = (s: string) =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = (s: string) => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = (name: string) =>
pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag!) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -1,7 +0,0 @@
function handleEnter(fnc) {
return e => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -0,0 +1,7 @@
function handleEnter(fnc: () => void) {
return (e: KeyboardEvent) => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -1,6 +1,16 @@
import { writable } from "svelte/store"
function defaultValue() {
interface PaginationStore {
nextPage: string | null | undefined
page: string | null | undefined
hasPrevPage: boolean
hasNextPage: boolean
loading: boolean
pageNumber: number
pages: string[]
}
function defaultValue(): PaginationStore {
return {
nextPage: null,
page: undefined,
@ -29,13 +39,13 @@ export function createPaginationStore() {
update(state => {
state.pageNumber++
state.page = state.nextPage
state.pages.push(state.page)
state.pages.push(state.page!)
state.hasPrevPage = state.pageNumber > 1
return state
})
}
function fetched(hasNextPage, nextPage) {
function fetched(hasNextPage: boolean, nextPage: string) {
update(state => {
state.hasNextPage = hasNextPage
state.nextPage = nextPage

View File

@ -1,6 +1,6 @@
import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) {
export function getFormattedPlanName(userPlanType: PlanType) {
let planName
switch (userPlanType) {
case PlanType.PRO:
@ -29,6 +29,6 @@ export function getFormattedPlanName(userPlanType) {
return `${planName} Plan`
}
export function isPremiumOrAbove(userPlanType) {
export function isPremiumOrAbove(userPlanType: PlanType) {
return ![PlanType.PRO, PlanType.TEAM, PlanType.FREE].includes(userPlanType)
}

View File

@ -1,4 +1,4 @@
export default function (url) {
export default function (url: string) {
return url
.split("/")
.map(part => {

View File

@ -1,75 +0,0 @@
import { FieldType } from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
isAutoColumnUserRelationship,
} from "@/constants/backend"
import { isEnabled } from "@/helpers/featureFlags"
export function getAutoColumnInformation(enabled = true) {
let info = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns, we disable their creation when the DEFAULT_VALUES
// feature flag is enabled.
if (isEnabled("DEFAULT_VALUES")) {
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
}
info[subtype] = { enabled, name: AUTO_COLUMN_DISPLAY_NAMES[key] }
}
return info
}
export function buildAutoColumn(tableName, name, subtype) {
let type, constraints
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
type = FieldType.LINK
constraints = FIELDS.LINK.constraints
break
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
type = FieldType.NUMBER
constraints = FIELDS.NUMBER.constraints
break
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
type = FieldType.DATETIME
constraints = FIELDS.DATETIME.constraints
break
default:
type = FieldType.STRING
constraints = FIELDS.STRING.constraints
break
}
if (Object.values(AUTO_COLUMN_SUB_TYPES).indexOf(subtype) === -1) {
throw "Cannot build auto column with supplied subtype"
}
const base = {
name,
type,
subtype,
icon: "ri-magic-line",
autocolumn: true,
constraints,
}
if (isAutoColumnUserRelationship(subtype)) {
base.tableId = TableNames.USERS
base.fieldName = `${tableName}-${name}`
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -0,0 +1,96 @@
import {
AutoFieldSubType,
Automation,
DateFieldMetadata,
FieldType,
NumberFieldMetadata,
RelationshipFieldMetadata,
RelationshipType,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
} from "@/constants/backend"
import { utils } from "@budibase/shared-core"
type AutoColumnInformation = Partial<
Record<AutoFieldSubType, { enabled: boolean; name: string }>
>
export function getAutoColumnInformation(
enabled = true
): AutoColumnInformation {
const info: AutoColumnInformation = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns with user column default values, we disable their creation
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
const typedKey = key as keyof typeof AUTO_COLUMN_SUB_TYPES
info[subtype] = {
enabled,
name: AUTO_COLUMN_DISPLAY_NAMES[typedKey],
}
}
return info
}
export function buildAutoColumn(
tableName: string,
name: string,
subtype: AutoFieldSubType
): RelationshipFieldMetadata | NumberFieldMetadata | DateFieldMetadata {
const base = {
name,
icon: "ri-magic-line",
autocolumn: true,
}
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
return {
...base,
type: FieldType.LINK,
subtype,
constraints: FIELDS.LINK.constraints,
tableId: TableNames.USERS,
fieldName: `${tableName}-${name}`,
relationshipType: RelationshipType.MANY_TO_ONE,
}
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
return {
...base,
type: FieldType.NUMBER,
subtype,
constraints: FIELDS.NUMBER.constraints,
}
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
return {
...base,
type: FieldType.DATETIME,
subtype,
constraints: FIELDS.DATETIME.constraints,
}
default:
throw utils.unreachable(subtype, {
message: "Cannot build auto column with supplied subtype",
})
}
}
export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -1,4 +1,4 @@
export const suppressWarnings = warnings => {
export const suppressWarnings = (warnings: string[]) => {
if (!warnings?.length) {
return
}

View File

@ -1,6 +1,6 @@
<script>
import { viewsV2, rowActions } from "@/stores/builder"
import { admin, themeStore, featureFlags } from "@/stores/portal"
import { admin, themeStore, licensing } from "@/stores/portal"
import { Grid } from "@budibase/frontend-core"
import { API } from "@/api"
import { notifications } from "@budibase/bbui"
@ -53,7 +53,7 @@
{buttons}
allowAddRows
allowDeleteRows
aiEnabled={$featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS}
aiEnabled={$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled}
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud}

View File

@ -8,7 +8,7 @@
rowActions,
roles,
} from "@/stores/builder"
import { themeStore, admin, featureFlags } from "@/stores/portal"
import { themeStore, admin, licensing } from "@/stores/portal"
import { TableNames } from "@/constants"
import { Grid } from "@budibase/frontend-core"
import { API } from "@/api"
@ -130,7 +130,8 @@
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
isCloud={$admin.cloud}
aiEnabled={$featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS}
aiEnabled={$licensing.customAIConfigsEnabled ||
$licensing.budibaseAIEnabled}
{buttons}
buttonsCollapsed
on:updatedatasource={handleGridTableUpdate}

View File

@ -12,7 +12,7 @@
Tags,
Tag,
} from "@budibase/bbui"
import { admin, licensing, featureFlags } from "@/stores/portal"
import { admin, licensing } from "@/stores/portal"
import { API } from "@/api"
import AIConfigModal from "./ConfigModal.svelte"
import AIConfigTile from "./AIConfigTile.svelte"
@ -27,8 +27,7 @@
let editingUuid
$: isCloud = $admin.cloud
$: customAIConfigsEnabled =
$featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() {
try {

View File

@ -1,10 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
import { featureFlags } from "@/stores/portal"
if ($featureFlags.AI_CUSTOM_CONFIGS) {
$redirect("./ai")
} else {
$redirect("./auth")
}
$redirect("./ai")
</script>

View File

@ -1,27 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createPermissionStore() {
const { subscribe } = writable([])
return {
subscribe,
save: async ({ level, role, resource }) => {
return await API.updatePermissionForResource(resource, role, level)
},
remove: async ({ level, role, resource }) => {
return await API.removePermissionFromResource(resource, role, level)
},
forResource: async resourceId => {
return (await API.getPermissionForResource(resourceId)).permissions
},
forResourceDetailed: async resourceId => {
return await API.getPermissionForResource(resourceId)
},
getDependantsInfo: async resourceId => {
return await API.getDependants(resourceId)
},
}
}
export const permissions = createPermissionStore()

View File

@ -0,0 +1,50 @@
import { BudiStore } from "../BudiStore"
import { API } from "@/api"
import {
PermissionLevel,
GetResourcePermsResponse,
GetDependantResourcesResponse,
ResourcePermissionInfo,
} from "@budibase/types"
interface Permission {
level: PermissionLevel
role: string
resource: string
}
export class PermissionStore extends BudiStore<Permission[]> {
constructor() {
super([])
}
save = async (permission: Permission) => {
const { level, role, resource } = permission
return await API.updatePermissionForResource(resource, role, level)
}
remove = async (permission: Permission) => {
const { level, role, resource } = permission
return await API.removePermissionFromResource(resource, role, level)
}
forResource = async (
resourceId: string
): Promise<Record<string, ResourcePermissionInfo>> => {
return (await API.getPermissionForResource(resourceId)).permissions
}
forResourceDetailed = async (
resourceId: string
): Promise<GetResourcePermsResponse> => {
return await API.getPermissionForResource(resourceId)
}
getDependantsInfo = async (
resourceId: string
): Promise<GetDependantResourcesResponse> => {
return await API.getDependants(resourceId)
}
}
export const permissions = new PermissionStore()

View File

@ -62,7 +62,7 @@ export class RowActionStore extends BudiStore<RowActionState> {
const existingRowActions = get(this)[tableId] || []
name = getSequentialName(existingRowActions, "New row action ", {
getName: x => x.name,
})
})!
}
if (!name) {

View File

@ -1,67 +0,0 @@
import { writable, derived } from "svelte/store"
import { tables } from "./tables"
import { API } from "@/api"
export function createViewsStore() {
const store = writable({
selectedViewName: null,
})
const derivedStore = derived([store, tables], ([$store, $tables]) => {
let list = []
$tables.list?.forEach(table => {
const views = Object.values(table?.views || {}).filter(view => {
return view.version !== 2
})
list = list.concat(views)
})
return {
...$store,
list,
selected: list.find(view => view.name === $store.selectedViewName),
}
})
const select = name => {
store.update(state => ({
...state,
selectedViewName: name,
}))
}
const deleteView = async view => {
await API.deleteView(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
delete table.views[view.name]
return { ...state }
})
}
const save = async view => {
const savedView = await API.saveView(view)
select(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
return {
subscribe: derivedStore.subscribe,
select,
delete: deleteView,
save,
}
}
export const views = createViewsStore()

View File

@ -0,0 +1,94 @@
import { DerivedBudiStore } from "../BudiStore"
import { tables } from "./tables"
import { API } from "@/api"
import { View } from "@budibase/types"
import { helpers } from "@budibase/shared-core"
import { derived, Writable } from "svelte/store"
interface BuilderViewStore {
selectedViewName: string | null
}
interface DerivedViewStore extends BuilderViewStore {
list: View[]
selected?: View
}
export class ViewsStore extends DerivedBudiStore<
BuilderViewStore,
DerivedViewStore
> {
constructor() {
const makeDerivedStore = (store: Writable<BuilderViewStore>) => {
return derived([store, tables], ([$store, $tables]): DerivedViewStore => {
let list: View[] = []
$tables.list?.forEach(table => {
const views = Object.values(table?.views || {}).filter(
(view): view is View => !helpers.views.isV2(view)
)
list = list.concat(views)
})
return {
selectedViewName: $store.selectedViewName,
list,
selected: list.find(view => view.name === $store.selectedViewName),
}
})
}
super(
{
selectedViewName: null,
},
makeDerivedStore
)
this.select = this.select.bind(this)
}
select = (name: string) => {
this.store.update(state => ({
...state,
selectedViewName: name,
}))
}
delete = async (view: View) => {
if (!view.name) {
throw new Error("View name is required")
}
await API.deleteView(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table?.views && view.name) {
delete table.views[view.name]
}
return { ...state }
})
}
save = async (view: View & { originalName?: string }) => {
if (!view.name) {
throw new Error("View name is required")
}
const savedView = await API.saveView(view)
this.select(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table?.views && view.name) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
}
export const views = new ViewsStore()

View File

@ -39,7 +39,7 @@
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
import { gridLayout } from "utils/grid.js"
import { gridLayout } from "utils/grid"
export let instance = {}
export let parent = null

View File

@ -1,22 +1,39 @@
<script>
<script lang="ts">
import { getContext } from "svelte"
import { Pagination, ProgressCircle } from "@budibase/bbui"
import { fetchData, QueryUtils } from "@budibase/frontend-core"
import { LogicalOperator, EmptyFilterOption } from "@budibase/types"
import {
LogicalOperator,
EmptyFilterOption,
TableSchema,
SortOrder,
SearchFilters,
UISearchFilter,
DataFetchDatasource,
UserDatasource,
GroupUserDatasource,
DataFetchOptions,
} from "@budibase/types"
import { SDK, Component } from "../../index"
export let dataSource
export let filter
export let sortColumn
export let sortOrder
export let limit
export let paginate
export let autoRefresh
type ProviderDatasource = Exclude<
DataFetchDatasource,
UserDatasource | GroupUserDatasource
>
const { styleable, Provider, ActionTypes, API } = getContext("sdk")
const component = getContext("component")
export let dataSource: ProviderDatasource
export let filter: UISearchFilter
export let sortColumn: string
export let sortOrder: SortOrder
export let limit: number
export let paginate: boolean
export let autoRefresh: number
let interval
let queryExtensions = {}
const { styleable, Provider, ActionTypes, API } = getContext<SDK>("sdk")
const component = getContext<Component>("component")
let interval: ReturnType<typeof setInterval>
let queryExtensions: Record<string, any> = {}
$: defaultQuery = QueryUtils.buildQuery(filter)
@ -49,8 +66,14 @@
},
{
type: ActionTypes.SetDataProviderSorting,
callback: ({ column, order }) => {
let newOptions = {}
callback: ({
column,
order,
}: {
column: string
order: SortOrder | undefined
}) => {
let newOptions: Partial<DataFetchOptions> = {}
if (column) {
newOptions.sortColumn = column
}
@ -63,6 +86,7 @@
},
},
]
$: dataContext = {
rows: $fetch.rows,
info: $fetch.info,
@ -75,14 +99,12 @@
id: $component?.id,
state: {
query: $fetch.query,
sortColumn: $fetch.sortColumn,
sortOrder: $fetch.sortOrder,
},
limit,
primaryDisplay: $fetch.definition?.primaryDisplay,
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
}
const createFetch = datasource => {
const createFetch = (datasource: ProviderDatasource) => {
return fetchData({
API,
datasource,
@ -96,7 +118,7 @@
})
}
const sanitizeSchema = schema => {
const sanitizeSchema = (schema: TableSchema | null) => {
if (!schema) {
return schema
}
@ -109,14 +131,14 @@
return cloned
}
const addQueryExtension = (key, extension) => {
const addQueryExtension = (key: string, extension: any) => {
if (!key || !extension) {
return
}
queryExtensions = { ...queryExtensions, [key]: extension }
}
const removeQueryExtension = key => {
const removeQueryExtension = (key: string) => {
if (!key) {
return
}
@ -125,11 +147,14 @@
queryExtensions = newQueryExtensions
}
const extendQuery = (defaultQuery, extensions) => {
const extendQuery = (
defaultQuery: SearchFilters,
extensions: Record<string, any>
): SearchFilters => {
if (!Object.keys(extensions).length) {
return defaultQuery
}
const extended = {
const extended: SearchFilters = {
[LogicalOperator.AND]: {
conditions: [
...(defaultQuery ? [defaultQuery] : []),
@ -140,12 +165,12 @@
}
// If there are no conditions applied at all, clear the request.
return extended[LogicalOperator.AND]?.conditions?.length > 0
return (extended[LogicalOperator.AND]?.conditions?.length ?? 0) > 0
? extended
: null
: {}
}
const setUpAutoRefresh = autoRefresh => {
const setUpAutoRefresh = (autoRefresh: number) => {
clearInterval(interval)
if (autoRefresh) {
interval = setInterval(fetch.refresh, Math.max(10000, autoRefresh * 1000))

View File

@ -0,0 +1,15 @@
import { APIClient } from "@budibase/frontend-core"
import type { ActionTypes } from "./constants"
import { Readable } from "svelte/store"
export interface SDK {
API: APIClient
styleable: any
Provider: any
ActionTypes: typeof ActionTypes
}
export type Component = Readable<{
id: string
styles: any
}>

View File

@ -74,6 +74,7 @@ export default {
fetchData,
QueryUtils,
ContextScopes: Constants.ContextScopes,
// This is not used internally but exposed to users to be used in plugins
getAPIKey,
enrichButtonActions,
processStringSync,

View File

@ -6,7 +6,7 @@ import { screenStore } from "./screens"
import { builderStore } from "./builder"
import Router from "../components/Router.svelte"
import * as AppComponents from "../components/app/index.js"
import { ScreenslotID, ScreenslotType } from "../constants.js"
import { ScreenslotID, ScreenslotType } from "../constants"
export const BudibasePrefix = "@budibase/standard-components/"

View File

@ -1,10 +1,16 @@
import { makePropSafe as safe } from "@budibase/string-templates"
import { API } from "../api"
import { UILogicalOperator } from "@budibase/types"
import {
BasicOperator,
LegacyFilter,
UIColumn,
UILogicalOperator,
UISearchFilter,
} from "@budibase/types"
import { Constants } from "@budibase/frontend-core"
// Map of data types to component types for search fields inside blocks
const schemaComponentMap = {
const schemaComponentMap: Record<string, string> = {
string: "stringfield",
options: "optionsfield",
number: "numberfield",
@ -19,7 +25,16 @@ const schemaComponentMap = {
* @param searchColumns the search columns to use
* @param schema the datasource schema
*/
export const enrichSearchColumns = async (searchColumns, schema) => {
export const enrichSearchColumns = async (
searchColumns: string[],
schema: Record<
string,
{
tableId: string
type: string
}
>
) => {
if (!searchColumns?.length || !schema) {
return []
}
@ -61,12 +76,16 @@ export const enrichSearchColumns = async (searchColumns, schema) => {
* @param columns the enriched search column structure
* @param formId the ID of the form containing the search fields
*/
export const enrichFilter = (filter, columns, formId) => {
export const enrichFilter = (
filter: UISearchFilter,
columns: UIColumn[],
formId: string
) => {
if (!columns?.length) {
return filter
}
let newFilters = []
const newFilters: LegacyFilter[] = []
columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
const stringType = column.type === "string" || column.type === "formula"
@ -99,7 +118,7 @@ export const enrichFilter = (filter, columns, formId) => {
newFilters.push({
field: column.name,
type: column.type,
operator: stringType ? "string" : "equal",
operator: stringType ? BasicOperator.STRING : BasicOperator.EQUAL,
valueType: "Binding",
value: `{{ ${binding} }}`,
})

View File

@ -1,7 +1,27 @@
import { GridSpacing, GridRowHeight } from "constants"
import { GridSpacing, GridRowHeight } from "@/constants"
import { builderStore } from "stores"
import { buildStyleString } from "utils/styleable.js"
interface GridMetadata {
id: string
styles: Record<string, string | number> & {
"--default-width"?: number
"--default-height"?: number
}
interactive: boolean
errored: boolean
definition?: {
size?: {
width: number
height: number
}
grid?: { hAlign: string; vAlign: string }
}
draggable: boolean
insideGrid: boolean
ignoresLayout: boolean
}
/**
* We use CSS variables on components to control positioning and layout of
* components inside grids.
@ -44,14 +64,17 @@ export const GridDragModes = {
}
// Builds a CSS variable name for a certain piece of grid metadata
export const getGridVar = (device, param) => `--grid-${device}-${param}`
export const getGridVar = (device: string, param: string) =>
`--grid-${device}-${param}`
// Determines whether a JS event originated from immediately within a grid
export const isGridEvent = e => {
export const isGridEvent = (e: Event & { target: HTMLElement }): boolean => {
return (
e.target.dataset?.indicator === "true" ||
// @ts-expect-error: api is not properly typed
e.target
.closest?.(".component")
// @ts-expect-error
?.parentNode.closest(".component")
?.childNodes[0]?.classList?.contains("grid")
)
@ -59,11 +82,11 @@ export const isGridEvent = e => {
// Svelte action to apply required class names and styles to our component
// wrappers
export const gridLayout = (node, metadata) => {
let selectComponent
export const gridLayout = (node: HTMLDivElement, metadata: GridMetadata) => {
let selectComponent: ((e: Event) => void) | null
// Applies the required listeners, CSS and classes to a component DOM node
const applyMetadata = metadata => {
const applyMetadata = (metadata: GridMetadata) => {
const {
id,
styles,
@ -86,7 +109,7 @@ export const gridLayout = (node, metadata) => {
}
// Callback to select the component when clicking on the wrapper
selectComponent = e => {
selectComponent = (e: Event) => {
e.stopPropagation()
builderStore.actions.selectComponent(id)
}
@ -100,7 +123,7 @@ export const gridLayout = (node, metadata) => {
}
width += 2 * GridSpacing
height += 2 * GridSpacing
let vars = {
const vars: Record<string, string | number> = {
"--default-width": width,
"--default-height": height,
}
@ -135,7 +158,7 @@ export const gridLayout = (node, metadata) => {
}
// Apply some metadata to data attributes to speed up lookups
const addDataTag = (tagName, device, param) => {
const addDataTag = (tagName: string, device: string, param: string) => {
const val = `${vars[getGridVar(device, param)]}`
if (node.dataset[tagName] !== val) {
node.dataset[tagName] = val
@ -147,11 +170,12 @@ export const gridLayout = (node, metadata) => {
addDataTag("gridMobileHAlign", Devices.Mobile, GridParams.HAlign)
addDataTag("gridDesktopVAlign", Devices.Desktop, GridParams.VAlign)
addDataTag("gridMobileVAlign", Devices.Mobile, GridParams.VAlign)
if (node.dataset.insideGrid !== true) {
node.dataset.insideGrid = true
if (node.dataset.insideGrid !== "true") {
node.dataset.insideGrid = "true"
}
// Apply all CSS variables to the wrapper
// @ts-expect-error TODO
node.style = buildStyleString(vars)
// Add a listener to select this node on click
@ -160,7 +184,7 @@ export const gridLayout = (node, metadata) => {
}
// Add draggable attribute
node.setAttribute("draggable", !!draggable)
node.setAttribute("draggable", (!!draggable).toString())
}
// Removes the previously set up listeners
@ -176,7 +200,7 @@ export const gridLayout = (node, metadata) => {
applyMetadata(metadata)
return {
update(newMetadata) {
update(newMetadata: GridMetadata) {
removeListeners()
applyMetadata(newMetadata)
},

View File

@ -1,8 +1,8 @@
import { get } from "svelte/store"
import { link } from "svelte-spa-router"
import { link, LinkActionOpts } from "svelte-spa-router"
import { builderStore } from "stores"
export const linkable = (node, href) => {
export const linkable = (node: HTMLElement, href?: LinkActionOpts) => {
if (get(builderStore).inBuilder) {
node.onclick = e => {
e.preventDefault()

View File

@ -14,6 +14,7 @@
"../*",
"../../node_modules/@budibase/*"
],
"@/*": ["./src/*"],
"*": ["./src/*"]
}
}

View File

@ -67,6 +67,10 @@ export default defineConfig(({ mode }) => {
find: "constants",
replacement: path.resolve("./src/constants"),
},
{
find: "@/constants",
replacement: path.resolve("./src/constants"),
},
{
find: "sdk",
replacement: path.resolve("./src/sdk"),

View File

@ -1,7 +1,11 @@
// TODO: datasource and defitions are unions of the different implementations. At this point, the datasource does not know what type is being used, and the assignations will cause TS exceptions. Casting it "as any" for now. This should be fixed improving the type usages.
import { derived, get, Readable, Writable } from "svelte/store"
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import {
DataFetchDefinition,
getDatasourceDefinition,
getDatasourceSchema,
} from "../../../fetch"
import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash"
import {
@ -18,7 +22,7 @@ import { Store as StoreContext, BaseStoreProps } from "."
import { DatasourceActions } from "./datasources"
interface DatasourceStore {
definition: Writable<UIDatasource | null>
definition: Writable<DataFetchDefinition | null>
schemaMutations: Writable<Record<string, UIFieldMutation>>
subSchemaMutations: Writable<Record<string, Record<string, UIFieldMutation>>>
}
@ -131,11 +135,17 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
[datasource, definition],
([$datasource, $definition]) => {
let type = $datasource?.type
// @ts-expect-error
if (type === "provider") {
type = ($datasource as any).value?.datasource?.type // TODO: see line 1
}
// Handle calculation views
if (type === "viewV2" && $definition?.type === ViewV2Type.CALCULATION) {
if (
type === "viewV2" &&
$definition &&
"type" in $definition &&
$definition.type === ViewV2Type.CALCULATION
) {
return false
}
return !!type && ["table", "viewV2", "link"].includes(type)
@ -197,7 +207,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
) => {
// Update local state
const originalDefinition = get(definition)
definition.set(newDefinition as UIDatasource)
definition.set(newDefinition)
// Update server
if (get(config).canSaveSchema) {
@ -225,13 +235,15 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
// Update primary display
newDefinition.primaryDisplay = column
// Sanitise schema to ensure field is required and has no default value
if (!newDefinition.schema[column].constraints) {
newDefinition.schema[column].constraints = {}
}
newDefinition.schema[column].constraints.presence = { allowEmpty: false }
if ("default" in newDefinition.schema[column]) {
delete newDefinition.schema[column].default
if (newDefinition.schema) {
// Sanitise schema to ensure field is required and has no default value
if (!newDefinition.schema[column].constraints) {
newDefinition.schema[column].constraints = {}
}
newDefinition.schema[column].constraints.presence = { allowEmpty: false }
if ("default" in newDefinition.schema[column]) {
delete newDefinition.schema[column].default
}
}
return await saveDefinition(newDefinition as any) // TODO: see line 1
}

View File

@ -8,6 +8,7 @@ import {
import { get } from "svelte/store"
import { Store as StoreContext } from ".."
import { DatasourceTableActions } from "."
import TableFetch from "../../../../fetch/TableFetch"
const SuppressErrors = true
@ -119,7 +120,7 @@ export const initialise = (context: StoreContext) => {
unsubscribers.push(
allFilters.subscribe($allFilters => {
// Ensure we're updating the correct fetch
const $fetch = get(fetch)
const $fetch = get(fetch) as TableFetch | null
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return
}
@ -133,7 +134,7 @@ export const initialise = (context: StoreContext) => {
unsubscribers.push(
sort.subscribe($sort => {
// Ensure we're updating the correct fetch
const $fetch = get(fetch)
const $fetch = get(fetch) as TableFetch | null
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
return
}

View File

@ -4,11 +4,11 @@ import {
SaveRowRequest,
SortOrder,
UIDatasource,
UIView,
UpdateViewRequest,
} from "@budibase/types"
import { Store as StoreContext } from ".."
import { DatasourceViewActions } from "."
import ViewV2Fetch from "../../../../fetch/ViewV2Fetch"
const SuppressErrors = true
@ -134,6 +134,9 @@ export const initialise = (context: StoreContext) => {
if (!get(config).canSaveSchema) {
return
}
if (!$definition || !("id" in $definition)) {
return
}
if ($definition?.id !== $datasource.id) {
return
}
@ -184,7 +187,10 @@ export const initialise = (context: StoreContext) => {
unsubscribers.push(
sort.subscribe(async $sort => {
// Ensure we're updating the correct view
const $view = get(definition) as UIView
const $view = get(definition)
if (!$view || !("id" in $view)) {
return
}
if ($view?.id !== $datasource.id) {
return
}
@ -207,7 +213,7 @@ export const initialise = (context: StoreContext) => {
// Also update the fetch to ensure the new sort is respected.
// Ensure we're updating the correct fetch.
const $fetch = get(fetch)
const $fetch = get(fetch) as ViewV2Fetch | null
if ($fetch?.options?.datasource?.id !== $datasource.id) {
return
}
@ -225,6 +231,9 @@ export const initialise = (context: StoreContext) => {
return
}
const $view = get(definition)
if (!$view || !("id" in $view)) {
return
}
if ($view?.id !== $datasource.id) {
return
}
@ -246,7 +255,7 @@ export const initialise = (context: StoreContext) => {
if (!get(config).canSaveSchema) {
return
}
const $fetch = get(fetch)
const $fetch = get(fetch) as ViewV2Fetch | null
if ($fetch?.options?.datasource?.id !== $datasource.id) {
return
}
@ -262,7 +271,7 @@ export const initialise = (context: StoreContext) => {
if (get(config).canSaveSchema) {
return
}
const $fetch = get(fetch)
const $fetch = get(fetch) as ViewV2Fetch | null
if ($fetch?.options?.datasource?.id !== $datasource.id) {
return
}

View File

@ -1,5 +1,5 @@
import { writable, derived, get, Writable, Readable } from "svelte/store"
import { fetchData } from "../../../fetch"
import { DataFetch, fetchData } from "../../../fetch"
import { NewRowID, RowPageSize } from "../lib/constants"
import {
generateRowID,
@ -13,7 +13,6 @@ import { sleep } from "../../../utils/utils"
import { FieldType, Row, UIRow } from "@budibase/types"
import { getRelatedTableValues } from "../../../utils"
import { Store as StoreContext } from "."
import DataFetch from "../../../fetch/DataFetch"
interface IndexedUIRow extends UIRow {
__idx: number
@ -21,7 +20,7 @@ interface IndexedUIRow extends UIRow {
interface RowStore {
rows: Writable<UIRow[]>
fetch: Writable<DataFetch<any, any, any> | null> // TODO: type this properly, having a union of all the possible options
fetch: Writable<DataFetch | null>
loaded: Writable<boolean>
refreshing: Writable<boolean>
loading: Writable<boolean>
@ -254,7 +253,7 @@ export const createActions = (context: StoreContext): RowActionStore => {
// Reset state properties when dataset changes
if (!$instanceLoaded || resetRows) {
definition.set($fetch.definition as any) // TODO: datasource and defitions are unions of the different implementations. At this point, the datasource does not know what type is being used, and the assignations will cause TS exceptions. Casting it "as any" for now. This should be fixed improving the type usages.
definition.set($fetch.definition ?? null)
}
// Reset scroll state when data changes

View File

@ -1,13 +1,9 @@
import DataFetch from "./DataFetch"
interface CustomDatasource {
type: "custom"
data: any
}
import { CustomDatasource } from "@budibase/types"
import BaseDataFetch from "./DataFetch"
type CustomDefinition = Record<string, any>
export default class CustomFetch extends DataFetch<
export default class CustomFetch extends BaseDataFetch<
CustomDatasource,
CustomDefinition
> {

View File

@ -3,14 +3,13 @@ import { cloneDeep } from "lodash/fp"
import { QueryUtils } from "../utils"
import { convertJSONSchemaToTableSchema } from "../utils/json"
import {
DataFetchOptions,
FieldType,
LegacyFilter,
Row,
SearchFilters,
SortOrder,
SortType,
TableSchema,
UISearchFilter,
} from "@budibase/types"
import { APIClient } from "../api/types"
import { DataFetchType } from "."
@ -44,14 +43,11 @@ interface DataFetchDerivedStore<TDefinition, TQuery>
supportsPagination: boolean
}
export interface DataFetchParams<
TDatasource,
TQuery = SearchFilters | undefined
> {
export interface DataFetchParams<TDatasource, TQuery = SearchFilters> {
API: APIClient
datasource: TDatasource
query: TQuery
options?: {}
options?: Partial<DataFetchOptions<TQuery>>
}
/**
@ -59,7 +55,7 @@ export interface DataFetchParams<
* internal table or datasource plus.
* For other types of datasource, this class is overridden and extended.
*/
export default abstract class DataFetch<
export default abstract class BaseDataFetch<
TDatasource extends { type: DataFetchType },
TDefinition extends {
schema?: Record<string, any> | null
@ -73,18 +69,11 @@ export default abstract class DataFetch<
supportsSort: boolean
supportsPagination: boolean
}
options: {
options: DataFetchOptions<TQuery> & {
datasource: TDatasource
limit: number
// Search config
filter: UISearchFilter | LegacyFilter[] | null
query: TQuery
// Sorting config
sortColumn: string | null
sortOrder: SortOrder
sortType: SortType | null
// Pagination config
paginate: boolean
// Client side feature customisation
clientSideSearching: boolean
clientSideSorting: boolean
@ -267,6 +256,7 @@ export default abstract class DataFetch<
// Build the query
let query = this.options.query
if (!query) {
query = buildQuery(filter ?? undefined) as TQuery
}
@ -430,7 +420,7 @@ export default abstract class DataFetch<
* Resets the data set and updates options
* @param newOptions any new options
*/
async update(newOptions: any) {
async update(newOptions: Partial<DataFetchOptions<TQuery>>) {
// Check if any settings have actually changed
let refresh = false
for (const [key, value] of Object.entries(newOptions || {})) {

View File

@ -1,14 +1,10 @@
import { Row } from "@budibase/types"
import DataFetch from "./DataFetch"
type Types = "field" | "queryarray" | "jsonarray"
export interface FieldDatasource<TType extends Types> {
type: TType
tableId: string
fieldType: "attachment" | "array"
value: string[] | Row[]
}
import {
FieldDatasource,
JSONArrayFieldDatasource,
QueryArrayFieldDatasource,
Row,
} from "@budibase/types"
import BaseDataFetch from "./DataFetch"
export interface FieldDefinition {
schema?: Record<string, { type: string }> | null
@ -18,10 +14,12 @@ function isArrayOfStrings(value: string[] | Row[]): value is string[] {
return Array.isArray(value) && !!value[0] && typeof value[0] !== "object"
}
export default class FieldFetch<TType extends Types> extends DataFetch<
FieldDatasource<TType>,
FieldDefinition
> {
export default class FieldFetch<
TDatasource extends
| FieldDatasource
| QueryArrayFieldDatasource
| JSONArrayFieldDatasource = FieldDatasource
> extends BaseDataFetch<TDatasource, FieldDefinition> {
async getDefinition(): Promise<FieldDefinition | null> {
const { datasource } = this.options

View File

@ -1,20 +1,20 @@
import { get } from "svelte/store"
import DataFetch, { DataFetchParams } from "./DataFetch"
import { TableNames } from "../constants"
import BaseDataFetch, { DataFetchParams } from "./DataFetch"
import { GroupUserDatasource, InternalTable } from "@budibase/types"
interface GroupUserQuery {
groupId: string
emailSearch: string
}
interface GroupUserDatasource {
type: "groupUser"
tableId: TableNames.USERS
interface GroupUserDefinition {
schema?: Record<string, any> | null
primaryDisplay?: string
}
export default class GroupUserFetch extends DataFetch<
export default class GroupUserFetch extends BaseDataFetch<
GroupUserDatasource,
{},
GroupUserDefinition,
GroupUserQuery
> {
constructor(opts: DataFetchParams<GroupUserDatasource, GroupUserQuery>) {
@ -22,7 +22,7 @@ export default class GroupUserFetch extends DataFetch<
...opts,
datasource: {
type: "groupUser",
tableId: TableNames.USERS,
tableId: InternalTable.USER_METADATA,
},
})
}

View File

@ -1,7 +1,8 @@
import FieldFetch from "./FieldFetch"
import { getJSONArrayDatasourceSchema } from "../utils/json"
import { JSONArrayFieldDatasource } from "@budibase/types"
export default class JSONArrayFetch extends FieldFetch<"jsonarray"> {
export default class JSONArrayFetch extends FieldFetch<JSONArrayFieldDatasource> {
async getDefinition() {
const { datasource } = this.options

View File

@ -1,20 +1,11 @@
import { Row, TableSchema } from "@budibase/types"
import DataFetch from "./DataFetch"
interface NestedProviderDatasource {
type: "provider"
value?: {
schema: TableSchema
primaryDisplay: string
rows: Row[]
}
}
import { NestedProviderDatasource, TableSchema } from "@budibase/types"
import BaseDataFetch from "./DataFetch"
interface NestedProviderDefinition {
schema?: TableSchema
primaryDisplay?: string
}
export default class NestedProviderFetch extends DataFetch<
export default class NestedProviderFetch extends BaseDataFetch<
NestedProviderDatasource,
NestedProviderDefinition
> {

View File

@ -3,8 +3,9 @@ import {
getJSONArrayDatasourceSchema,
generateQueryArraySchemas,
} from "../utils/json"
import { QueryArrayFieldDatasource } from "@budibase/types"
export default class QueryArrayFetch extends FieldFetch<"queryarray"> {
export default class QueryArrayFetch extends FieldFetch<QueryArrayFieldDatasource> {
async getDefinition() {
const { datasource } = this.options

View File

@ -1,23 +1,9 @@
import DataFetch from "./DataFetch"
import BaseDataFetch from "./DataFetch"
import { Helpers } from "@budibase/bbui"
import { ExecuteQueryRequest, Query } from "@budibase/types"
import { ExecuteQueryRequest, Query, QueryDatasource } from "@budibase/types"
import { get } from "svelte/store"
interface QueryDatasource {
type: "query"
_id: string
fields: Record<string, any> & {
pagination?: {
type: string
location: string
pageParam: string
}
}
queryParams?: Record<string, string>
parameters: { name: string; default: string }[]
}
export default class QueryFetch extends DataFetch<QueryDatasource, Query> {
export default class QueryFetch extends BaseDataFetch<QueryDatasource, Query> {
async determineFeatureFlags() {
const definition = await this.getDefinition()
const supportsPagination =

View File

@ -1,15 +1,7 @@
import { Table } from "@budibase/types"
import DataFetch from "./DataFetch"
import { RelationshipDatasource, Table } from "@budibase/types"
import BaseDataFetch from "./DataFetch"
interface RelationshipDatasource {
type: "link"
tableId: string
rowId: string
rowTableId: string
fieldName: string
}
export default class RelationshipFetch extends DataFetch<
export default class RelationshipFetch extends BaseDataFetch<
RelationshipDatasource,
Table
> {

View File

@ -1,13 +1,8 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch"
import { SortOrder, Table } from "@budibase/types"
import BaseDataFetch from "./DataFetch"
import { SortOrder, Table, TableDatasource } from "@budibase/types"
interface TableDatasource {
type: "table"
tableId: string
}
export default class TableFetch extends DataFetch<TableDatasource, Table> {
export default class TableFetch extends BaseDataFetch<TableDatasource, Table> {
async determineFeatureFlags() {
return {
supportsSearch: true,

View File

@ -1,22 +1,24 @@
import { get } from "svelte/store"
import DataFetch, { DataFetchParams } from "./DataFetch"
import { TableNames } from "../constants"
import BaseDataFetch, { DataFetchParams } from "./DataFetch"
import { utils } from "@budibase/shared-core"
import { SearchFilters, SearchUsersRequest } from "@budibase/types"
import {
InternalTable,
SearchFilters,
SearchUsersRequest,
UserDatasource,
} from "@budibase/types"
interface UserFetchQuery {
appId: string
paginated: boolean
}
interface UserDatasource {
type: "user"
tableId: TableNames.USERS
interface UserDefinition {
schema?: Record<string, any> | null
primaryDisplay?: string
}
interface UserDefinition {}
export default class UserFetch extends DataFetch<
export default class UserFetch extends BaseDataFetch<
UserDatasource,
UserDefinition,
UserFetchQuery
@ -26,7 +28,7 @@ export default class UserFetch extends DataFetch<
...opts,
datasource: {
type: "user",
tableId: TableNames.USERS,
tableId: InternalTable.USER_METADATA,
},
})
}

View File

@ -1,16 +1,7 @@
import { Table } from "@budibase/types"
import DataFetch from "./DataFetch"
import { Table, ViewV1Datasource } from "@budibase/types"
import BaseDataFetch from "./DataFetch"
type ViewV1Datasource = {
type: "view"
name: string
tableId: string
calculation: string
field: string
groupBy: string
}
export default class ViewFetch extends DataFetch<ViewV1Datasource, Table> {
export default class ViewFetch extends BaseDataFetch<ViewV1Datasource, Table> {
async getDefinition() {
const { datasource } = this.options

View File

@ -1,14 +1,14 @@
import { SortOrder, ViewV2Enriched, ViewV2Type } from "@budibase/types"
import DataFetch from "./DataFetch"
import {
SortOrder,
ViewDatasource,
ViewV2Enriched,
ViewV2Type,
} from "@budibase/types"
import BaseDataFetch from "./DataFetch"
import { get } from "svelte/store"
import { helpers } from "@budibase/shared-core"
interface ViewDatasource {
type: "viewV2"
id: string
}
export default class ViewV2Fetch extends DataFetch<
export default class ViewV2Fetch extends BaseDataFetch<
ViewDatasource,
ViewV2Enriched
> {

View File

@ -11,6 +11,7 @@ import GroupUserFetch from "./GroupUserFetch"
import CustomFetch from "./CustomFetch"
import QueryArrayFetch from "./QueryArrayFetch"
import { APIClient } from "../api/types"
import { DataFetchDatasource, Table, ViewV2Enriched } from "@budibase/types"
export type DataFetchType = keyof typeof DataFetchMap
@ -26,32 +27,88 @@ export const DataFetchMap = {
// Client specific datasource types
provider: NestedProviderFetch,
field: FieldFetch<"field">,
field: FieldFetch,
jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}
export interface DataFetchClassMap {
table: TableFetch
view: ViewFetch
viewV2: ViewV2Fetch
query: QueryFetch
link: RelationshipFetch
user: UserFetch
groupUser: GroupUserFetch
custom: CustomFetch
// Client specific datasource types
provider: NestedProviderFetch
field: FieldFetch
jsonarray: JSONArrayFetch
queryarray: QueryArrayFetch
}
export type DataFetch =
| TableFetch
| ViewFetch
| ViewV2Fetch
| QueryFetch
| RelationshipFetch
| UserFetch
| GroupUserFetch
| CustomFetch
| NestedProviderFetch
| FieldFetch
| JSONArrayFetch
| QueryArrayFetch
export type DataFetchDefinition =
| Table
| ViewV2Enriched
| {
// These fields are added to allow checking these fields on definition usages without requiring constant castings
schema?: Record<string, any> | null
primaryDisplay?: string
rowHeight?: number
type?: string
name?: string
}
// Constructs a new fetch model for a certain datasource
export const fetchData = ({ API, datasource, options }: any) => {
const Fetch = DataFetchMap[datasource?.type as DataFetchType] || TableFetch
export const fetchData = <
T extends DataFetchDatasource,
Type extends T["type"] = T["type"]
>({
API,
datasource,
options,
}: {
API: APIClient
datasource: T
options: any
}): Type extends keyof DataFetchClassMap
? DataFetchClassMap[Type]
: TableFetch => {
const Fetch = DataFetchMap[datasource?.type] || TableFetch
const fetch = new Fetch({ API, datasource, ...options })
// Initially fetch data but don't bother waiting for the result
fetch.getInitialData()
return fetch
return fetch as any
}
// Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded
const createEmptyFetchInstance = <TDatasource extends { type: DataFetchType }>({
const createEmptyFetchInstance = ({
API,
datasource,
}: {
API: APIClient
datasource: TDatasource
datasource: DataFetchDatasource
}) => {
const handler = DataFetchMap[datasource?.type as DataFetchType]
const handler = DataFetchMap[datasource?.type]
if (!handler) {
return null
}
@ -63,29 +120,25 @@ const createEmptyFetchInstance = <TDatasource extends { type: DataFetchType }>({
}
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async <
TDatasource extends { type: DataFetchType }
>({
export const getDatasourceDefinition = async ({
API,
datasource,
}: {
API: APIClient
datasource: TDatasource
datasource: DataFetchDatasource
}) => {
const instance = createEmptyFetchInstance({ API, datasource })
return await instance?.getDefinition()
}
// Fetches the schema of any type of datasource
export const getDatasourceSchema = <
TDatasource extends { type: DataFetchType }
>({
export const getDatasourceSchema = ({
API,
datasource,
definition,
}: {
API: APIClient
datasource: TDatasource
datasource: DataFetchDatasource
definition?: any
}) => {
const instance = createEmptyFetchInstance({ API, datasource })

View File

@ -1,7 +1,6 @@
export { createAPIClient } from "./api"
export type { APIClient } from "./api"
export { fetchData, DataFetchMap } from "./fetch"
export type { DataFetchType } from "./fetch"
export * as Constants from "./constants"
export * from "./stores"
export * from "./utils"

View File

@ -8,6 +8,7 @@ export * as search from "./searchFields"
export * as SchemaUtils from "./schema"
export { memo, derivedMemo } from "./memo"
export { createWebsocket } from "./websocket"
export * as JsonFormatter from "./jsonFormatter"
export * from "./download"
export * from "./settings"
export * from "./relatedColumns"

View File

@ -0,0 +1,71 @@
import { JSONValue } from "@budibase/types"
export type ColorsOptions = {
keyColor?: string
numberColor?: string
stringColor?: string
trueColor?: string
falseColor?: string
nullColor?: string
}
const defaultColors: ColorsOptions = {
keyColor: "dimgray",
numberColor: "lightskyblue",
stringColor: "lightcoral",
trueColor: "lightseagreen",
falseColor: "#f66578",
nullColor: "cornflowerblue",
}
const entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"`": "&#x60;",
"=": "&#x3D;",
}
function escapeHtml(html: string) {
return String(html).replace(/[&<>"'`=]/g, function (s) {
return entityMap[s as keyof typeof entityMap]
})
}
export function format(json: JSONValue, colorOptions: ColorsOptions = {}) {
const valueType = typeof json
let jsonString =
typeof json === "string" ? json : JSON.stringify(json, null, 2) || valueType
let colors = Object.assign({}, defaultColors, colorOptions)
jsonString = jsonString
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
return jsonString.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
(match: string) => {
let color = colors.numberColor
let style = ""
if (/^"/.test(match)) {
if (/:$/.test(match)) {
color = colors.keyColor
} else {
color = colors.stringColor
match = '"' + escapeHtml(match.substr(1, match.length - 2)) + '"'
style = "word-wrap:break-word;white-space:pre-wrap;"
}
} else {
color = /true/.test(match)
? colors.trueColor
: /false/.test(match)
? colors.falseColor
: /null/.test(match)
? colors.nullColor
: color
}
return `<span style="${style}color:${color}">${match}</span>`
}
)
}

View File

@ -43,7 +43,7 @@ export const sequential = fn => {
* invocations is enforced.
* @param callback an async function to run
* @param minDelay the minimum delay between invocations
* @returns {Promise} a debounced version of the callback
* @returns a debounced version of the callback
*/
export const debounce = (callback, minDelay = 1000) => {
let timeout

@ -1 +1 @@
Subproject commit 193476cdfade6d3c613e6972f16ee0c527e01ff6
Subproject commit 43a5785ccb4f83ce929b29f05ea0a62199fcdf23

View File

@ -1,34 +1,24 @@
const { Curl } = require("../../curl")
const fs = require("fs")
const path = require("path")
import { Curl } from "../../curl"
import { readFileSync } from "fs"
import { join } from "path"
const getData = file => {
return fs.readFileSync(path.join(__dirname, `./data/${file}.txt`), "utf8")
const getData = (file: string) => {
return readFileSync(join(__dirname, `./data/${file}.txt`), "utf8")
}
describe("Curl Import", () => {
let curl
let curl: Curl
beforeEach(() => {
curl = new Curl()
})
it("validates unsupported data", async () => {
let data
let supported
// JSON
data = "{}"
supported = await curl.isSupported(data)
expect(supported).toBe(false)
// Empty
data = ""
supported = await curl.isSupported(data)
expect(supported).toBe(false)
expect(await curl.isSupported("{}")).toBe(false)
expect(await curl.isSupported("")).toBe(false)
})
const init = async file => {
const init = async (file: string) => {
await curl.isSupported(getData(file))
}
@ -39,14 +29,14 @@ describe("Curl Import", () => {
})
describe("Returns queries", () => {
const getQueries = async file => {
const getQueries = async (file: string) => {
await init(file)
const queries = await curl.getQueries()
const queries = await curl.getQueries("fake_datasource_id")
expect(queries.length).toBe(1)
return queries
}
const testVerb = async (file, verb) => {
const testVerb = async (file: string, verb: string) => {
const queries = await getQueries(file)
expect(queries[0].queryVerb).toBe(verb)
}
@ -59,7 +49,7 @@ describe("Curl Import", () => {
await testVerb("patch", "patch")
})
const testPath = async (file, urlPath) => {
const testPath = async (file: string, urlPath: string) => {
const queries = await getQueries(file)
expect(queries[0].fields.path).toBe(urlPath)
}
@ -69,7 +59,10 @@ describe("Curl Import", () => {
await testPath("path", "http://example.com/paths/abc")
})
const testHeaders = async (file, headers) => {
const testHeaders = async (
file: string,
headers: Record<string, string>
) => {
const queries = await getQueries(file)
expect(queries[0].fields.headers).toStrictEqual(headers)
}
@ -82,7 +75,7 @@ describe("Curl Import", () => {
})
})
const testQuery = async (file, queryString) => {
const testQuery = async (file: string, queryString: string) => {
const queries = await getQueries(file)
expect(queries[0].fields.queryString).toBe(queryString)
}
@ -91,7 +84,7 @@ describe("Curl Import", () => {
await testQuery("query", "q1=v1&q1=v2")
})
const testBody = async (file, body) => {
const testBody = async (file: string, body?: Record<string, any>) => {
const queries = await getQueries(file)
expect(queries[0].fields.requestBody).toStrictEqual(
JSON.stringify(body, null, 2)

View File

@ -1,243 +0,0 @@
const { OpenAPI2 } = require("../../openapi2")
const fs = require("fs")
const path = require("path")
const getData = (file, extension) => {
return fs.readFileSync(
path.join(__dirname, `./data/${file}/${file}.${extension}`),
"utf8"
)
}
describe("OpenAPI2 Import", () => {
let openapi2
beforeEach(() => {
openapi2 = new OpenAPI2()
})
it("validates unsupported data", async () => {
let data
let supported
// non json / yaml
data = "curl http://example.com"
supported = await openapi2.isSupported(data)
expect(supported).toBe(false)
// Empty
data = ""
supported = await openapi2.isSupported(data)
expect(supported).toBe(false)
})
const init = async (file, extension) => {
await openapi2.isSupported(getData(file, extension))
}
const runTests = async (filename, test, assertions) => {
for (let extension of ["json", "yaml"]) {
await test(filename, extension, assertions)
}
}
const testImportInfo = async (file, extension) => {
await init(file, extension)
const info = await openapi2.getInfo()
expect(info.name).toBe("Swagger Petstore")
}
it("returns import info", async () => {
await runTests("petstore", testImportInfo)
})
describe("Returns queries", () => {
const indexQueries = queries => {
return queries.reduce((acc, query) => {
acc[query.name] = query
return acc
}, {})
}
const getQueries = async (file, extension) => {
await init(file, extension)
const queries = await openapi2.getQueries()
expect(queries.length).toBe(6)
return indexQueries(queries)
}
const testVerb = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, method] of Object.entries(assertions)) {
expect(queries[operationId].queryVerb).toBe(method)
}
}
it("populates verb", async () => {
const assertions = {
createEntity: "create",
getEntities: "read",
getEntity: "read",
updateEntity: "update",
patchEntity: "patch",
deleteEntity: "delete",
}
await runTests("crud", testVerb, assertions)
})
const testPath = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, urlPath] of Object.entries(assertions)) {
expect(queries[operationId].fields.path).toBe(urlPath)
}
}
it("populates path", async () => {
const assertions = {
createEntity: "http://example.com/entities",
getEntities: "http://example.com/entities",
getEntity: "http://example.com/entities/{{entityId}}",
updateEntity: "http://example.com/entities/{{entityId}}",
patchEntity: "http://example.com/entities/{{entityId}}",
deleteEntity: "http://example.com/entities/{{entityId}}",
}
await runTests("crud", testPath, assertions)
})
const testHeaders = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, headers] of Object.entries(assertions)) {
expect(queries[operationId].fields.headers).toStrictEqual(headers)
}
}
const contentTypeHeader = {
"Content-Type": "application/json",
}
it("populates headers", async () => {
const assertions = {
createEntity: {
...contentTypeHeader,
},
getEntities: {},
getEntity: {},
updateEntity: {
...contentTypeHeader,
},
patchEntity: {
...contentTypeHeader,
},
deleteEntity: {
"x-api-key": "{{x-api-key}}",
},
}
await runTests("crud", testHeaders, assertions)
})
const testQuery = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, queryString] of Object.entries(assertions)) {
expect(queries[operationId].fields.queryString).toStrictEqual(
queryString
)
}
}
it("populates query", async () => {
const assertions = {
createEntity: "",
getEntities: "page={{page}}&size={{size}}",
getEntity: "",
updateEntity: "",
patchEntity: "",
deleteEntity: "",
}
await runTests("crud", testQuery, assertions)
})
const testParameters = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, parameters] of Object.entries(assertions)) {
expect(queries[operationId].parameters).toStrictEqual(parameters)
}
}
it("populates parameters", async () => {
const assertions = {
createEntity: [],
getEntities: [
{
name: "page",
default: "",
},
{
name: "size",
default: "",
},
],
getEntity: [
{
name: "entityId",
default: "",
},
],
updateEntity: [
{
name: "entityId",
default: "",
},
],
patchEntity: [
{
name: "entityId",
default: "",
},
],
deleteEntity: [
{
name: "entityId",
default: "",
},
{
name: "x-api-key",
default: "",
},
],
}
await runTests("crud", testParameters, assertions)
})
const testBody = async (file, extension, assertions) => {
const queries = await getQueries(file, extension)
for (let [operationId, body] of Object.entries(assertions)) {
expect(queries[operationId].fields.requestBody).toStrictEqual(
JSON.stringify(body, null, 2)
)
}
}
it("populates body", async () => {
const assertions = {
createEntity: {
name: "name",
type: "type",
},
getEntities: undefined,
getEntity: undefined,
updateEntity: {
id: 1,
name: "name",
type: "type",
},
patchEntity: {
id: 1,
name: "name",
type: "type",
},
deleteEntity: undefined,
}
await runTests("crud", testBody, assertions)
})
})
})

View File

@ -0,0 +1,135 @@
import { OpenAPI2 } from "../../openapi2"
import { readFileSync } from "fs"
import { join } from "path"
import { groupBy, mapValues } from "lodash"
import { Query } from "@budibase/types"
const getData = (file: string, extension: string) => {
return readFileSync(
join(__dirname, `./data/${file}/${file}.${extension}`),
"utf8"
)
}
describe("OpenAPI2 Import", () => {
let openapi2: OpenAPI2
beforeEach(() => {
openapi2 = new OpenAPI2()
})
it("validates unsupported data", async () => {
expect(await openapi2.isSupported("curl http://example.com")).toBe(false)
expect(await openapi2.isSupported("")).toBe(false)
})
describe.each(["json", "yaml"])("%s", extension => {
describe("petstore", () => {
beforeEach(async () => {
await openapi2.isSupported(getData("petstore", extension))
})
it("returns import info", async () => {
const { name } = await openapi2.getInfo()
expect(name).toBe("Swagger Petstore")
})
})
describe("crud", () => {
let queries: Record<string, Query>
beforeEach(async () => {
await openapi2.isSupported(getData("crud", extension))
const raw = await openapi2.getQueries("fake_datasource_id")
queries = mapValues(groupBy(raw, "name"), group => group[0])
})
it("should have 6 queries", () => {
expect(Object.keys(queries).length).toBe(6)
})
it.each([
["createEntity", "create"],
["getEntities", "read"],
["getEntity", "read"],
["updateEntity", "update"],
["patchEntity", "patch"],
["deleteEntity", "delete"],
])("should have correct verb for %s", (operationId, method) => {
expect(queries[operationId].queryVerb).toBe(method)
})
it.each([
["createEntity", "http://example.com/entities"],
["getEntities", "http://example.com/entities"],
["getEntity", "http://example.com/entities/{{entityId}}"],
["updateEntity", "http://example.com/entities/{{entityId}}"],
["patchEntity", "http://example.com/entities/{{entityId}}"],
["deleteEntity", "http://example.com/entities/{{entityId}}"],
])("should have correct path for %s", (operationId, urlPath) => {
expect(queries[operationId].fields.path).toBe(urlPath)
})
it.each([
["createEntity", { "Content-Type": "application/json" }],
["getEntities", {}],
["getEntity", {}],
["updateEntity", { "Content-Type": "application/json" }],
["patchEntity", { "Content-Type": "application/json" }],
["deleteEntity", { "x-api-key": "{{x-api-key}}" }],
])(`should have correct headers for %s`, (operationId, headers) => {
expect(queries[operationId].fields.headers).toStrictEqual(headers)
})
it.each([
["createEntity", ""],
["getEntities", "page={{page}}&size={{size}}"],
["getEntity", ""],
["updateEntity", ""],
["patchEntity", ""],
["deleteEntity", ""],
])(
`should have correct query string for %s`,
(operationId, queryString) => {
expect(queries[operationId].fields.queryString).toBe(queryString)
}
)
it.each([
["createEntity", []],
[
"getEntities",
[
{ name: "page", default: "" },
{ name: "size", default: "" },
],
],
["getEntity", [{ name: "entityId", default: "" }]],
["updateEntity", [{ name: "entityId", default: "" }]],
["patchEntity", [{ name: "entityId", default: "" }]],
[
"deleteEntity",
[
{ name: "entityId", default: "" },
{ name: "x-api-key", default: "" },
],
],
])(`should have correct parameters for %s`, (operationId, parameters) => {
expect(queries[operationId].parameters).toStrictEqual(parameters)
})
it.each([
["createEntity", { name: "name", type: "type" }],
["getEntities", undefined],
["getEntity", undefined],
["updateEntity", { id: 1, name: "name", type: "type" }],
["patchEntity", { id: 1, name: "name", type: "type" }],
["deleteEntity", undefined],
])(`should have correct body for %s`, (operationId, body) => {
expect(queries[operationId].fields.requestBody).toBe(
JSON.stringify(body, null, 2)
)
})
})
})
})

View File

@ -2043,6 +2043,101 @@ if (descriptions.length) {
expect(rows[0].name).toEqual("Clare updated")
expect(rows[1].name).toEqual("Jeff updated")
})
it("should reject bulkImport date only fields with wrong format", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
date: {
type: FieldType.DATETIME,
dateOnly: true,
name: "date",
},
},
})
)
await config.api.row.bulkImport(
table._id!,
{
rows: [
{
date: "01.02.2024",
},
],
},
{
status: 400,
body: {
message:
'Invalid format for field "date": "01.02.2024". Date-only fields must be in the format "YYYY-MM-DD".',
},
}
)
})
it("should reject bulkImport date time fields with wrong format", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
date: {
type: FieldType.DATETIME,
name: "date",
},
},
})
)
await config.api.row.bulkImport(
table._id!,
{
rows: [
{
date: "01.02.2024",
},
],
},
{
status: 400,
body: {
message:
'Invalid format for field "date": "01.02.2024". Datetime fields must be in ISO format, e.g. "YYYY-MM-DDTHH:MM:SSZ".',
},
}
)
})
it("should reject bulkImport time fields with wrong format", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
time: {
type: FieldType.DATETIME,
timeOnly: true,
name: "time",
},
},
})
)
await config.api.row.bulkImport(
table._id!,
{
rows: [
{
time: "3pm",
},
],
},
{
status: 400,
body: {
message:
'Invalid format for field "time": "3pm". Time-only fields must be in the format "HH:MM:SS".',
},
}
)
})
})
describe("enrich", () => {
@ -3555,6 +3650,51 @@ if (descriptions.length) {
})
})
if (isInternal || isMSSQL) {
describe("Fields with spaces", () => {
let table: Table
let otherTable: Table
let relatedRow: Row
beforeAll(async () => {
otherTable = await config.api.table.save(defaultTable())
table = await config.api.table.save(
saveTableRequest({
schema: {
links: {
name: "links",
fieldName: "links",
type: FieldType.LINK,
tableId: otherTable._id!,
relationshipType: RelationshipType.ONE_TO_MANY,
},
"nameWithSpace ": {
name: "nameWithSpace ",
type: FieldType.STRING,
},
},
})
)
relatedRow = await config.api.row.save(otherTable._id!, {
name: generator.word(),
description: generator.paragraph(),
})
await config.api.row.save(table._id!, {
"nameWithSpace ": generator.word(),
tableId: table._id!,
links: [relatedRow._id],
})
})
it("Successfully returns rows that have spaces in their field names", async () => {
const { rows } = await config.api.row.search(table._id!)
expect(rows.length).toBe(1)
const row = rows[0]
expect(row["nameWithSpace "]).toBeDefined()
})
})
}
if (!isInternal && !isOracle) {
describe("bigint ids", () => {
let table1: Table, table2: Table

View File

@ -1705,7 +1705,10 @@ if (descriptions.length) {
beforeAll(async () => {
tableOrViewId = await createTableOrView({
dateid: { name: "dateid", type: FieldType.STRING },
dateid: {
name: "dateid",
type: FieldType.STRING,
},
date: {
name: "date",
type: FieldType.DATETIME,
@ -1751,7 +1754,9 @@ if (descriptions.length) {
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
notEqual: {
date: `${JAN_1ST}${SEARCH_SUFFIX}`,
},
}).toContainExactly([
{ date: JAN_10TH },
{ dateid: NULL_DATE__ID },
@ -1760,7 +1765,9 @@ if (descriptions.length) {
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: { date: `${JAN_30TH}${SEARCH_SUFFIX}` },
notEqual: {
date: `${JAN_30TH}${SEARCH_SUFFIX}`,
},
}).toContainExactly([
{ date: JAN_1ST },
{ date: JAN_10TH },
@ -1822,6 +1829,60 @@ if (descriptions.length) {
}).toFindNothing()
})
})
describe("sort", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "date",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ dateid: NULL_DATE__ID },
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "date",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ date: JAN_10TH },
{ date: JAN_1ST },
{ dateid: NULL_DATE__ID },
])
})
describe("sortType STRING", () => {
it("sorts ascending", async () => {
await expectSearch({
query: {},
sort: "date",
sortType: SortType.STRING,
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([
{ dateid: NULL_DATE__ID },
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("sorts descending", async () => {
await expectSearch({
query: {},
sort: "date",
sortType: SortType.STRING,
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([
{ date: JAN_10TH },
{ date: JAN_1ST },
{ dateid: NULL_DATE__ID },
])
})
})
})
}
)
}

View File

@ -276,6 +276,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
encrypt,
enableArithAbort: true,
requestTimeout: env.QUERY_THREAD_TIMEOUT,
connectTimeout: env.QUERY_THREAD_TIMEOUT,
},
}
if (encrypt) {

View File

@ -7,7 +7,7 @@ import {
Table,
} from "@budibase/types"
import { ValidColumnNameRegex, helpers, utils } from "@budibase/shared-core"
import { db } from "@budibase/backend-core"
import { db, HTTPError, sql } from "@budibase/backend-core"
type Rows = Array<Row>
@ -175,15 +175,27 @@ export function parse(rows: Rows, table: Table): Rows {
if ([FieldType.NUMBER].includes(columnType)) {
// If provided must be a valid number
parsedRow[columnName] = columnData ? Number(columnData) : columnData
} else if (
columnType === FieldType.DATETIME &&
!columnSchema.timeOnly &&
!columnSchema.dateOnly
) {
// If provided must be a valid date
} else if (columnType === FieldType.DATETIME) {
if (columnData && !columnSchema.timeOnly) {
if (!sql.utils.isValidISODateString(columnData)) {
let message = `Invalid format for field "${columnName}": "${columnData}".`
if (columnSchema.dateOnly) {
message += ` Date-only fields must be in the format "YYYY-MM-DD".`
} else {
message += ` Datetime fields must be in ISO format, e.g. "YYYY-MM-DDTHH:MM:SSZ".`
}
throw new HTTPError(message, 400)
}
}
if (columnData && columnSchema.timeOnly) {
if (!sql.utils.isValidTime(columnData)) {
throw new HTTPError(
`Invalid format for field "${columnName}": "${columnData}". Time-only fields must be in the format "HH:MM:SS".`,
400
)
}
}
parsedRow[columnName] = columnData
? new Date(columnData).toISOString()
: columnData
} else if (
columnType === FieldType.JSON &&
typeof columnData === "string"

View File

@ -0,0 +1,7 @@
export type JSONValue =
| string
| number
| boolean
| null
| { [key: string]: JSONValue }
| JSONValue[]

View File

@ -1,2 +1,3 @@
export * from "./installation"
export * from "./events"
export * from "./common"

View File

@ -0,0 +1,26 @@
export interface BindingCompletion {
section: {
name: string
}
label: string
}
export interface EnrichedBinding {
runtimeBinding: string
readableBinding: string
type?: null | string
}
export enum BindingMode {
Text = "Text",
JavaScript = "JavaScript",
}
export type CaretPositionFn = () => { start: number; end: number }
export type InsertAtPositionFn = (_: {
start: number
end?: number
value: string
cursor?: { anchor: number }
}) => void

View File

@ -0,0 +1,4 @@
export interface Helper {
example: string
description: string
}

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