Merge branch 'master' into feature/automation-sidebar

This commit is contained in:
deanhannigan 2025-03-31 09:01:17 +01:00 committed by GitHub
commit cd022e4a89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 252 additions and 64 deletions

View File

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

View File

@ -6,6 +6,7 @@ import {
isInvalidISODateString, isInvalidISODateString,
isValidFilter, isValidFilter,
isValidISODateString, isValidISODateString,
isValidTime,
sqlLog, sqlLog,
validateManyToMany, validateManyToMany,
} from "./utils" } from "./utils"
@ -417,11 +418,17 @@ class InternalBuilder {
} }
if (typeof input === "string" && schema.type === FieldType.DATETIME) { if (typeof input === "string" && schema.type === FieldType.DATETIME) {
if (isInvalidISODateString(input)) { if (schema.timeOnly) {
return null if (!isValidTime(input)) {
} return null
if (isValidISODateString(input)) { }
return new Date(input.trim()) } else {
if (isInvalidISODateString(input)) {
return null
}
if (isValidISODateString(input)) {
return new Date(input.trim())
}
} }
} }
return input return input

View File

@ -0,0 +1,35 @@
import { isValidISODateString, isInvalidISODateString } from "../utils"
describe("ISO date string validity checks", () => {
it("accepts a valid ISO date string without a time", () => {
const str = "2013-02-01"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time", () => {
const str = "2013-02-01T01:23:45Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time and millis", () => {
const str = "2013-02-01T01:23:45.678Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("rejects an invalid ISO date string", () => {
const str = "2013-523-814T444:22:11Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(false)
expect(invalid).toEqual(true)
})
})

View File

@ -14,7 +14,7 @@ import environment from "../environment"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ") const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/ const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:.\d{3})?Z)?$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/ const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) { export function isExternalTableID(tableId: string) {
@ -139,17 +139,17 @@ export function breakRowIdField(_id: string | { _id: string }): any[] {
} }
} }
export function isInvalidISODateString(str: string) { export function isValidISODateString(str: string) {
const trimmedValue = str.trim() const trimmedValue = str.trim()
if (!ISO_DATE_REGEX.test(trimmedValue)) { if (!ISO_DATE_REGEX.test(trimmedValue)) {
return false return false
} }
let d = new Date(trimmedValue) const d = new Date(trimmedValue)
return isNaN(d.getTime()) return !isNaN(d.getTime())
} }
export function isValidISODateString(str: string) { export function isInvalidISODateString(str: string) {
return ISO_DATE_REGEX.test(str.trim()) return !isValidISODateString(str)
} }
export function isValidFilter(value: any) { export function isValidFilter(value: any) {

View File

@ -75,6 +75,7 @@
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={isFocused} class:is-focused={isFocused}
> >
<!-- We need to ignore prettier here as we want no whitespace -->
<!-- prettier-ignore --> <!-- prettier-ignore -->
<textarea <textarea
bind:this={textarea} bind:this={textarea}
@ -90,6 +91,7 @@
on:blur on:blur
on:keypress on:keypress
>{value || ""}</textarea> >{value || ""}</textarea>
<slot />
</div> </div>
<style> <style>

View File

@ -114,6 +114,7 @@
inputmode={getInputMode(type)} inputmode={getInputMode(type)}
autocomplete={autocompleteValue} autocomplete={autocompleteValue}
/> />
<slot />
</div> </div>
<style> <style>

View File

@ -41,5 +41,7 @@
on:blur on:blur
on:focus on:focus
on:keyup on:keyup
/> >
<slot />
</TextField>
</Field> </Field>

View File

@ -7,11 +7,13 @@
export let label: string | undefined = undefined export let label: string | undefined = undefined
export let labelPosition = "above" export let labelPosition = "above"
export let placeholder: string | undefined = undefined export let placeholder: string | undefined = undefined
export let disabled = false export let readonly: boolean = false
export let disabled: boolean = false
export let error: string | undefined = undefined export let error: string | undefined = undefined
export let height: number | undefined = undefined export let height: number | undefined = undefined
export let minHeight: number | undefined = undefined export let minHeight: number | undefined = undefined
export let helpText: string | undefined = undefined export let helpText: string | undefined = undefined
export let updateOnChange: boolean = false
let textarea: TextArea let textarea: TextArea
export function focus() { export function focus() {
@ -33,11 +35,16 @@
<TextArea <TextArea
bind:this={textarea} bind:this={textarea}
{disabled} {disabled}
{readonly}
{value} {value}
{placeholder} {placeholder}
{height} {height}
{minHeight} {minHeight}
{updateOnChange}
on:change={onChange} on:change={onChange}
on:keypress on:keypress
/> on:scrollable
>
<slot />
</TextArea>
</Field> </Field>

View File

@ -45,7 +45,7 @@
}, },
{ {
label: "Multi-select", label: "Multi-select",
value: FieldType.ARRAY.type, value: FieldType.ARRAY,
}, },
{ {
label: "Barcode/QR", label: "Barcode/QR",

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon, Input, Drawer, Button, CoreTextArea } from "@budibase/bbui" import { Icon, Input, Drawer, Button, TextArea } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
@ -67,7 +67,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:multiline class:disabled class:scrollable> <div class="control" class:multiline class:disabled class:scrollable>
<svelte:component <svelte:component
this={multiline ? CoreTextArea : Input} this={multiline ? TextArea : Input}
{label} {label}
{disabled} {disabled}
readonly={isJS} readonly={isJS}
@ -78,18 +78,19 @@
{placeholder} {placeholder}
{updateOnChange} {updateOnChange}
{autocomplete} {autocomplete}
/> >
{#if !disabled && !disableBindings} {#if !disabled && !disableBindings}
<div <div
class="icon" class="icon"
on:click={() => { on:click={() => {
builderStore.propertyFocus(key) builderStore.propertyFocus(key)
bindingDrawer.show() bindingDrawer.show()
}} }}
> >
<Icon size="S" name="FlashOn" /> <Icon size="S" name="FlashOn" />
</div> </div>
{/if} {/if}
</svelte:component>
</div> </div>
<Drawer <Drawer
on:drawerHide={onDrawerHide} on:drawerHide={onDrawerHide}

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { MarkdownViewer } from "@budibase/bbui" import { MarkdownViewer } from "@budibase/bbui"
export let text: string = "" export let text: any = ""
export let color: string | undefined = undefined export let color: string | undefined = undefined
export let align: "left" | "center" | "right" | "justify" = "left" export let align: "left" | "center" | "right" | "justify" = "left"
@ -12,6 +12,9 @@
// Add in certain settings to styles // Add in certain settings to styles
$: styles = enrichStyles($component.styles, color, align) $: styles = enrichStyles($component.styles, color, align)
// Ensure we're always passing in a string value to the markdown editor
$: safeText = stringify(text)
const enrichStyles = ( const enrichStyles = (
styles: any, styles: any,
colorStyle: typeof color, colorStyle: typeof color,
@ -31,10 +34,24 @@
}, },
} }
} }
const stringify = (text: any): string => {
if (text == null) {
return ""
}
if (typeof text !== "string") {
try {
return JSON.stringify(text)
} catch (e) {
return ""
}
}
return text
}
</script> </script>
<div use:styleable={styles}> <div use:styleable={styles}>
<MarkdownViewer value={text} /> <MarkdownViewer value={safeText} />
</div> </div>
<style> <style>

View File

@ -82,8 +82,9 @@ export const deriveStores = (context: StoreContext): ConfigDerivedStore => {
config.canEditColumns = false config.canEditColumns = false
} }
// Determine if we can select rows // Determine if we can select rows. Always true in the meantime as you can
config.canSelectRows = !!config.canDeleteRows || !!config.canAddRows // use the selected rows binding regardless of readonly state.
config.canSelectRows = true
return config return config
} }

View File

@ -0,0 +1,12 @@
import { join } from "../../utilities/centralPath"
import { TOP_LEVEL_PATH, DEV_ASSET_PATH } from "../../utilities/fileSystem"
import { Ctx } from "@budibase/types"
import env from "../../environment"
import send from "koa-send"
// this is a public endpoint with no middlewares
export const serveBuilderAssets = async function (ctx: Ctx<void, void>) {
let topLevelPath = env.isDev() ? DEV_ASSET_PATH : TOP_LEVEL_PATH
const builderPath = join(topLevelPath, "builder")
await send(ctx, ctx.file, { root: builderPath })
}

View File

@ -76,11 +76,6 @@ export const toggleBetaUiFeature = async function (
} }
} }
export const serveBuilder = async function (ctx: Ctx<void, void>) {
const builderPath = join(TOP_LEVEL_PATH, "builder")
await send(ctx, ctx.file, { root: builderPath })
}
export const uploadFile = async function ( export const uploadFile = async function (
ctx: Ctx<void, ProcessAttachmentResponse> ctx: Ctx<void, ProcessAttachmentResponse>
) { ) {

View File

@ -1,4 +1,9 @@
import { AIOperationEnum, AutoFieldSubType, FieldType } from "@budibase/types" import {
AIOperationEnum,
AutoFieldSubType,
FieldType,
JsonFieldSubType,
} from "@budibase/types"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { importToRows } from "../utils" import { importToRows } from "../utils"
@ -152,5 +157,33 @@ describe("utils", () => {
]) ])
}) })
}) })
it("coerces strings into arrays for array fields", async () => {
await config.doInContext(config.appId, async () => {
const table = await config.createTable({
name: "table",
type: "table",
schema: {
colours: {
name: "colours",
type: FieldType.ARRAY,
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["red"],
},
},
},
})
const data = [{ colours: "red" }]
const result = await importToRows(data, table, config.user?._id)
expect(result).toEqual([
expect.objectContaining({
colours: ["red"],
}),
])
})
})
}) })
}) })

View File

@ -155,13 +155,19 @@ export async function importToRows(
schema.type === FieldType.ARRAY) && schema.type === FieldType.ARRAY) &&
row[fieldName] row[fieldName]
) { ) {
const rowVal = Array.isArray(row[fieldName]) const isArray = Array.isArray(row[fieldName])
? row[fieldName]
: [row[fieldName]] // Add option to inclusion constraints
const rowVal = isArray ? row[fieldName] : [row[fieldName]]
let merged = [...schema.constraints!.inclusion!, ...rowVal] let merged = [...schema.constraints!.inclusion!, ...rowVal]
let superSet = new Set(merged) let superSet = new Set(merged)
schema.constraints!.inclusion = Array.from(superSet) schema.constraints!.inclusion = Array.from(superSet)
schema.constraints!.inclusion.sort() schema.constraints!.inclusion.sort()
// If array type, ensure we import the value as an array
if (!isArray && schema.type === FieldType.ARRAY) {
row[fieldName] = rowVal
}
} }
} }

View File

@ -8,6 +8,7 @@ import { middleware as pro } from "@budibase/pro"
import { apiEnabled, automationsEnabled } from "../features" import { apiEnabled, automationsEnabled } from "../features"
import migrations from "../middleware/appMigrations" import migrations from "../middleware/appMigrations"
import { automationQueue } from "../automations" import { automationQueue } from "../automations"
import assetRouter from "./routes/assets"
export { shutdown } from "./routes/public" export { shutdown } from "./routes/public"
const compress = require("koa-compress") const compress = require("koa-compress")
@ -44,6 +45,12 @@ if (apiEnabled()) {
) )
// re-direct before any middlewares occur // re-direct before any middlewares occur
.redirect("/", "/builder") .redirect("/", "/builder")
// send assets before middleware
router.use(assetRouter.routes())
router.use(assetRouter.allowedMethods())
router
.use( .use(
auth.buildAuthMiddleware([], { auth.buildAuthMiddleware([], {
publicAllowed: true, publicAllowed: true,

View File

@ -0,0 +1,11 @@
import { addFileManagement } from "../utils"
import { serveBuilderAssets } from "../controllers/assets"
import Router from "@koa/router"
const router: Router = new Router()
addFileManagement(router)
router.get("/builder/:file*", serveBuilderAssets)
export default router

View File

@ -1,35 +1,17 @@
import Router from "@koa/router" import Router from "@koa/router"
import * as controller from "../controllers/static" import * as controller from "../controllers/static"
import { budibaseTempDir } from "../../utilities/budibaseDir"
import authorized from "../../middleware/authorized" import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core" import { permissions } from "@budibase/backend-core"
import env from "../../environment" import { addFileManagement } from "../utils"
import { paramResource } from "../../middleware/resourceId" import { paramResource } from "../../middleware/resourceId"
import { devClientLibPath } from "../../utilities/fileSystem"
const { BUILDER, PermissionType, PermissionLevel } = permissions const { BUILDER, PermissionType, PermissionLevel } = permissions
const router: Router = new Router() const router: Router = new Router()
/* istanbul ignore next */ addFileManagement(router)
router.param("file", async (file: any, ctx: any, next: any) => {
ctx.file = file && file.includes(".") ? file : "index.html"
if (!ctx.file.startsWith("budibase-client")) {
return next()
}
// test serves from require
if (env.isTest()) {
const path = devClientLibPath()
ctx.devPath = path.split(ctx.file)[0]
} else if (env.isDev()) {
// Serving the client library from your local dir in dev
ctx.devPath = budibaseTempDir()
}
return next()
})
router router
.get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary) .get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post("/api/beta/:feature", controller.toggleBetaUiFeature) .post("/api/beta/:feature", controller.toggleBetaUiFeature)

View File

@ -0,0 +1,34 @@
import fs from "fs"
import { join } from "path"
import { DEV_ASSET_PATH } from "../../../utilities/fileSystem"
import * as setup from "./utilities"
const path = join(DEV_ASSET_PATH, "builder")
let addedPath = false
const config = setup.getConfig()
beforeAll(async () => {
if (!fs.existsSync(path)) {
addedPath = true
fs.mkdirSync(path)
}
const indexPath = join(path, "index.html")
if (!fs.existsSync(indexPath)) {
fs.writeFileSync(indexPath, "<html></html>", "utf8")
addedPath = true
}
await config.init()
})
afterAll(() => {
if (addedPath) {
fs.rmSync(path, { recursive: true })
}
})
describe("/builder/:file*", () => {
it("should be able to retrieve the builder file", async () => {
const res = await config.api.assets.get("index.html")
expect(res.text).toContain("<html")
})
})

View File

@ -0,0 +1,23 @@
import env from "../environment"
import { devClientLibPath } from "../utilities/fileSystem"
import { budibaseTempDir } from "../utilities/budibaseDir"
import Router from "@koa/router"
export function addFileManagement(router: Router) {
/* istanbul ignore next */
router.param("file", async (file: any, ctx: any, next: any) => {
ctx.file = file && file.includes(".") ? file : "index.html"
if (!ctx.file.startsWith("budibase-client")) {
return next()
}
// test serves from require
if (env.isTest()) {
const path = devClientLibPath()
ctx.devPath = path.split(ctx.file)[0]
} else if (env.isDev()) {
// Serving the client library from your local dir in dev
ctx.devPath = budibaseTempDir()
}
return next()
})
}

View File

@ -0,0 +1,8 @@
import { TestAPI } from "./base"
export class AssetsAPI extends TestAPI {
get = async (path: string) => {
// has to be raw, body isn't JSON
return await this._requestRaw("get", `/builder/${path}`)
}
}

View File

@ -21,6 +21,7 @@ import { EnvironmentAPI } from "./environment"
import { UserPublicAPI } from "./public/user" import { UserPublicAPI } from "./public/user"
import { MiscAPI } from "./misc" import { MiscAPI } from "./misc"
import { OAuth2API } from "./oauth2" import { OAuth2API } from "./oauth2"
import { AssetsAPI } from "./assets"
export default class API { export default class API {
application: ApplicationAPI application: ApplicationAPI
@ -44,6 +45,7 @@ export default class API {
user: UserAPI user: UserAPI
viewV2: ViewV2API viewV2: ViewV2API
webhook: WebhookAPI webhook: WebhookAPI
assets: AssetsAPI
public: { public: {
user: UserPublicAPI user: UserPublicAPI
@ -71,6 +73,7 @@ export default class API {
this.user = new UserAPI(config) this.user = new UserAPI(config)
this.viewV2 = new ViewV2API(config) this.viewV2 = new ViewV2API(config)
this.webhook = new WebhookAPI(config) this.webhook = new WebhookAPI(config)
this.assets = new AssetsAPI(config)
this.public = { this.public = {
user: new UserPublicAPI(config), user: new UserPublicAPI(config),
} }

View File

@ -8,6 +8,7 @@ import { v4 as uuid } from "uuid"
export const TOP_LEVEL_PATH = export const TOP_LEVEL_PATH =
env.TOP_LEVEL_PATH || resolve(join(__dirname, "..", "..", "..")) env.TOP_LEVEL_PATH || resolve(join(__dirname, "..", "..", ".."))
export const DEV_ASSET_PATH = join(TOP_LEVEL_PATH, "packages", "server")
/** /**
* Upon first startup of instance there may not be everything we need in tmp directory, set it up. * Upon first startup of instance there may not be everything we need in tmp directory, set it up.

View File

@ -106,7 +106,7 @@ export function validate(
} else if ( } else if (
// If provided must be a valid date // If provided must be a valid date
columnType === FieldType.DATETIME && columnType === FieldType.DATETIME &&
isNaN(new Date(columnData).getTime()) sql.utils.isInvalidISODateString(columnData)
) { ) {
results.schemaValidation[columnName] = false results.schemaValidation[columnName] = false
} else if ( } else if (