merge master

This commit is contained in:
Gerard Burns 2024-05-21 09:51:06 +01:00
commit 04cf17cea7
143 changed files with 2577 additions and 1143 deletions

View File

@ -55,7 +55,9 @@
} }
], ],
"no-redeclare": "off", "no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error" "@typescript-eslint/no-redeclare": "error",
// have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off"
} }
}, },
{ {
@ -88,7 +90,9 @@
"jest/expect-expect": "off", "jest/expect-expect": "off",
// We do this in some tests where the behaviour of internal tables // We do this in some tests where the behaviour of internal tables
// differs to external, but the API is broadly the same // differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off" "jest/no-conditional-expect": "off",
// have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off"
} }
}, },
{ {

3
.github/AUTHORS.md vendored
View File

@ -8,4 +8,5 @@ Contributors
* Andrew Kingston - [@aptkingston](https://github.com/aptkingston) * Andrew Kingston - [@aptkingston](https://github.com/aptkingston)
* Michael Drury - [@mike12345567](https://github.com/mike12345567) * Michael Drury - [@mike12345567](https://github.com/mike12345567)
* Peter Clement - [@PClmnt](https://github.com/PClmnt) * Peter Clement - [@PClmnt](https://github.com/PClmnt)
* Rory Powell - [@Rory-Powell](https://github.com/Rory-Powell) * Rory Powell - [@Rory-Powell](https://github.com/Rory-Powell)
* Michaël St-Georges [@CSLTech](https://github.com/CSLTech)

View File

@ -1,5 +1,5 @@
{ {
"version": "2.26.2", "version": "2.26.4",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -1,4 +1,6 @@
import { IdentityContext, Snippet, VM } from "@budibase/types" import { IdentityContext, Snippet, VM } from "@budibase/types"
import { OAuth2Client } from "google-auth-library"
import { GoogleSpreadsheet } from "google-spreadsheet"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -12,4 +14,8 @@ export type ContextMap = {
vm?: VM vm?: VM
cleanup?: (() => void | Promise<void>)[] cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[] snippets?: Snippet[]
googleSheets?: {
oauthClient: OAuth2Client
clients: Record<string, GoogleSpreadsheet>
}
} }

View File

@ -1,14 +1,31 @@
import PouchDB from "pouchdb" import PouchDB from "pouchdb"
import { getPouchDB, closePouchDB } from "./couch" import { getPouchDB, closePouchDB } from "./couch"
import { DocumentType } from "../constants" import { DocumentType } from "@budibase/types"
enum ReplicationDirection {
TO_PRODUCTION = "toProduction",
TO_DEV = "toDev",
}
class Replication { class Replication {
source: PouchDB.Database source: PouchDB.Database
target: PouchDB.Database target: PouchDB.Database
direction: ReplicationDirection | undefined
constructor({ source, target }: { source: string; target: string }) { constructor({ source, target }: { source: string; target: string }) {
this.source = getPouchDB(source) this.source = getPouchDB(source)
this.target = getPouchDB(target) this.target = getPouchDB(target)
if (
source.startsWith(DocumentType.APP_DEV) &&
target.startsWith(DocumentType.APP)
) {
this.direction = ReplicationDirection.TO_PRODUCTION
} else if (
source.startsWith(DocumentType.APP) &&
target.startsWith(DocumentType.APP_DEV)
) {
this.direction = ReplicationDirection.TO_DEV
}
} }
async close() { async close() {
@ -40,12 +57,18 @@ class Replication {
} }
const filter = opts.filter const filter = opts.filter
const direction = this.direction
const toDev = direction === ReplicationDirection.TO_DEV
delete opts.filter delete opts.filter
return { return {
...opts, ...opts,
filter: (doc: any, params: any) => { filter: (doc: any, params: any) => {
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) { // don't sync design documents
if (toDev && doc._id?.startsWith("_design")) {
return false
}
if (doc._id?.startsWith(DocumentType.AUTOMATION_LOG)) {
return false return false
} }
if (doc._id === DocumentType.APP_METADATA) { if (doc._id === DocumentType.APP_METADATA) {

View File

@ -12,6 +12,7 @@ import {
isDocument, isDocument,
RowResponse, RowResponse,
RowValue, RowValue,
SQLiteDefinition,
SqlQueryBinding, SqlQueryBinding,
} from "@budibase/types" } from "@budibase/types"
import { getCouchInfo } from "./connections" import { getCouchInfo } from "./connections"
@ -21,6 +22,8 @@ import { ReadStream, WriteStream } from "fs"
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { SQLITE_DESIGN_DOC_ID } from "../../constants"
import { DDInstrumentedDatabase } from "../instrumentation" import { DDInstrumentedDatabase } from "../instrumentation"
import { checkSlashesInUrl } from "../../helpers"
import env from "../../environment"
const DATABASE_NOT_FOUND = "Database does not exist." const DATABASE_NOT_FOUND = "Database does not exist."
@ -281,25 +284,61 @@ export class DatabaseImpl implements Database {
}) })
} }
async _sqlQuery<T>(
url: string,
method: "POST" | "GET",
body?: Record<string, any>
): Promise<T> {
url = checkSlashesInUrl(`${this.couchInfo.sqlUrl}/${url}`)
const args: { url: string; method: string; cookie: string; body?: any } = {
url,
method,
cookie: this.couchInfo.cookie,
}
if (body) {
args.body = body
}
return this.performCall(() => {
return async () => {
const response = await directCouchUrlCall(args)
const json = await response.json()
if (response.status > 300) {
throw json
}
return json as T
}
})
}
async sql<T extends Document>( async sql<T extends Document>(
sql: string, sql: string,
parameters?: SqlQueryBinding parameters?: SqlQueryBinding
): Promise<T[]> { ): Promise<T[]> {
const dbName = this.name const dbName = this.name
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}` const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
const response = await directCouchUrlCall({ return await this._sqlQuery<T[]>(url, "POST", {
url: `${this.couchInfo.sqlUrl}/${url}`, query: sql,
method: "POST", args: parameters,
cookie: this.couchInfo.cookie,
body: {
query: sql,
args: parameters,
},
}) })
if (response.status > 300) { }
throw new Error(await response.text())
// checks design document is accurate (cleans up tables)
// this will check the design document and remove anything from
// disk which is not supposed to be there
async sqlDiskCleanup(): Promise<void> {
const dbName = this.name
const url = `/${dbName}/_cleanup`
return await this._sqlQuery<void>(url, "POST")
}
// removes a document from sqlite
async sqlPurgeDocument(docIds: string[] | string): Promise<void> {
if (!Array.isArray(docIds)) {
docIds = [docIds]
} }
return (await response.json()) as T[] const dbName = this.name
const url = `/${dbName}/_purge`
return await this._sqlQuery<void>(url, "POST", { docs: docIds })
} }
async query<T extends Document>( async query<T extends Document>(
@ -314,6 +353,17 @@ export class DatabaseImpl implements Database {
async destroy() { async destroy() {
try { try {
if (env.SQS_SEARCH_ENABLE) {
// delete the design document, then run the cleanup operation
try {
const definition = await this.get<SQLiteDefinition>(
SQLITE_DESIGN_DOC_ID
)
await this.remove(SQLITE_DESIGN_DOC_ID, definition._rev)
} finally {
await this.sqlDiskCleanup()
}
}
return await this.nano().db.destroy(this.name) return await this.nano().db.destroy(this.name)
} catch (err: any) { } catch (err: any) {
// didn't exist, don't worry // didn't exist, don't worry

View File

@ -21,7 +21,7 @@ export async function directCouchUrlCall({
url: string url: string
cookie: string cookie: string
method: string method: string
body?: any body?: Record<string, any>
}) { }) {
const params: any = { const params: any = {
method: method, method: method,

View File

@ -56,12 +56,17 @@ export class DDInstrumentedDatabase implements Database {
}) })
} }
remove(idOrDoc: Document): Promise<DocumentDestroyResponse>
remove(idOrDoc: string, rev?: string): Promise<DocumentDestroyResponse>
remove( remove(
id: string | Document, idOrDoc: string | Document,
rev?: string | undefined rev?: string
): Promise<DocumentDestroyResponse> { ): Promise<DocumentDestroyResponse> {
return tracer.trace("db.remove", span => { return tracer.trace("db.remove", span => {
span?.addTags({ db_name: this.name, doc_id: id }) span?.addTags({ db_name: this.name, doc_id: idOrDoc })
const isDocument = typeof idOrDoc === "object"
const id = isDocument ? idOrDoc._id! : idOrDoc
rev = isDocument ? idOrDoc._rev : rev
return this.db.remove(id, rev) return this.db.remove(id, rev)
}) })
} }
@ -160,4 +165,18 @@ export class DDInstrumentedDatabase implements Database {
return this.db.sql(sql, parameters) return this.db.sql(sql, parameters)
}) })
} }
sqlPurgeDocument(docIds: string[] | string): Promise<void> {
return tracer.trace("db.sqlPurgeDocument", span => {
span?.addTags({ db_name: this.name })
return this.db.sqlPurgeDocument(docIds)
})
}
sqlDiskCleanup(): Promise<void> {
return tracer.trace("db.sqlDiskCleanup", span => {
span?.addTags({ db_name: this.name })
return this.db.sqlDiskCleanup()
})
}
} }

View File

@ -109,6 +109,7 @@ const environment = {
API_ENCRYPTION_KEY: getAPIEncryptionKey(), API_ENCRYPTION_KEY: getAPIEncryptionKey(),
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
COUCH_DB_SQL_URL: process.env.COUCH_DB_SQL_URL || "http://localhost:4006", COUCH_DB_SQL_URL: process.env.COUCH_DB_SQL_URL || "http://localhost:4006",
SQS_SEARCH_ENABLE: process.env.SQS_SEARCH_ENABLE,
COUCH_DB_USERNAME: process.env.COUCH_DB_USER, COUCH_DB_USERNAME: process.env.COUCH_DB_USER,
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD, COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,

View File

@ -492,7 +492,7 @@ export class UserDB {
await platform.users.removeUser(dbUser) await platform.users.removeUser(dbUser)
await db.remove(userId, dbUser._rev) await db.remove(userId, dbUser._rev!)
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete) await UserDB.quotas.removeUsers(1, creatorsToDelete)

View File

@ -71,8 +71,8 @@ const handleMouseDown = e => {
// Clear any previous listeners in case of multiple down events, and register // Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener // a single mouse up listener
document.removeEventListener("mouseup", handleMouseUp) document.removeEventListener("click", handleMouseUp)
document.addEventListener("mouseup", handleMouseUp, true) document.addEventListener("click", handleMouseUp, true)
} }
// Global singleton listeners for our events // Global singleton listeners for our events

View File

@ -155,6 +155,8 @@ export default function positionDropdown(element, opts) {
applyXStrategy(Strategies.StartToEnd) applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") { } else if (align === "left-outside") {
applyXStrategy(Strategies.EndToStart) applyXStrategy(Strategies.EndToStart)
} else if (align === "center") {
applyXStrategy(Strategies.MidPoint)
} else { } else {
applyXStrategy(Strategies.StartToStart) applyXStrategy(Strategies.StartToStart)
} }

View File

@ -0,0 +1,267 @@
<script>
import { onMount, createEventDispatcher } from "svelte"
import Atrament from "atrament"
import Icon from "../../Icon/Icon.svelte"
const dispatch = createEventDispatcher()
let last
export let value
export let disabled = false
export let editable = true
export let width = 400
export let height = 220
export let saveIcon = false
export let darkMode
export function toDataUrl() {
// PNG to preserve transparency
return canvasRef.toDataURL("image/png")
}
export function toFile() {
const data = canvasContext
.getImageData(0, 0, width, height)
.data.some(channel => channel !== 0)
if (!data) {
return
}
let dataURIParts = toDataUrl().split(",")
if (!dataURIParts.length) {
console.error("Could not retrieve signature data")
}
// Pull out the base64 encoded byte data
let binaryVal = atob(dataURIParts[1])
let blobArray = new Uint8Array(binaryVal.length)
let pos = 0
while (pos < binaryVal.length) {
blobArray[pos] = binaryVal.charCodeAt(pos)
pos++
}
const signatureBlob = new Blob([blobArray], {
type: "image/png",
})
return new File([signatureBlob], "signature.png", {
type: signatureBlob.type,
})
}
export function clearCanvas() {
return canvasContext.clearRect(0, 0, canvasWidth, canvasHeight)
}
let canvasRef
let canvasContext
let canvasWrap
let canvasWidth
let canvasHeight
let signature
let updated = false
let signatureFile
let urlFailed
$: if (value) {
signatureFile = value
}
$: if (signatureFile?.url) {
updated = false
}
$: if (last) {
dispatch("update")
}
onMount(() => {
if (!editable) {
return
}
const getPos = e => {
var rect = canvasRef.getBoundingClientRect()
const canvasX = e.offsetX || e.targetTouches?.[0].pageX - rect.left
const canvasY = e.offsetY || e.targetTouches?.[0].pageY - rect.top
return { x: canvasX, y: canvasY }
}
const checkUp = e => {
last = getPos(e)
}
canvasRef.addEventListener("pointerdown", e => {
const current = getPos(e)
//If the cursor didn't move at all, block the default pointerdown
if (last?.x === current?.x && last?.y === current?.y) {
e.preventDefault()
e.stopImmediatePropagation()
}
})
document.addEventListener("pointerup", checkUp)
signature = new Atrament(canvasRef, {
width,
height,
color: "white",
})
signature.weight = 4
signature.smoothing = 2
canvasWrap.style.width = `${width}px`
canvasWrap.style.height = `${height}px`
const { width: wrapWidth, height: wrapHeight } =
canvasWrap.getBoundingClientRect()
canvasHeight = wrapHeight
canvasWidth = wrapWidth
canvasContext = canvasRef.getContext("2d")
return () => {
signature.destroy()
document.removeEventListener("pointerup", checkUp)
}
})
</script>
<div class="signature" class:light={!darkMode} class:image-error={urlFailed}>
{#if !disabled}
<div class="overlay">
{#if updated && saveIcon}
<span class="save">
<Icon
name="Checkmark"
hoverable
tooltip={"Save"}
tooltipPosition={"top"}
tooltipType={"info"}
on:click={() => {
dispatch("change", toDataUrl())
}}
/>
</span>
{/if}
{#if signatureFile?.url && !updated}
<span class="delete">
<Icon
name="DeleteOutline"
hoverable
tooltip={"Delete"}
tooltipPosition={"top"}
tooltipType={"info"}
on:click={() => {
if (editable) {
clearCanvas()
}
dispatch("clear")
}}
/>
</span>
{/if}
</div>
{/if}
{#if !editable && signatureFile?.url}
<!-- svelte-ignore a11y-missing-attribute -->
{#if !urlFailed}
<img
src={signatureFile?.url}
on:error={() => {
urlFailed = true
}}
/>
{:else}
Could not load signature
{/if}
{:else}
<div bind:this={canvasWrap} class="canvas-wrap">
<canvas
id="signature-canvas"
bind:this={canvasRef}
style="--max-sig-width: {width}px; --max-sig-height: {height}px"
/>
{#if editable}
<div class="indicator-overlay">
<div class="sign-here">
<Icon name="Close" />
<hr />
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.indicator-overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0px;
display: flex;
flex-direction: column;
justify-content: end;
padding: var(--spectrum-global-dimension-size-150);
box-sizing: border-box;
pointer-events: none;
}
.sign-here {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spectrum-global-dimension-size-150);
}
.sign-here hr {
border: 0;
border-top: 2px solid var(--spectrum-global-color-gray-200);
width: 100%;
}
.canvas-wrap {
position: relative;
margin: auto;
}
.signature img {
max-width: 100%;
}
#signature-canvas {
max-width: var(--max-sig-width);
max-height: var(--max-sig-height);
}
.signature.light img,
.signature.light #signature-canvas {
-webkit-filter: invert(100%);
filter: invert(100%);
}
.signature.image-error .overlay {
padding-top: 0px;
}
.signature {
width: 100%;
height: 100%;
position: relative;
text-align: center;
}
.overlay {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
padding: var(--spectrum-global-dimension-size-150);
text-align: right;
z-index: 2;
box-sizing: border-box;
}
.save,
.delete {
display: inline-block;
}
</style>

View File

@ -16,3 +16,4 @@ export { default as CoreStepper } from "./Stepper.svelte"
export { default as CoreRichTextField } from "./RichTextField.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte"
export { default as CoreSlider } from "./Slider.svelte" export { default as CoreSlider } from "./Slider.svelte"
export { default as CoreFile } from "./File.svelte" export { default as CoreFile } from "./File.svelte"
export { default as CoreSignature } from "./Signature.svelte"

View File

@ -173,6 +173,7 @@
} }
.spectrum-Modal { .spectrum-Modal {
border: 2px solid var(--spectrum-global-color-gray-200);
overflow: visible; overflow: visible;
max-height: none; max-height: none;
margin: 40px 0; margin: 40px 0;

View File

@ -27,6 +27,7 @@
export let secondaryButtonText = undefined export let secondaryButtonText = undefined
export let secondaryAction = undefined export let secondaryAction = undefined
export let secondaryButtonWarning = false export let secondaryButtonWarning = false
export let custom = false
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -63,12 +64,13 @@
class:spectrum-Dialog--medium={size === "M"} class:spectrum-Dialog--medium={size === "M"}
class:spectrum-Dialog--large={size === "L"} class:spectrum-Dialog--large={size === "L"}
class:spectrum-Dialog--extraLarge={size === "XL"} class:spectrum-Dialog--extraLarge={size === "XL"}
class:no-grid={custom}
style="position: relative;" style="position: relative;"
role="dialog" role="dialog"
tabindex="-1" tabindex="-1"
aria-modal="true" aria-modal="true"
> >
<div class="spectrum-Dialog-grid"> <div class="modal-core" class:spectrum-Dialog-grid={!custom}>
{#if title || $$slots.header} {#if title || $$slots.header}
<h1 <h1
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader" class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
@ -153,6 +155,25 @@
.spectrum-Dialog-content { .spectrum-Dialog-content {
overflow: visible; overflow: visible;
} }
.no-grid .spectrum-Dialog-content {
border-top: 2px solid var(--spectrum-global-color-gray-200);
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
}
.no-grid .spectrum-Dialog-heading {
margin-top: 12px;
margin-left: 12px;
}
.spectrum-Dialog.no-grid {
width: 100%;
}
.spectrum-Dialog.no-grid .spectrum-Dialog-buttonGroup {
padding: 12px;
}
.spectrum-Dialog-heading { .spectrum-Dialog-heading {
font-family: var(--font-accent); font-family: var(--font-accent);
font-weight: 600; font-weight: 600;

View File

@ -1,40 +1,28 @@
<script> <script>
import { getContext, onMount, createEventDispatcher } from "svelte" import { getContext, onDestroy } from "svelte"
import Portal from "svelte-portal" import Portal from "svelte-portal"
export let title export let title
export let icon = "" export let icon = ""
export let id export let id
const dispatch = createEventDispatcher()
let selected = getContext("tab") let selected = getContext("tab")
let tab_internal let observer
let tabInfo let ref
const setTabInfo = () => { $: isSelected = $selected.title === title
// If the tabs are being rendered inside a component which uses $: {
// a svelte transition to enter, then this initial getBoundingClientRect if (isSelected && ref) {
// will return an incorrect position. observe()
// We just need to get this off the main thread to fix this, by using } else {
// a 0ms timeout. stopObserving()
setTimeout(() => { }
tabInfo = tab_internal?.getBoundingClientRect()
if (tabInfo && $selected.title === title) {
$selected.info = tabInfo
}
}, 0)
} }
onMount(() => { const setTabInfo = () => {
setTabInfo() const tabInfo = ref?.getBoundingClientRect()
}) if (tabInfo) {
$selected.info = tabInfo
//Ensure that the underline is in the correct location
$: {
if ($selected.title === title && tab_internal) {
if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) {
setTabInfo()
}
} }
} }
@ -42,10 +30,25 @@
$selected = { $selected = {
...$selected, ...$selected,
title, title,
info: tab_internal.getBoundingClientRect(), info: ref.getBoundingClientRect(),
} }
dispatch("click")
} }
const observe = () => {
if (!observer) {
observer = new ResizeObserver(setTabInfo)
observer.observe(ref)
}
}
const stopObserving = () => {
if (observer) {
observer.unobserve(ref)
observer = null
}
}
onDestroy(stopObserving)
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
@ -53,11 +56,12 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div <div
{id} {id}
bind:this={tab_internal} bind:this={ref}
on:click={onClick} on:click={onClick}
class:is-selected={$selected.title === title} on:click
class="spectrum-Tabs-item" class="spectrum-Tabs-item"
class:emphasized={$selected.title === title && $selected.emphasized} class:is-selected={isSelected}
class:emphasized={isSelected && $selected.emphasized}
tabindex="0" tabindex="0"
> >
{#if icon} {#if icon}
@ -72,7 +76,8 @@
{/if} {/if}
<span class="spectrum-Tabs-itemLabel">{title}</span> <span class="spectrum-Tabs-itemLabel">{title}</span>
</div> </div>
{#if $selected.title === title}
{#if isSelected}
<Portal target=".spectrum-Tabs-content-{$selected.id}"> <Portal target=".spectrum-Tabs-content-{$selected.id}">
<slot /> <slot />
</Portal> </Portal>

View File

@ -9,7 +9,7 @@
import TestDataModal from "./TestDataModal.svelte" import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate" import { flip } from "svelte/animate"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import { Icon, notifications, Modal } from "@budibase/bbui" import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte" import UndoRedoControl from "components/common/UndoRedoControl.svelte"
@ -73,6 +73,16 @@
Test details Test details
</div> </div>
</div> </div>
<div class="setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
value={!automation.disabled}
/>
</div>
</div> </div>
</div> </div>
<div class="canvas" on:scroll={handleScroll}> <div class="canvas" on:scroll={handleScroll}>

View File

@ -61,6 +61,7 @@
selected={automation._id === selectedAutomationId} selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)} on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]} selectedBy={$userSelectedResourceMap[automation._id]}
disabled={automation.disabled}
> >
<EditAutomationPopover {automation} /> <EditAutomationPopover {automation} />
</NavItem> </NavItem>

View File

@ -39,6 +39,15 @@
>Duplicate</MenuItem >Duplicate</MenuItem
> >
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem> <MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem>
<MenuItem
icon={automation.disabled ? "CheckmarkCircle" : "Cancel"}
on:click={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
>
{automation.disabled ? "Activate" : "Pause"}
</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem> <MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu> </ActionMenu>

View File

@ -364,6 +364,7 @@
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" && value.customType !== "automationFields" &&
value.type !== "signature_single" &&
value.type !== "attachment" && value.type !== "attachment" &&
value.type !== "attachment_single" value.type !== "attachment_single"
) )
@ -456,7 +457,7 @@
value={inputData[key]} value={inputData[key]}
options={Object.keys(table?.schema || {})} options={Object.keys(table?.schema || {})}
/> />
{:else if value.type === "attachment"} {:else if value.type === "attachment" || value.type === "signature_single"}
<div class="attachment-field-wrapper"> <div class="attachment-field-wrapper">
<div class="label-wrapper"> <div class="label-wrapper">
<Label>{label}</Label> <Label>{label}</Label>

View File

@ -24,6 +24,11 @@
let table let table
let schemaFields let schemaFields
let attachmentTypes = [
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.SIGNATURE_SINGLE,
]
$: { $: {
table = $tables.list.find(table => table._id === value?.tableId) table = $tables.list.find(table => table._id === value?.tableId)
@ -120,15 +125,9 @@
{#if schemaFields.length} {#if schemaFields.length}
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
{#if !schema.autocolumn} {#if !schema.autocolumn}
<div <div class:schema-fields={!attachmentTypes.includes(schema.type)}>
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label> <Label>{field}</Label>
<div <div class:field-width={!attachmentTypes.includes(schema.type)}>
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}

View File

@ -21,6 +21,12 @@
return clone return clone
}) })
let attachmentTypes = [
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.SIGNATURE_SINGLE,
]
function schemaHasOptions(schema) { function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length return !!schema.constraints?.inclusion?.length
} }
@ -29,7 +35,8 @@
let params = {} let params = {}
if ( if (
schema.type === FieldType.ATTACHMENT_SINGLE && (schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE) &&
Object.keys(keyValueObj).length === 0 Object.keys(keyValueObj).length === 0
) { ) {
return [] return []
@ -100,16 +107,20 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}
/> />
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE} {:else if attachmentTypes.includes(schema.type)}
<div class="attachment-field-spacinng"> <div class="attachment-field-spacinng">
<KeyValueBuilder <KeyValueBuilder
on:change={e => on:change={e =>
onChange( onChange(
{ {
detail: detail:
schema.type === FieldType.ATTACHMENT_SINGLE schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE
? e.detail.length > 0 ? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value } ? {
url: e.detail[0].name,
filename: e.detail[0].value,
}
: {} : {}
: e.detail.map(({ name, value }) => ({ : e.detail.map(({ name, value }) => ({
url: name, url: name,
@ -125,7 +136,8 @@
customButtonText={"Add attachment"} customButtonText={"Add attachment"}
keyPlaceholder={"URL"} keyPlaceholder={"URL"}
valuePlaceholder={"Filename"} valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE && actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE) &&
Object.keys(value[field]).length >= 1} Object.keys(value[field]).length >= 1}
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
<script> <script>
import { API } from "api"
import { import {
Input, Input,
Select, Select,
@ -8,15 +9,19 @@
Label, Label,
RichTextField, RichTextField,
TextArea, TextArea,
CoreSignature,
ActionButton,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte" import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import Editor from "../../integration/QueryEditor.svelte" import Editor from "../../integration/QueryEditor.svelte"
import { SignatureModal } from "@budibase/frontend-core/src/components"
import { themeStore } from "stores/portal"
export let defaultValue
export let meta export let meta
export let value = defaultValue || (meta.type === "boolean" ? false : "") export let value
export let readonly export let readonly
export let error export let error
@ -39,8 +44,35 @@
const timeStamp = resolveTimeStamp(value) const timeStamp = resolveTimeStamp(value)
const isTimeStamp = !!timeStamp || meta?.timeOnly const isTimeStamp = !!timeStamp || meta?.timeOnly
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
let signatureModal
</script> </script>
<SignatureModal
{darkMode}
onConfirm={async sigCanvas => {
const signatureFile = sigCanvas.toFile()
let attachRequest = new FormData()
attachRequest.append("file", signatureFile)
try {
const uploadReq = await API.uploadBuilderAttachment(attachRequest)
const [signatureAttachment] = uploadReq
value = signatureAttachment
} catch (error) {
$notifications.error(error.message || "Failed to save signature")
value = []
}
}}
title={meta.name}
{value}
bind:this={signatureModal}
/>
{#if type === "options" && meta.constraints.inclusion.length !== 0} {#if type === "options" && meta.constraints.inclusion.length !== 0}
<Select <Select
{label} {label}
@ -59,7 +91,51 @@
bind:value bind:value
/> />
{:else if type === "attachment"} {:else if type === "attachment"}
<Dropzone {label} {error} bind:value /> <Dropzone
compact
{label}
{error}
{value}
on:change={e => {
value = e.detail
}}
/>
{:else if type === "attachment_single"}
<Dropzone
compact
{label}
{error}
value={value ? [value] : []}
on:change={e => {
value = e.detail?.[0]
}}
maximum={1}
/>
{:else if type === "signature_single"}
<div class="signature">
<Label>{label}</Label>
<div class="sig-wrap" class:display={value}>
{#if value}
<CoreSignature
{darkMode}
{value}
editable={false}
on:clear={() => {
value = null
}}
/>
{:else}
<ActionButton
fullWidth
on:click={() => {
signatureModal.show()
}}
>
Add signature
</ActionButton>
{/if}
</div>
</div>
{:else if type === "boolean"} {:else if type === "boolean"}
<Toggle text={label} {error} bind:value /> <Toggle text={label} {error} bind:value />
{:else if type === "array" && meta.constraints.inclusion.length !== 0} {:else if type === "array" && meta.constraints.inclusion.length !== 0}
@ -95,3 +171,22 @@
{:else} {:else}
<Input {label} {type} {error} bind:value disabled={readonly} /> <Input {label} {type} {error} bind:value disabled={readonly} />
{/if} {/if}
<style>
.signature :global(label.spectrum-FieldLabel) {
padding-top: var(--spectrum-fieldlabel-padding-top);
padding-bottom: var(--spectrum-fieldlabel-padding-bottom);
}
.sig-wrap.display {
min-height: 50px;
justify-content: center;
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--spectrum-global-color-gray-50);
box-sizing: border-box;
border: var(--spectrum-alias-border-size-thin)
var(--spectrum-alias-border-color) solid;
border-radius: var(--spectrum-alias-border-radius-regular);
}
</style>

View File

@ -1,5 +1,6 @@
<script> <script>
import { datasources, tables, integrations, appStore } from "stores/builder" import { datasources, tables, integrations, appStore } from "stores/builder"
import { themeStore, admin } from "stores/portal"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
@ -37,6 +38,9 @@
}) })
$: relationshipsEnabled = relationshipSupport(tableDatasource) $: relationshipsEnabled = relationshipSupport(tableDatasource)
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
const relationshipSupport = datasource => { const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source] const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false return !isInternal && integration?.relationships !== false
@ -55,6 +59,7 @@
<div class="wrapper"> <div class="wrapper">
<Grid <Grid
{API} {API}
{darkMode}
datasource={gridDatasource} datasource={gridDatasource}
canAddRows={!isUsersTable} canAddRows={!isUsersTable}
canDeleteRows={!isUsersTable} canDeleteRows={!isUsersTable}
@ -63,6 +68,7 @@
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false} showAvatars={false}
on:updatedatasource={handleGridTableUpdate} on:updatedatasource={handleGridTableUpdate}
isCloud={$admin.cloud}
> >
<svelte:fragment slot="filter"> <svelte:fragment slot="filter">
{#if isUsersTable && $appStore.features.disableUserMetadata} {#if isUsersTable && $appStore.features.disableUserMetadata}

View File

@ -1,5 +1,6 @@
<script> <script>
import { viewsV2 } from "stores/builder" import { viewsV2 } from "stores/builder"
import { admin } from "stores/portal"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte" import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
@ -26,6 +27,7 @@
allowDeleteRows allowDeleteRows
showAvatars={false} showAvatars={false}
on:updatedatasource={handleGridViewUpdate} on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud}
> >
<svelte:fragment slot="filter"> <svelte:fragment slot="filter">
<GridFilterButton /> <GridFilterButton />

View File

@ -9,6 +9,7 @@ const MAX_DEPTH = 1
const TYPES_TO_SKIP = [ const TYPES_TO_SKIP = [
FieldType.FORMULA, FieldType.FORMULA,
FieldType.LONGFORM, FieldType.LONGFORM,
FieldType.SIGNATURE_SINGLE,
FieldType.ATTACHMENTS, FieldType.ATTACHMENTS,
//https://github.com/Budibase/budibase/issues/3030 //https://github.com/Budibase/budibase/issues/3030
FieldType.INTERNAL, FieldType.INTERNAL,

View File

@ -398,44 +398,51 @@
if (!externalTable) { if (!externalTable) {
return [ return [
FIELDS.STRING, FIELDS.STRING,
FIELDS.BARCODEQR, FIELDS.NUMBER,
FIELDS.LONGFORM,
FIELDS.OPTIONS, FIELDS.OPTIONS,
FIELDS.ARRAY, FIELDS.ARRAY,
FIELDS.NUMBER,
FIELDS.BIGINT,
FIELDS.BOOLEAN, FIELDS.BOOLEAN,
FIELDS.DATETIME, FIELDS.DATETIME,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.LINK, FIELDS.LINK,
FIELDS.FORMULA, FIELDS.LONGFORM,
FIELDS.JSON,
FIELDS.USER, FIELDS.USER,
FIELDS.USERS, FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT,
FIELDS.AUTO, FIELDS.AUTO,
] ]
} else { } else {
let fields = [ let fields = [
FIELDS.STRING, FIELDS.STRING,
FIELDS.BARCODEQR,
FIELDS.LONGFORM,
FIELDS.OPTIONS,
FIELDS.DATETIME,
FIELDS.NUMBER, FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN, FIELDS.BOOLEAN,
FIELDS.FORMULA, FIELDS.DATETIME,
FIELDS.BIGINT, FIELDS.LINK,
FIELDS.LONGFORM,
FIELDS.USER, FIELDS.USER,
FIELDS.USERS,
FIELDS.FORMULA,
FIELDS.BARCODEQR,
FIELDS.BIGINT,
] ]
if (datasource && datasource.source !== SourceName.GOOGLE_SHEETS) { // Filter out multiple users for google sheets
fields.push(FIELDS.USERS) if (datasource?.source === SourceName.GOOGLE_SHEETS) {
fields = fields.filter(x => x !== FIELDS.USERS)
} }
// no-sql or a spreadsheet
if (!externalTable || table.sql) { // Filter out SQL-specific types for non-SQL datasources
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] if (!table.sql) {
fields = fields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
} }
return fields return fields
} }
} }

View File

@ -86,8 +86,9 @@ export const createValidatedConfigStore = (integration, config) => {
([$configStore, $errorsStore, $selectedValidatorsStore]) => { ([$configStore, $errorsStore, $selectedValidatorsStore]) => {
const validatedConfig = [] const validatedConfig = []
const allowedRestKeys = ["rejectUnauthorized", "downloadImages"]
Object.entries(integration.datasource).forEach(([key, properties]) => { Object.entries(integration.datasource).forEach(([key, properties]) => {
if (integration.name === "REST" && key !== "rejectUnauthorized") { if (integration.name === "REST" && !allowedRestKeys.includes(key)) {
return return
} }

View File

@ -54,6 +54,10 @@
label: "Attachment", label: "Attachment",
value: FieldType.ATTACHMENT_SINGLE, value: FieldType.ATTACHMENT_SINGLE,
}, },
{
label: "Signature",
value: FieldType.SIGNATURE_SINGLE,
},
{ {
label: "Attachment list", label: "Attachment list",
value: FieldType.ATTACHMENTS, value: FieldType.ATTACHMENTS,

View File

@ -0,0 +1,50 @@
import { it, expect, describe, vi } from "vitest"
import Dropzone from "./Dropzone.svelte"
import { render, fireEvent } from "@testing-library/svelte"
import { notifications } from "@budibase/bbui"
import { admin } from "stores/portal"
vi.spyOn(notifications, "error").mockImplementation(() => {})
describe("Dropzone", () => {
let instance = null
afterEach(() => {
vi.restoreAllMocks()
})
it("that the Dropzone is rendered", () => {
instance = render(Dropzone, {})
expect(instance).toBeDefined()
})
it("Ensure the correct error message is shown when uploading the file in cloud", async () => {
admin.subscribe = vi.fn().mockImplementation(callback => {
callback({ cloud: true })
return () => {}
})
instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB
const fileInput = instance.getByLabelText("Select a file to upload")
const file = new File(["hello".repeat(2000000)], "hello.png", {
type: "image/png",
})
await fireEvent.change(fileInput, { target: { files: [file] } })
expect(notifications.error).toHaveBeenCalledWith(
"Files cannot exceed 1MB. Please try again with smaller files."
)
})
it("Ensure the file size error message is not shown when running on self host", async () => {
admin.subscribe = vi.fn().mockImplementation(callback => {
callback({ cloud: false })
return () => {}
})
instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB
const fileInput = instance.getByLabelText("Select a file to upload")
const file = new File(["hello".repeat(2000000)], "hello.png", {
type: "image/png",
})
await fireEvent.change(fileInput, { target: { files: [file] } })
expect(notifications.error).not.toHaveBeenCalled()
})
})

View File

@ -1,9 +1,11 @@
<script> <script>
import { Dropzone, notifications } from "@budibase/bbui" import { Dropzone, notifications } from "@budibase/bbui"
import { admin } from "stores/portal"
import { API } from "api" import { API } from "api"
export let value = [] export let value = []
export let label export let label
export let fileSizeLimit = undefined
const BYTES_IN_MB = 1000000 const BYTES_IN_MB = 1000000
@ -34,5 +36,6 @@
{label} {label}
{...$$restProps} {...$$restProps}
{processFiles} {processFiles}
{handleFileTooLarge} handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null}
{fileSizeLimit}
/> />

View File

@ -7,6 +7,7 @@
export let app export let app
export let color export let color
export let autoSave = false export let autoSave = false
export let disabled = false
let modal let modal
</script> </script>
@ -14,12 +15,16 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="editable-icon"> <div class="editable-icon">
<div class="hover" on:click={modal.show}> {#if !disabled}
<Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" /> <div class="hover" on:click={modal.show}>
</div> <Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" />
<div class="normal"> </div>
<div class="normal">
<Icon name={name || "Apps"} {size} {color} />
</div>
{:else}
<Icon {name} {size} {color} /> <Icon {name} {size} {color} />
</div> {/if}
</div> </div>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -25,6 +25,7 @@
export let selectedBy = null export let selectedBy = null
export let compact = false export let compact = false
export let hovering = false export let hovering = false
export let disabled = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -74,6 +75,7 @@
class:scrollable class:scrollable
class:highlighted class:highlighted
class:selectedBy class:selectedBy
class:disabled
on:dragend on:dragend
on:dragstart on:dragstart
on:dragover on:dragover
@ -165,6 +167,9 @@
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
color: var(--ink); color: var(--ink);
} }
.nav-item.disabled span {
color: var(--spectrum-global-color-gray-700);
}
.nav-item:hover, .nav-item:hover,
.hovering { .hovering {
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-200);

View File

@ -0,0 +1,214 @@
<script>
import { Button, Label, Icon, Input, notifications } from "@budibase/bbui"
import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder"
import { appsStore } from "stores/portal"
import { API } from "api"
import { writable } from "svelte/store"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
import EditableIcon from "components/common/EditableIcon.svelte"
import { isEqual } from "lodash"
import { createEventDispatcher } from "svelte"
export let alignActions = "left"
const values = writable({})
const validation = createValidationStore()
const dispatch = createEventDispatcher()
let updating = false
let edited = false
let initialised = false
$: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
$: appName = $appStore.name
$: appURL = $appStore.url
$: appIconName = $appStore.icon?.name
$: appIconColor = $appStore.icon?.color
$: appMeta = {
name: appName,
url: appURL,
iconName: appIconName,
iconColor: appIconColor,
}
const initForm = appMeta => {
edited = false
values.set({
...appMeta,
})
if (!initialised) {
setupValidation()
initialised = true
}
}
const validate = (vals, appMeta) => {
const { url } = vals || {}
validation.check({
...vals,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
edited = !isEqual(vals, appMeta)
}
// On app/apps update, reset the state.
$: initForm(appMeta)
$: validate($values, appMeta)
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(null, name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(null, appName)
tidyUrl(resolvedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const updateIcon = e => {
const { name, color } = e.detail
$values.iconColor = color
$values.iconName = name
}
const setupValidation = async () => {
appValidation.name(validation, {
apps: $appsStore.apps,
currentApp: app,
})
appValidation.url(validation, {
apps: $appsStore.apps,
currentApp: app,
})
}
async function updateApp() {
try {
await appsStore.save($appStore.appId, {
name: $values.name?.trim(),
url: $values.url?.trim(),
icon: {
name: $values.iconName,
color: $values.iconColor,
},
})
await initialiseApp()
notifications.success("App update successful")
} catch (error) {
console.error(error)
notifications.error("Error updating app")
}
}
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.appId)
await initialise(applicationPkg)
}
</script>
<div class="form">
<div class="fields">
<div class="field">
<Label size="L">Name</Label>
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
disabled={appDeployed}
/>
</div>
<div class="field">
<Label size="L">URL</Label>
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(null, $values.name)}`}
disabled={appDeployed}
/>
</div>
<div class="field">
<Label size="L">Icon</Label>
<EditableIcon
{app}
size="XL"
name={$values.iconName}
color={$values.iconColor}
on:change={updateIcon}
disabled={appDeployed}
/>
</div>
<div class="actions" class:right={alignActions === "right"}>
{#if !appDeployed}
<Button
cta
on:click={async () => {
updating = true
await updateApp()
updating = false
dispatch("updated")
}}
disabled={appDeployed || updating || !edited || !$validation.valid}
>
Save
</Button>
{:else}
<div class="edit-info">
<Icon size="S" name="Info" /> Unpublish your app to edit name and URL
</div>
{/if}
</div>
</div>
</div>
<style>
.actions {
display: flex;
}
.actions.right {
justify-content: end;
}
.fields {
display: grid;
grid-gap: var(--spacing-l);
}
.field {
display: grid;
grid-template-columns: 80px 220px;
grid-gap: var(--spacing-l);
align-items: center;
}
.edit-info {
display: flex;
gap: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,68 @@
<script>
import { Popover, Layout, Icon } from "@budibase/bbui"
import UpdateAppForm from "./UpdateAppForm.svelte"
let formPopover
let formPopoverAnchor
let formPopoverOpen = false
</script>
<div bind:this={formPopoverAnchor}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="app-heading"
class:editing={formPopoverOpen}
on:click={() => {
formPopover.show()
}}
>
<slot />
<span class="edit-icon">
<Icon size="S" name="Edit" color={"var(--grey-7)"} />
</span>
</div>
</div>
<Popover
customZindex={998}
bind:this={formPopover}
align="center"
anchor={formPopoverAnchor}
offset={20}
on:close={() => {
formPopoverOpen = false
}}
on:open={() => {
formPopoverOpen = true
}}
>
<Layout noPadding gap="M">
<div class="popover-content">
<UpdateAppForm
on:updated={() => {
formPopover.hide()
}}
/>
</div>
</Layout>
</Popover>
<style>
.popover-content {
padding: var(--spacing-xl);
}
.app-heading {
display: flex;
cursor: pointer;
align-items: center;
gap: var(--spacing-s);
}
.edit-icon {
display: none;
}
.app-heading:hover .edit-icon,
.app-heading.editing .edit-icon {
display: inline;
}
</style>

View File

@ -28,6 +28,12 @@
let bindingDrawer let bindingDrawer
let currentVal = value let currentVal = value
let attachmentTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.SIGNATURE_SINGLE,
]
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue $: tempValue = readableValue
$: isJS = isJSBinding(value) $: isJS = isJSBinding(value)
@ -105,6 +111,7 @@
boolean: isValidBoolean, boolean: isValidBoolean,
attachment: false, attachment: false,
attachment_single: false, attachment_single: false,
signature_single: false,
} }
const isValid = value => { const isValid = value => {
@ -126,6 +133,7 @@
"bigint", "bigint",
"barcodeqr", "barcodeqr",
"attachment", "attachment",
"signature_single",
"attachment_single", "attachment_single",
].includes(type) ].includes(type)
) { ) {
@ -169,7 +177,7 @@
{updateOnChange} {updateOnChange}
/> />
{/if} {/if}
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE} {#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
<div <div
class={`icon ${getIconClass(value, type)}`} class={`icon ${getIconClass(value, type)}`}
on:click={() => { on:click={() => {

View File

@ -8,13 +8,11 @@
ActionButton, ActionButton,
Icon, Icon,
Link, Link,
Modal,
StatusLight, StatusLight,
AbsTooltip, AbsTooltip,
} from "@budibase/bbui" } from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
@ -26,7 +24,6 @@
isOnlyUser, isOnlyUser,
appStore, appStore,
deploymentStore, deploymentStore,
initialise,
sortedScreens, sortedScreens,
} from "stores/builder" } from "stores/builder"
import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte"
@ -37,7 +34,6 @@
export let loaded export let loaded
let unpublishModal let unpublishModal
let updateAppModal
let revertModal let revertModal
let versionModal let versionModal
let appActionPopover let appActionPopover
@ -61,11 +57,6 @@
$: canPublish = !publishing && loaded && $sortedScreens.length > 0 $: canPublish = !publishing && loaded && $sortedScreens.length > 0
$: lastDeployed = getLastDeployedString($deploymentStore, lastOpened) $: lastDeployed = getLastDeployedString($deploymentStore, lastOpened)
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.devId)
await initialise(applicationPkg)
}
const getLastDeployedString = deployments => { const getLastDeployedString = deployments => {
return deployments?.length return deployments?.length
? processStringSync("Published {{ duration time 'millisecond' }} ago", { ? processStringSync("Published {{ duration time 'millisecond' }} ago", {
@ -247,16 +238,12 @@
appActionPopover.hide() appActionPopover.hide()
if (isPublished) { if (isPublished) {
viewApp() viewApp()
} else {
updateAppModal.show()
} }
}} }}
> >
{$appStore.url} {$appStore.url}
{#if isPublished} {#if isPublished}
<Icon size="S" name="LinkOut" /> <Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if} {/if}
</span> </span>
</Body> </Body>
@ -330,20 +317,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={{
name: $appStore.name,
url: $appStore.url,
icon: $appStore.icon,
appId: $appStore.appId,
}}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<RevertModal bind:this={revertModal} /> <RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} /> <VersionModal hideIcon bind:this={versionModal} />

View File

@ -76,6 +76,7 @@ const componentMap = {
"field/array": FormFieldSelect, "field/array": FormFieldSelect,
"field/json": FormFieldSelect, "field/json": FormFieldSelect,
"field/barcodeqr": FormFieldSelect, "field/barcodeqr": FormFieldSelect,
"field/signature_single": FormFieldSelect,
"field/bb_reference": FormFieldSelect, "field/bb_reference": FormFieldSelect,
// Some validation types are the same as others, so not all types are // Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation // explicitly listed here. e.g. options uses string validation
@ -85,6 +86,8 @@ const componentMap = {
"validation/boolean": ValidationEditor, "validation/boolean": ValidationEditor,
"validation/datetime": ValidationEditor, "validation/datetime": ValidationEditor,
"validation/attachment": ValidationEditor, "validation/attachment": ValidationEditor,
"validation/attachment_single": ValidationEditor,
"validation/signature_single": ValidationEditor,
"validation/link": ValidationEditor, "validation/link": ValidationEditor,
"validation/bb_reference": ValidationEditor, "validation/bb_reference": ValidationEditor,
} }

View File

@ -24,7 +24,9 @@
parameters parameters
} }
$: automations = $automationStore.automations $: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP) .filter(
a => a.definition.trigger?.stepId === TriggerStepID.APP && !a.disabled
)
.map(automation => { .map(automation => {
const schema = Object.entries( const schema = Object.entries(
automation.definition.trigger.inputs.fields || {} automation.definition.trigger.inputs.fields || {}

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { runtimeToReadableBinding } from "dataBinding" import { runtimeToReadableBinding } from "dataBinding"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"

View File

@ -100,9 +100,6 @@
on:click={() => { on:click={() => {
get(store).actions.select(draggableItem.id) get(store).actions.select(draggableItem.id)
}} }}
on:mousedown={() => {
get(store).actions.select()
}}
bind:this={anchors[draggableItem.id]} bind:this={anchors[draggableItem.id]}
class:highlighted={draggableItem.id === $store.selected} class:highlighted={draggableItem.id === $store.selected}
> >

View File

@ -3,7 +3,6 @@
import { componentStore } from "stores/builder" import { componentStore } from "stores/builder"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { customPositionHandler } from "."
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte" import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor export let anchor
@ -18,76 +17,74 @@
let popover let popover
let drawers = [] let drawers = []
let open = false let isOpen = false
// Auto hide the component when another item is selected // Auto hide the component when another item is selected
$: if (open && $draggable.selected !== componentInstance._id) { $: if (open && $draggable.selected !== componentInstance._id) {
popover.hide() close()
} }
// Open automatically if the component is marked as selected // Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) { $: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show() open()
open = true
} }
$: componentDef = componentStore.getDefinition(componentInstance._component) $: componentDef = componentStore.getDefinition(componentInstance._component)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef) $: parsedComponentDef = processComponentDefinitionSettings(componentDef)
const open = () => {
isOpen = true
drawers = []
$draggable.actions.select(componentInstance._id)
}
const close = () => {
// Slight delay allows us to be able to properly toggle open/close state by
// clicking again on the settings icon
setTimeout(() => {
isOpen = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}, 10)
}
const toggleOpen = () => {
if (isOpen) {
close()
} else {
open()
}
}
const processComponentDefinitionSettings = componentDef => { const processComponentDefinitionSettings = componentDef => {
if (!componentDef) { if (!componentDef) {
return {} return {}
} }
const clone = cloneDeep(componentDef) const clone = cloneDeep(componentDef)
if (typeof parseSettings === "function") { if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings) clone.settings = parseSettings(clone.settings)
} }
return clone return clone
} }
const updateSetting = async (setting, value) => { const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(componentInstance) const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = componentStore.updateComponentSetting(setting.key, value) const patchFn = componentStore.updateComponentSetting(setting.key, value)
patchFn(nestedComponentInstance) patchFn(nestedComponentInstance)
dispatch("change", nestedComponentInstance) dispatch("change", nestedComponentInstance)
} }
</script> </script>
<Icon <Icon name="Settings" hoverable size="S" on:click={toggleOpen} />
name="Settings"
hoverable
size="S"
on:click={() => {
if (!open) {
popover.show()
open = true
}
}}
/>
<Popover <Popover
bind:this={popover} open={isOpen}
on:open={() => { on:close={close}
drawers = []
$draggable.actions.select(componentInstance._id)
}}
on:close={() => {
open = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}}
{anchor} {anchor}
align="left-outside" align="left-outside"
showPopover={drawers.length === 0} showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0} clickOutsideOverride={drawers.length > 0}
maxHeight={600} maxHeight={600}
offset={18} offset={18}
handlePostionUpdate={customPositionHandler}
> >
<span class="popover-wrap"> <span class="popover-wrap">
<Layout noPadding noGap> <Layout noPadding noGap>

View File

@ -1,18 +0,0 @@
export const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top, offset } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - (offset || 5)
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"

View File

@ -41,6 +41,7 @@ export const FieldTypeToComponentMap = {
[FieldType.BOOLEAN]: "booleanfield", [FieldType.BOOLEAN]: "booleanfield",
[FieldType.LONGFORM]: "longformfield", [FieldType.LONGFORM]: "longformfield",
[FieldType.DATETIME]: "datetimefield", [FieldType.DATETIME]: "datetimefield",
[FieldType.SIGNATURE_SINGLE]: "signaturesinglefield",
[FieldType.ATTACHMENTS]: "attachmentfield", [FieldType.ATTACHMENTS]: "attachmentfield",
[FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield", [FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield",
[FieldType.LINK]: "relationshipfield", [FieldType.LINK]: "relationshipfield",

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { setContext } from "svelte" import { setContext } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"

View File

@ -67,6 +67,7 @@ const toGridFormat = draggableListColumns => {
label: entry.label, label: entry.label,
field: entry.field, field: entry.field,
active: entry.active, active: entry.active,
width: entry.width,
})) }))
} }
@ -81,6 +82,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
field: column.field, field: column.field,
label: column.label, label: column.label,
columnType: schema[column.field].type, columnType: schema[column.field].type,
width: column.width,
}, },
{} {}
) )

View File

@ -5,7 +5,7 @@
export let value = null export let value = null
$: dataSources = $datasources.list $: dataSources = $datasources.list
.filter(ds => ds.source === "S3" && !ds.config?.endpoint) .filter(ds => ds.source === "S3")
.map(ds => ({ .map(ds => ({
label: ds.name, label: ds.name,
value: ds._id, value: ds._id,

View File

@ -108,6 +108,8 @@
Constraints.MaxFileSize, Constraints.MaxFileSize,
Constraints.MaxUploadSize, Constraints.MaxUploadSize,
], ],
["attachment_single"]: [Constraints.Required, Constraints.MaxUploadSize],
["signature_single"]: [Constraints.Required],
["link"]: [ ["link"]: [
Constraints.Required, Constraints.Required,
Constraints.Contains, Constraints.Contains,

View File

@ -2,21 +2,21 @@
import { Modal, ModalContent } from "@budibase/bbui" import { Modal, ModalContent } from "@budibase/bbui"
import FreeTrial from "../../../../assets/FreeTrial.svelte" import FreeTrial from "../../../../assets/FreeTrial.svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
import { auth, licensing } from "stores/portal" import { auth, licensing, admin } from "stores/portal"
import { API } from "api" import { API } from "api"
import { PlanType } from "@budibase/types" import { PlanType } from "@budibase/types"
import { sdk } from "@budibase/shared-core"
let freeTrialModal let freeTrialModal
$: planType = $licensing?.license?.plan?.type $: planType = $licensing?.license?.plan?.type
$: showFreeTrialModal(planType, freeTrialModal) $: showFreeTrialModal(planType, freeTrialModal)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const showFreeTrialModal = (planType, freeTrialModal) => { const showFreeTrialModal = (planType, freeTrialModal) => {
if ( if (
planType === PlanType.ENTERPRISE_BASIC_TRIAL && planType === PlanType.ENTERPRISE_BASIC_TRIAL &&
!$auth.user?.freeTrialConfirmedAt && !$auth.user?.freeTrialConfirmedAt &&
sdk.users.isAdmin($auth.user) isOwner
) { ) {
freeTrialModal?.show() freeTrialModal?.show()
} }

View File

@ -10,7 +10,6 @@ import {
NewFormSteps, NewFormSteps,
} from "./steps" } from "./steps"
import { API } from "api" import { API } from "api"
import { customPositionHandler } from "components/design/settings/controls/EditComponentPopover"
const ONBOARDING_EVENT_PREFIX = "onboarding" const ONBOARDING_EVENT_PREFIX = "onboarding"
@ -187,7 +186,6 @@ const getTours = () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS) tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS)
builderStore.highlightSetting("steps", "info") builderStore.highlightSetting("steps", "info")
}, },
positionHandler: customPositionHandler,
align: "left-outside", align: "left-outside",
}, },
], ],
@ -203,7 +201,6 @@ const getTours = () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID) tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID)
builderStore.highlightSetting("rowId", "info") builderStore.highlightSetting("rowId", "info")
}, },
positionHandler: customPositionHandler,
align: "left-outside", align: "left-outside",
}, },
{ {
@ -219,7 +216,6 @@ const getTours = () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS) tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS)
builderStore.highlightSetting("steps", "info") builderStore.highlightSetting("steps", "info")
}, },
positionHandler: customPositionHandler,
align: "left-outside", align: "left-outside",
scrollIntoView: true, scrollIntoView: true,
}, },

View File

@ -1,151 +0,0 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import {
notifications,
Input,
ModalContent,
Layout,
Label,
} from "@budibase/bbui"
import { appsStore } from "stores/portal"
import { onMount } from "svelte"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
import EditableIcon from "../common/EditableIcon.svelte"
export let app
export let onUpdateComplete
$: appIdParts = app.appId ? app.appId?.split("_") : []
$: appId = appIdParts.slice(-1)[0]
const values = writable({
name: app.name,
url: app.url,
iconName: app.icon?.name,
iconColor: app.icon?.color,
})
const validation = createValidationStore()
$: {
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
const setupValidation = async () => {
const applications = svelteGet(appsStore).apps
appValidation.name(validation, {
apps: applications,
currentApp: {
...app,
appId,
},
})
appValidation.url(validation, {
apps: applications,
currentApp: {
...app,
appId,
},
})
// init validation
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
async function updateApp() {
try {
await appsStore.save(app.appId, {
name: $values.name?.trim(),
url: $values.url?.trim(),
icon: {
name: $values.iconName,
color: $values.iconColor,
},
})
if (typeof onUpdateComplete == "function") {
onUpdateComplete()
}
} catch (error) {
console.error(error)
notifications.error("Error updating app")
}
}
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(null, name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(null, appName)
tidyUrl(resolvedUrl)
}
const updateIcon = e => {
const { name, color } = e.detail
$values.iconColor = color
$values.iconName = name
}
onMount(setupValidation)
</script>
<ModalContent
title="Edit name and URL"
confirmText="Save"
onConfirm={updateApp}
disabled={!$validation.valid}
>
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
/>
<Layout noPadding gap="XS">
<Label>Icon</Label>
<EditableIcon
{app}
size="XL"
name={$values.iconName}
color={$values.iconColor}
on:change={updateIcon}
/>
</Layout>
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(null, $values.name)}`}
/>
</ModalContent>

View File

@ -33,7 +33,7 @@ export const FIELDS = {
}, },
}, },
BARCODEQR: { BARCODEQR: {
name: "Barcode/QR", name: "Barcode / QR",
type: FieldType.BARCODEQR, type: FieldType.BARCODEQR,
icon: TypeIconMap[FieldType.BARCODEQR], icon: TypeIconMap[FieldType.BARCODEQR],
constraints: { constraints: {
@ -43,7 +43,7 @@ export const FIELDS = {
}, },
}, },
LONGFORM: { LONGFORM: {
name: "Long Form Text", name: "Long form text",
type: FieldType.LONGFORM, type: FieldType.LONGFORM,
icon: TypeIconMap[FieldType.LONGFORM], icon: TypeIconMap[FieldType.LONGFORM],
constraints: { constraints: {
@ -53,7 +53,7 @@ export const FIELDS = {
}, },
}, },
OPTIONS: { OPTIONS: {
name: "Options", name: "Single select",
type: FieldType.OPTIONS, type: FieldType.OPTIONS,
icon: TypeIconMap[FieldType.OPTIONS], icon: TypeIconMap[FieldType.OPTIONS],
constraints: { constraints: {
@ -63,7 +63,7 @@ export const FIELDS = {
}, },
}, },
ARRAY: { ARRAY: {
name: "Multi-select", name: "Multi select",
type: FieldType.ARRAY, type: FieldType.ARRAY,
icon: TypeIconMap[FieldType.ARRAY], icon: TypeIconMap[FieldType.ARRAY],
constraints: { constraints: {
@ -83,7 +83,7 @@ export const FIELDS = {
}, },
}, },
BIGINT: { BIGINT: {
name: "BigInt", name: "Big integer",
type: FieldType.BIGINT, type: FieldType.BIGINT,
icon: TypeIconMap[FieldType.BIGINT], icon: TypeIconMap[FieldType.BIGINT],
}, },
@ -97,7 +97,7 @@ export const FIELDS = {
}, },
}, },
DATETIME: { DATETIME: {
name: "Date/Time", name: "Date / time",
type: FieldType.DATETIME, type: FieldType.DATETIME,
icon: TypeIconMap[FieldType.DATETIME], icon: TypeIconMap[FieldType.DATETIME],
constraints: { constraints: {
@ -111,7 +111,7 @@ export const FIELDS = {
}, },
}, },
ATTACHMENT_SINGLE: { ATTACHMENT_SINGLE: {
name: "Attachment", name: "Single attachment",
type: FieldType.ATTACHMENT_SINGLE, type: FieldType.ATTACHMENT_SINGLE,
icon: TypeIconMap[FieldType.ATTACHMENT_SINGLE], icon: TypeIconMap[FieldType.ATTACHMENT_SINGLE],
constraints: { constraints: {
@ -119,7 +119,7 @@ export const FIELDS = {
}, },
}, },
ATTACHMENTS: { ATTACHMENTS: {
name: "Attachment List", name: "Multi attachment",
type: FieldType.ATTACHMENTS, type: FieldType.ATTACHMENTS,
icon: TypeIconMap[FieldType.ATTACHMENTS], icon: TypeIconMap[FieldType.ATTACHMENTS],
constraints: { constraints: {
@ -127,6 +127,14 @@ export const FIELDS = {
presence: false, presence: false,
}, },
}, },
SIGNATURE_SINGLE: {
name: "Signature",
type: FieldType.SIGNATURE_SINGLE,
icon: "AnnotatePen",
constraints: {
presence: false,
},
},
LINK: { LINK: {
name: "Relationship", name: "Relationship",
type: FieldType.LINK, type: FieldType.LINK,
@ -137,7 +145,7 @@ export const FIELDS = {
}, },
}, },
AUTO: { AUTO: {
name: "Auto Column", name: "Auto column",
type: FieldType.AUTO, type: FieldType.AUTO,
icon: TypeIconMap[FieldType.AUTO], icon: TypeIconMap[FieldType.AUTO],
constraints: {}, constraints: {},
@ -158,7 +166,7 @@ export const FIELDS = {
}, },
}, },
USER: { USER: {
name: "User", name: "Single user",
type: FieldType.BB_REFERENCE_SINGLE, type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][ icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][
@ -166,7 +174,7 @@ export const FIELDS = {
], ],
}, },
USERS: { USERS: {
name: "User List", name: "Multi user",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER], icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],

View File

@ -830,7 +830,7 @@ export const getActionBindings = (actions, actionId) => {
* @return {{schema: Object, table: Object}} * @return {{schema: Object, table: Object}}
*/ */
export const getSchemaForDatasourcePlus = (resourceId, options) => { export const getSchemaForDatasourcePlus = (resourceId, options) => {
const isViewV2 = resourceId?.includes("view_") const isViewV2 = resourceId?.startsWith("view_")
const datasource = isViewV2 const datasource = isViewV2
? { ? {
type: "viewV2", type: "viewV2",

View File

@ -19,11 +19,10 @@ export const name = (validation, { apps, currentApp } = { apps: [] }) => {
// exit early, above validator will fail // exit early, above validator will fail
return true return true
} }
if (currentApp) {
// filter out the current app if present
apps = apps.filter(app => app.appId !== currentApp.appId)
}
return !apps return !apps
.filter(app => {
return app.appId !== currentApp?.appId
})
.map(app => app.name) .map(app => app.name)
.some(appName => appName.toLowerCase() === value.toLowerCase()) .some(appName => appName.toLowerCase() === value.toLowerCase())
} }

View File

@ -33,6 +33,7 @@
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte" import PreviewOverlay from "./_components/PreviewOverlay.svelte"
import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte" import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte"
import UpdateAppTopNav from "components/common/UpdateAppTopNav.svelte"
export let application export let application
@ -104,10 +105,6 @@
} }
onMount(async () => { onMount(async () => {
document.fonts.onloadingdone = e => {
builderStore.loadFonts(e.fontfaces)
}
if (!hasSynced && application) { if (!hasSynced && application) {
try { try {
await API.syncApp(application) await API.syncApp(application)
@ -148,23 +145,25 @@
/> />
</span> </span>
<Tabs {selected} size="M"> <Tabs {selected} size="M">
{#key $builderStore?.fonts} {#each $layout.children as { path, title }}
{#each $layout.children as { path, title }} <TourWrap stepKeys={[`builder-${title}-section`]}>
<TourWrap stepKeys={[`builder-${title}-section`]}> <Tab
<Tab quiet
quiet selected={$isActive(path)}
selected={$isActive(path)} on:click={topItemNavigate(path)}
on:click={topItemNavigate(path)} title={capitalise(title)}
title={capitalise(title)} id={`builder-${title}-tab`}
id={`builder-${title}-tab`} />
/> </TourWrap>
</TourWrap> {/each}
{/each}
{/key}
</Tabs> </Tabs>
</div> </div>
<div class="topcenternav"> <div class="topcenternav">
<Heading size="XS">{$appStore.name}</Heading> <div class="app-name">
<UpdateAppTopNav {application}>
<Heading noPadding size="XS">{$appStore.name}</Heading>
</UpdateAppTopNav>
</div>
</div> </div>
<div class="toprightnav"> <div class="toprightnav">
<span> <span>
@ -253,7 +252,6 @@
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 0px var(--spacing-m);
} }
.topleftnav { .topleftnav {

View File

@ -71,6 +71,7 @@
"multifieldselect", "multifieldselect",
"s3upload", "s3upload",
"codescanner", "codescanner",
"signaturesinglefield",
"bbreferencesinglefield", "bbreferencesinglefield",
"bbreferencefield" "bbreferencefield"
] ]

View File

@ -1,30 +1,6 @@
<script> <script>
import { import { Layout, Divider, Heading, Body } from "@budibase/bbui"
Layout, import UpdateAppForm from "components/common/UpdateAppForm.svelte"
Divider,
Heading,
Body,
Button,
Label,
Modal,
Icon,
} from "@budibase/bbui"
import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder"
import { appsStore } from "stores/portal"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { API } from "api"
let updatingModal
$: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.appId)
await initialise(applicationPkg)
}
</script> </script>
<Layout noPadding> <Layout noPadding>
@ -33,61 +9,5 @@
<Body>Edit your app's name and URL</Body> <Body>Edit your app's name and URL</Body>
</Layout> </Layout>
<Divider /> <Divider />
<UpdateAppForm />
<Layout noPadding gap="XXS">
<Label size="L">Name</Label>
<Body>{$appStore?.name}</Body>
</Layout>
<Layout noPadding gap="XS">
<Label size="L">Icon</Label>
<div class="icon">
<Icon
size="L"
name={$appStore?.icon?.name || "Apps"}
color={$appStore?.icon?.color}
/>
</div>
</Layout>
<Layout noPadding gap="XXS">
<Label size="L">URL</Label>
<Body>{$appStore.url}</Body>
</Layout>
<div>
<Button
cta
on:click={() => {
updatingModal.show()
}}
disabled={appDeployed}
tooltip={appDeployed
? "You must unpublish your app to make changes"
: null}
>
Edit
</Button>
</div>
</Layout> </Layout>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal
app={{
name: $appStore.name,
url: $appStore.url,
icon: $appStore.icon,
appId: $appStore.appId,
}}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<style>
.icon {
display: flex;
justify-content: flex-start;
}
</style>

View File

@ -1,7 +1,14 @@
<script> <script>
import { isActive, redirect, goto, url } from "@roxi/routify" import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui" import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, appsStore, licensing } from "stores/portal" import {
organisation,
auth,
menu,
appsStore,
licensing,
admin,
} from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte" import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte" import MobileMenu from "./_components/MobileMenu.svelte"
@ -20,6 +27,7 @@
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = $: isOnboarding =
!$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user) !$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -38,8 +46,7 @@
const showFreeTrialBanner = () => { const showFreeTrialBanner = () => {
return ( return (
$licensing.license?.plan?.type === $licensing.license?.plan?.type ===
Constants.PlanType.ENTERPRISE_BASIC_TRIAL && Constants.PlanType.ENTERPRISE_BASIC_TRIAL && isOwner
sdk.users.isAdmin($auth.user)
) )
} }

View File

@ -82,6 +82,7 @@ const automationActions = store => ({
steps: [], steps: [],
trigger, trigger,
}, },
disabled: false,
} }
const response = await store.actions.save(automation) const response = await store.actions.save(automation)
await store.actions.fetch() await store.actions.fetch()
@ -134,6 +135,28 @@ const automationActions = store => ({
}) })
await store.actions.fetch() await store.actions.fetch()
}, },
toggleDisabled: async automationId => {
let automation
try {
automation = store.actions.getDefinition(automationId)
if (!automation) {
return
}
automation.disabled = !automation.disabled
await store.actions.save(automation)
notifications.success(
`Automation ${
automation.disabled ? "enabled" : "disabled"
} successfully`
)
} catch (error) {
notifications.error(
`Error ${
automation && automation.disabled ? "enabling" : "disabling"
} automation`
)
}
},
updateBlockInputs: async (block, data) => { updateBlockInputs: async (block, data) => {
// Create new modified block // Create new modified block
let newBlock = { let newBlock = {

View File

@ -14,7 +14,6 @@ export const INITIAL_BUILDER_STATE = {
tourKey: null, tourKey: null,
tourStepKey: null, tourStepKey: null,
hoveredComponentId: null, hoveredComponentId: null,
fonts: null,
} }
export class BuilderStore extends BudiStore { export class BuilderStore extends BudiStore {
@ -37,16 +36,6 @@ export class BuilderStore extends BudiStore {
this.websocket this.websocket
} }
loadFonts(fontFaces) {
const ff = fontFaces.map(
fontFace => `${fontFace.family}-${fontFace.weight}`
)
this.update(state => ({
...state,
fonts: [...(state.fonts || []), ...ff],
}))
}
init(app) { init(app) {
if (!app?.appId) { if (!app?.appId) {
console.error("BuilderStore: No appId supplied for websocket") console.error("BuilderStore: No appId supplied for websocket")

View File

@ -131,7 +131,7 @@ export class AppsStore extends BudiStore {
if (updatedAppIndex !== -1) { if (updatedAppIndex !== -1) {
let updatedApp = state.apps[updatedAppIndex] let updatedApp = state.apps[updatedAppIndex]
updatedApp = { ...updatedApp, ...value } updatedApp = { ...updatedApp, ...value }
state.apps = state.apps.splice(updatedAppIndex, 1, updatedApp) state.apps.splice(updatedAppIndex, 1, updatedApp)
} }
return state return state
}) })

View File

@ -1,3 +1,4 @@
process.env.DISABLE_PINO_LOGGER = "1"
process.env.NO_JS = "1" process.env.NO_JS = "1"
process.env.JS_BCRYPT = "1" process.env.JS_BCRYPT = "1"
process.env.DISABLE_JWT_WARNING = "1" process.env.DISABLE_JWT_WARNING = "1"

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
process.env.DISABLE_PINO_LOGGER = "1" // have to import this before anything else
import "./environment" import "./environment"
import { getCommands } from "./options" import { getCommands } from "./options"
import { Command } from "commander" import { Command } from "commander"

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
dir="$(dirname -- "$(readlink -f "${BASH_SOURCE}")")" dir="$(dirname -- "$(readlink -f "${BASH_SOURCE}")")"
${dir}/node_modules/ts-node/dist/bin.js ${dir}/src/index.ts $@ ${dir}/../../node_modules/ts-node/dist/bin.js ${dir}/src/index.ts $@

View File

@ -2868,6 +2868,14 @@
"type": "plainText", "type": "plainText",
"label": "Label", "label": "Label",
"key": "label" "key": "label"
},
{
"type": "number",
"label": "Initial width",
"key": "width",
"placeholder": "Auto",
"min": 80,
"max": 9999
} }
] ]
}, },
@ -4229,6 +4237,55 @@
} }
] ]
}, },
"signaturesinglefield": {
"name": "Signature",
"icon": "AnnotatePen",
"styles": ["size"],
"size": {
"width": 400,
"height": 50
},
"settings": [
{
"type": "field/signature_single",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Help text",
"key": "helpText"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "validation/signature_single",
"label": "Validation",
"key": "validation"
}
]
},
"embeddedmap": { "embeddedmap": {
"name": "Embedded Map", "name": "Embedded Map",
"icon": "Location", "icon": "Location",
@ -4494,7 +4551,7 @@
"defaultValue": false "defaultValue": false
}, },
{ {
"type": "validation/attachment", "type": "validation/attachment_single",
"label": "Validation", "label": "Validation",
"key": "validation" "key": "validation"
}, },

View File

@ -33,7 +33,8 @@
"sanitize-html": "^2.7.0", "sanitize-html": "^2.7.0",
"screenfull": "^6.0.1", "screenfull": "^6.0.1",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1",
"atrament": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-alias": "^5.1.0",

View File

@ -193,6 +193,9 @@
$: pad = pad || (interactive && hasChildren && inDndPath) $: pad = pad || (interactive && hasChildren && inDndPath)
$: $dndIsDragging, (pad = false) $: $dndIsDragging, (pad = false)
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
// Update component context // Update component context
$: store.set({ $: store.set({
id, id,
@ -222,6 +225,7 @@
parent: id, parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component], ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id], path: [...($component?.path ?? []), id],
darkMode,
}) })
const initialise = (instance, force = false) => { const initialise = (instance, force = false) => {

View File

@ -22,6 +22,7 @@
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const { environmentStore } = getContext("sdk")
const { const {
styleable, styleable,
API, API,
@ -36,12 +37,16 @@
let grid let grid
let gridContext let gridContext
let minHeight = 0
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
$: parsedColumns = getParsedColumns(columns) $: parsedColumns = getParsedColumns(columns)
$: columnWhitelist = parsedColumns.filter(x => x.active).map(x => x.field) $: columnWhitelist = parsedColumns.filter(x => x.active).map(x => x.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns) $: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons) $: enrichedButtons = enrichButtons(buttons)
$: selectedRows = deriveSelectedRows(gridContext) $: selectedRows = deriveSelectedRows(gridContext)
$: styles = patchStyles($component.styles, minHeight)
$: data = { selectedRows: $selectedRows } $: data = { selectedRows: $selectedRows }
$: actions = [ $: actions = [
{ {
@ -50,8 +55,6 @@
metadata: { dataSource: table }, metadata: { dataSource: table },
}, },
] ]
$: height = $component.styles?.normal?.height || "408px"
$: styles = getSanitisedStyles($component.styles)
// Provide additional data context for live binding eval // Provide additional data context for live binding eval
export const getAdditionalDataContext = () => { export const getAdditionalDataContext = () => {
@ -84,9 +87,11 @@
const getSchemaOverrides = columns => { const getSchemaOverrides = columns => {
let overrides = {} let overrides = {}
columns.forEach(column => { columns.forEach((column, idx) => {
overrides[column.field] = { overrides[column.field] = {
displayName: column.label, displayName: column.label,
width: column.width,
order: idx,
} }
}) })
return overrides return overrides
@ -128,49 +133,50 @@
) )
} }
const getSanitisedStyles = styles => { const patchStyles = (styles, minHeight) => {
return { return {
...styles, ...styles,
normal: { normal: {
...styles?.normal, ...styles?.normal,
height: undefined, "min-height": `${minHeight}px`,
}, },
} }
} }
onMount(() => { onMount(() => {
gridContext = grid.getContext() gridContext = grid.getContext()
gridContext.minHeight.subscribe($height => (minHeight = $height))
}) })
</script> </script>
<div use:styleable={styles} class:in-builder={$builderStore.inBuilder}> <div use:styleable={styles} class:in-builder={$builderStore.inBuilder}>
<span style="--height:{height};"> <Grid
<Grid bind:this={grid}
bind:this={grid} datasource={table}
datasource={table} {API}
{API} {stripeRows}
{stripeRows} {quiet}
{quiet} {darkMode}
{initialFilter} {initialFilter}
{initialSortColumn} {initialSortColumn}
{initialSortOrder} {initialSortOrder}
{fixedRowHeight} {fixedRowHeight}
{columnWhitelist} {columnWhitelist}
{schemaOverrides} {schemaOverrides}
canAddRows={allowAddRows} canAddRows={allowAddRows}
canEditRows={allowEditRows} canEditRows={allowEditRows}
canDeleteRows={allowDeleteRows} canDeleteRows={allowDeleteRows}
canEditColumns={false} canEditColumns={false}
canExpandRows={false} canExpandRows={false}
canSaveSchema={false} canSaveSchema={false}
canSelectRows={true} canSelectRows={true}
showControls={false} showControls={false}
notifySuccess={notificationStore.actions.success} notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error} notifyError={notificationStore.actions.error}
buttons={enrichedButtons} buttons={enrichedButtons}
on:rowclick={e => onRowClick?.({ row: e.detail })} isCloud={$environmentStore.cloud}
/> on:rowclick={e => onRowClick?.({ row: e.detail })}
</span> />
</div> </div>
<Provider {data} {actions} /> <Provider {data} {actions} />
@ -183,14 +189,9 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
height: 410px;
} }
div.in-builder :global(*) { div.in-builder :global(*) {
pointer-events: none; pointer-events: none;
} }
span {
display: contents;
}
span :global(.grid) {
height: var(--height);
}
</style> </style>

View File

@ -15,6 +15,7 @@
[FieldType.BOOLEAN]: "booleanfield", [FieldType.BOOLEAN]: "booleanfield",
[FieldType.LONGFORM]: "longformfield", [FieldType.LONGFORM]: "longformfield",
[FieldType.DATETIME]: "datetimefield", [FieldType.DATETIME]: "datetimefield",
[FieldType.SIGNATURE_SINGLE]: "signaturesinglefield",
[FieldType.ATTACHMENTS]: "attachmentfield", [FieldType.ATTACHMENTS]: "attachmentfield",
[FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield", [FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield",
[FieldType.LINK]: "relationshipfield", [FieldType.LINK]: "relationshipfield",

View File

@ -25,7 +25,7 @@
let fieldState let fieldState
let fieldApi let fieldApi
const { API, notificationStore } = getContext("sdk") const { API, notificationStore, environmentStore } = getContext("sdk")
const formContext = getContext("form") const formContext = getContext("form")
const BYTES_IN_MB = 1000000 const BYTES_IN_MB = 1000000
@ -87,7 +87,7 @@
error={fieldState.error} error={fieldState.error}
on:change={handleChange} on:change={handleChange}
{processFiles} {processFiles}
{handleFileTooLarge} handleFileTooLarge={$environmentStore.cloud ? handleFileTooLarge : null}
{handleTooManyFiles} {handleTooManyFiles}
{maximum} {maximum}
{extensions} {extensions}

View File

@ -0,0 +1,129 @@
<script>
import { CoreSignature, ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import { SignatureModal } from "@budibase/frontend-core/src/components"
export let field
export let label
export let disabled = false
export let readonly = false
export let validation
export let onChange
export let span
export let helpText = null
let fieldState
let fieldApi
let fieldSchema
let modal
const { API, notificationStore, builderStore } = getContext("sdk")
const context = getContext("context")
const formContext = getContext("form")
const saveSignature = async canvas => {
try {
const signatureFile = canvas.toFile()
let updateValue
if (signatureFile) {
let attachRequest = new FormData()
attachRequest.append("file", signatureFile)
const resp = await API.uploadAttachment({
data: attachRequest,
tableId: formContext?.dataSource?.tableId,
})
const [signatureAttachment] = resp
updateValue = signatureAttachment
} else {
updateValue = null
}
const changed = fieldApi.setValue(updateValue)
if (onChange && changed) {
onChange({ value: updateValue })
}
} catch (error) {
notificationStore.actions.error(
`There was a problem saving your signature`
)
console.error(error)
}
}
const deleteSignature = async () => {
const changed = fieldApi.setValue(null)
if (onChange && changed) {
onChange({ value: null })
}
}
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
</script>
<SignatureModal
onConfirm={saveSignature}
title={label || fieldSchema?.name || ""}
value={fieldState?.value}
{darkMode}
bind:this={modal}
/>
<Field
{label}
{field}
{disabled}
{readonly}
{validation}
{span}
{helpText}
type="signature_single"
bind:fieldState
bind:fieldApi
bind:fieldSchema
defaultValue={[]}
>
{#if fieldState}
{#if (Array.isArray(fieldState?.value) && !fieldState?.value?.length) || !fieldState?.value}
<ActionButton
fullWidth
disabled={fieldState.disabled}
on:click={() => {
if (!$builderStore.inBuilder) {
modal.show()
}
}}
>
Add signature
</ActionButton>
{:else}
<div class="signature-field">
<CoreSignature
{darkMode}
disabled={$builderStore.inBuilder || fieldState.disabled}
editable={false}
value={fieldState?.value}
on:clear={deleteSignature}
/>
</div>
{/if}
{/if}
</Field>
<style>
.signature-field {
min-height: 50px;
justify-content: center;
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--spectrum-global-color-gray-50);
box-sizing: border-box;
border: var(--spectrum-alias-border-size-thin)
var(--spectrum-alias-border-color) solid;
border-radius: var(--spectrum-alias-border-radius-regular);
}
</style>

View File

@ -16,5 +16,6 @@ export { default as formstep } from "./FormStep.svelte"
export { default as jsonfield } from "./JSONField.svelte" export { default as jsonfield } from "./JSONField.svelte"
export { default as s3upload } from "./S3Upload.svelte" export { default as s3upload } from "./S3Upload.svelte"
export { default as codescanner } from "./CodeScannerField.svelte" export { default as codescanner } from "./CodeScannerField.svelte"
export { default as signaturesinglefield } from "./SignatureField.svelte"
export { default as bbreferencefield } from "./BBReferenceField.svelte" export { default as bbreferencefield } from "./BBReferenceField.svelte"
export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte" export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte"

View File

@ -200,6 +200,17 @@ const parseType = (value, type) => {
return value return value
} }
// Parse attachment/signature single, treating no key as null
if (
type === FieldTypes.ATTACHMENT_SINGLE ||
type === FieldTypes.SIGNATURE_SINGLE
) {
if (!value?.key) {
return null
}
return value
}
// Parse links, treating no elements as null // Parse links, treating no elements as null
if (type === FieldTypes.LINK) { if (type === FieldTypes.LINK) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
@ -246,10 +257,8 @@ const maxLengthHandler = (value, rule) => {
// Evaluates a max file size (MB) constraint // Evaluates a max file size (MB) constraint
const maxFileSizeHandler = (value, rule) => { const maxFileSizeHandler = (value, rule) => {
const limit = parseType(rule.value, "number") const limit = parseType(rule.value, "number")
return ( const check = attachment => attachment.size / 1000000 > limit
value == null || return value == null || !(value?.key ? check(value) : value.some(check))
!value.some(attachment => attachment.size / 1000000 > limit)
)
} }
// Evaluates a max total upload size (MB) constraint // Evaluates a max total upload size (MB) constraint
@ -257,8 +266,11 @@ const maxUploadSizeHandler = (value, rule) => {
const limit = parseType(rule.value, "number") const limit = parseType(rule.value, "number")
return ( return (
value == null || value == null ||
value.reduce((acc, currentItem) => acc + currentItem.size, 0) / 1000000 <= (value?.key
limit ? value.size / 1000000 <= limit
: value.reduce((acc, currentItem) => acc + currentItem.size, 0) /
1000000 <=
limit)
) )
} }

View File

@ -0,0 +1,59 @@
<script>
import { Modal, ModalContent, Body, CoreSignature } from "@budibase/bbui"
export let onConfirm = () => {}
export let value
export let title
export let darkMode
export const show = () => {
edited = false
modal.show()
}
let modal
let canvas
let edited = false
</script>
<Modal bind:this={modal}>
<ModalContent
showConfirmButton
showCancelButton={false}
showCloseIcon={false}
custom
disabled={!edited}
showDivider={false}
onConfirm={() => {
onConfirm(canvas)
}}
>
<div slot="header">
<Body>{title}</Body>
</div>
<div class="signature-wrap modal">
<CoreSignature
{darkMode}
{value}
saveIcon={false}
bind:this={canvas}
on:update={() => {
edited = true
}}
/>
</div>
</ModalContent>
</Modal>
<style>
.signature-wrap {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
background-color: var(--spectrum-global-color-gray-50);
color: var(--spectrum-alias-text-color);
box-sizing: border-box;
position: relative;
}
</style>

View File

@ -8,11 +8,10 @@
export let onChange export let onChange
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false
export let schema export let schema
export let maximum export let maximum
const { API, notifications } = getContext("grid") const { API, notifications, props } = getContext("grid")
const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"] const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"]
let isOpen = false let isOpen = false
@ -92,13 +91,7 @@
</div> </div>
{#if isOpen} {#if isOpen}
<GridPopover <GridPopover open={isOpen} {anchor} maxHeight={null} on:close={close}>
open={isOpen}
{anchor}
{invertX}
maxHeight={null}
on:close={close}
>
<div class="dropzone"> <div class="dropzone">
<Dropzone <Dropzone
{value} {value}
@ -106,7 +99,7 @@
on:change={e => onChange(e.detail)} on:change={e => onChange(e.detail)}
maximum={maximum || schema.constraints?.length?.maximum} maximum={maximum || schema.constraints?.length?.maximum}
{processFiles} {processFiles}
{handleFileTooLarge} handleFileTooLarge={$props.isCloud ? handleFileTooLarge : null}
/> />
</div> </div>
</GridPopover> </GridPopover>

View File

@ -18,8 +18,6 @@
export let row export let row
export let cellId export let cellId
export let updateValue = rows.actions.updateValue export let updateValue = rows.actions.updateValue
export let invertX = false
export let invertY = false
export let contentLines = 1 export let contentLines = 1
export let hidden = false export let hidden = false
@ -93,8 +91,6 @@
onChange={cellAPI.setValue} onChange={cellAPI.setValue}
{focused} {focused}
{readonly} {readonly}
{invertY}
{invertX}
{contentLines} {contentLines}
/> />
<slot /> <slot />

View File

@ -10,7 +10,6 @@
export let focused = false export let focused = false
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false
let isOpen let isOpen
let anchor let anchor
@ -111,7 +110,7 @@
</div> </div>
{#if isOpen} {#if isOpen}
<GridPopover {anchor} {invertX} maxHeight={null} on:close={close}> <GridPopover {anchor} maxHeight={null} on:close={close}>
<CoreDatePickerPopoverContents <CoreDatePickerPopoverContents
value={parsedValue} value={parsedValue}
useKeyboardShortcuts={false} useKeyboardShortcuts={false}

View File

@ -23,7 +23,6 @@
subscribe, subscribe,
config, config,
ui, ui,
columns,
definition, definition,
datasource, datasource,
schema, schema,
@ -158,17 +157,13 @@
} }
const makeDisplayColumn = () => { const makeDisplayColumn = () => {
columns.actions.changePrimaryDisplay(column.name) datasource.actions.changePrimaryDisplay(column.name)
open = false open = false
} }
const hideColumn = () => { const hideColumn = () => {
columns.update(state => { datasource.actions.addSchemaMutation(column.name, { visible: false })
const index = state.findIndex(col => col.name === column.name) datasource.actions.saveSchemaMutations()
state[index].visible = false
return state.slice()
})
columns.actions.saveChanges()
open = false open = false
} }
@ -386,7 +381,7 @@
> >
Hide column Hide column
</MenuItem> </MenuItem>
{#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS && !column.schema.autocolumn}
<MenuItem icon="User" on:click={openMigrationModal}> <MenuItem icon="User" on:click={openMigrationModal}>
Migrate to user column Migrate to user column
</MenuItem> </MenuItem>

View File

@ -8,7 +8,6 @@
export let onChange export let onChange
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false
let textarea let textarea
let isOpen = false let isOpen = false
@ -67,7 +66,7 @@
</div> </div>
{#if isOpen} {#if isOpen}
<GridPopover {anchor} {invertX} on:close={close}> <GridPopover {anchor} on:close={close}>
<textarea <textarea
bind:this={textarea} bind:this={textarea}
value={value || ""} value={value || ""}

View File

@ -11,7 +11,6 @@
export let multi = false export let multi = false
export let readonly = false export let readonly = false
export let api export let api
export let invertX
export let contentLines = 1 export let contentLines = 1
let isOpen = false let isOpen = false
@ -120,7 +119,7 @@
</div> </div>
{#if isOpen} {#if isOpen}
<GridPopover {anchor} {invertX} on:close={close}> <GridPopover {anchor} on:close={close}>
<div class="options"> <div class="options">
{#each options as option, idx} {#each options as option, idx}
{@const color = optionColors[option] || getOptionColor(option)} {@const color = optionColors[option] || getOptionColor(option)}

View File

@ -13,7 +13,6 @@
export let focused export let focused
export let schema export let schema
export let onChange export let onChange
export let invertX = false
export let contentLines = 1 export let contentLines = 1
export let searchFunction = API.searchTable export let searchFunction = API.searchTable
export let primaryDisplay export let primaryDisplay
@ -275,7 +274,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
{#if isOpen} {#if isOpen}
<GridPopover open={isOpen} {anchor} {invertX} on:close={close}> <GridPopover open={isOpen} {anchor} on:close={close}>
<div class="dropdown" on:wheel|stopPropagation> <div class="dropdown" on:wheel|stopPropagation>
<div class="search"> <div class="search">
<Input <Input

View File

@ -0,0 +1,162 @@
<script>
import { onMount, getContext } from "svelte"
import { SignatureModal } from "@budibase/frontend-core/src/components"
import { CoreSignature, ActionButton } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
export let schema
export let value
export let focused = false
export let onChange
export let readonly = false
export let api
const { API, notifications, props } = getContext("grid")
let isOpen = false
let modal
let anchor
$: editable = focused && !readonly
$: {
if (!focused) {
close()
}
}
const onKeyDown = () => {
return false
}
const open = () => {
isOpen = true
}
const close = () => {
isOpen = false
}
const deleteSignature = async () => {
onChange(null)
}
const saveSignature = async sigCanvas => {
const signatureFile = sigCanvas.toFile()
let attachRequest = new FormData()
attachRequest.append("file", signatureFile)
try {
const uploadReq = await API.uploadBuilderAttachment(attachRequest)
const [signatureAttachment] = uploadReq
onChange(signatureAttachment)
} catch (error) {
$notifications.error(error.message || "Failed to save signature")
return []
}
}
onMount(() => {
api = {
focus: () => open(),
blur: () => close(),
isActive: () => isOpen,
onKeyDown,
}
})
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="signature-cell"
class:light={!$props?.darkMode}
class:editable
bind:this={anchor}
on:click={editable ? open : null}
>
{#if value?.url}
<!-- svelte-ignore a11y-missing-attribute -->
<img src={value?.url} />
{/if}
</div>
<SignatureModal
onConfirm={saveSignature}
title={schema?.name}
{value}
darkMode={$props.darkMode}
bind:this={modal}
/>
{#if isOpen}
<GridPopover open={isOpen} {anchor} maxHeight={null} on:close={close}>
<div class="signature" class:empty={!value}>
{#if value?.key}
<div class="signature-wrap">
<CoreSignature
darkMode={$props.darkMode}
editable={false}
{value}
on:change={saveSignature}
on:clear={deleteSignature}
/>
</div>
{:else}
<div class="add-signature">
<ActionButton
fullWidth
on:click={() => {
modal.show()
}}
>
Add signature
</ActionButton>
</div>
{/if}
</div>
</GridPopover>
{/if}
<style>
.signature {
min-width: 320px;
padding: var(--cell-padding);
background: var(--grid-background-alt);
border: var(--cell-border);
}
.signature.empty {
width: 100%;
min-width: unset;
}
.signature-cell.light img {
-webkit-filter: invert(100%);
filter: invert(100%);
}
.signature-cell {
flex: 1 1 auto;
display: flex;
flex-direction: row;
align-items: stretch;
max-width: 320px;
padding-left: var(--cell-padding);
padding-right: var(--cell-padding);
flex-wrap: nowrap;
align-self: stretch;
overflow: hidden;
user-select: none;
}
.signature-cell.editable:hover {
cursor: pointer;
}
.signature-wrap {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
background-color: var(--spectrum-global-color-gray-50);
color: var(--spectrum-alias-text-color);
box-sizing: border-box;
position: relative;
}
</style>

View File

@ -3,7 +3,7 @@
import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui" import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
const { columns, stickyColumn, dispatch } = getContext("grid") const { columns, datasource, stickyColumn, dispatch } = getContext("grid")
let open = false let open = false
let anchor let anchor
@ -11,36 +11,20 @@
$: anyHidden = $columns.some(col => !col.visible) $: anyHidden = $columns.some(col => !col.visible)
$: text = getText($columns) $: text = getText($columns)
const toggleVisibility = async (column, visible) => { const toggleColumn = async (column, visible) => {
columns.update(state => { datasource.actions.addSchemaMutation(column.name, { visible })
const index = state.findIndex(col => col.name === column.name) await datasource.actions.saveSchemaMutations()
state[index].visible = visible
return state.slice()
})
await columns.actions.saveChanges()
dispatch(visible ? "show-column" : "hide-column") dispatch(visible ? "show-column" : "hide-column")
} }
const showAll = async () => { const toggleAll = async visible => {
columns.update(state => { let mutations = {}
return state.map(col => ({ $columns.forEach(column => {
...col, mutations[column.name] = { visible }
visible: true,
}))
}) })
await columns.actions.saveChanges() datasource.actions.addSchemaMutations(mutations)
dispatch("show-column") await datasource.actions.saveSchemaMutations()
} dispatch(visible ? "show-column" : "hide-column")
const hideAll = async () => {
columns.update(state => {
return state.map(col => ({
...col,
visible: false,
}))
})
await columns.actions.saveChanges()
dispatch("hide-column")
} }
const getText = columns => { const getText = columns => {
@ -80,14 +64,14 @@
<Toggle <Toggle
size="S" size="S"
value={column.visible} value={column.visible}
on:change={e => toggleVisibility(column, e.detail)} on:change={e => toggleColumn(column, e.detail)}
disabled={column.primaryDisplay} disabled={column.primaryDisplay}
/> />
{/each} {/each}
</div> </div>
<div class="buttons"> <div class="buttons">
<ActionButton on:click={showAll}>Show all</ActionButton> <ActionButton on:click={() => toggleAll(true)}>Show all</ActionButton>
<ActionButton on:click={hideAll}>Hide all</ActionButton> <ActionButton on:click={() => toggleAll(false)}>Hide all</ActionButton>
</div> </div>
</div> </div>
</Popover> </Popover>

View File

@ -1,6 +1,6 @@
<script> <script>
import { setContext, onMount } from "svelte" import { setContext, onMount } from "svelte"
import { writable } from "svelte/store" import { writable, derived } from "svelte/store"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui" import { clickOutside, ProgressCircle } from "@budibase/bbui"
import { createEventManagers } from "../lib/events" import { createEventManagers } from "../lib/events"
@ -54,6 +54,8 @@
export let notifySuccess = null export let notifySuccess = null
export let notifyError = null export let notifyError = null
export let buttons = null export let buttons = null
export let darkMode
export let isCloud = null
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const gridID = `grid-${Math.random().toString().slice(2)}` const gridID = `grid-${Math.random().toString().slice(2)}`
@ -108,9 +110,16 @@
notifySuccess, notifySuccess,
notifyError, notifyError,
buttons, buttons,
darkMode,
isCloud,
}) })
$: minHeight =
Padding + SmallRowHeight + $rowHeight + (showControls ? ControlsHeight : 0) // Derive min height and make available in context
const minHeight = derived(rowHeight, $height => {
const heightForControls = showControls ? ControlsHeight : 0
return Padding + SmallRowHeight + $height + heightForControls
})
context = { ...context, minHeight }
// Set context for children to consume // Set context for children to consume
setContext("grid", context) setContext("grid", context)
@ -136,7 +145,7 @@
class:quiet class:quiet
on:mouseenter={() => gridFocused.set(true)} on:mouseenter={() => gridFocused.set(true)}
on:mouseleave={() => gridFocused.set(false)} on:mouseleave={() => gridFocused.set(false)}
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-overflow:{MaxCellRenderOverflow}px; --content-lines:{$contentLines}; --min-height:{minHeight}px; --controls-height:{ControlsHeight}px;" style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-overflow:{MaxCellRenderOverflow}px; --content-lines:{$contentLines}; --min-height:{$minHeight}px; --controls-height:{ControlsHeight}px;"
> >
{#if showControls} {#if showControls}
<div class="controls"> <div class="controls">

View File

@ -9,7 +9,6 @@
bounds, bounds,
renderedRows, renderedRows,
visibleColumns, visibleColumns,
rowVerticalInversionIndex,
hoveredRowId, hoveredRowId,
dispatch, dispatch,
isDragging, isDragging,
@ -41,11 +40,7 @@
<div bind:this={body} class="grid-body"> <div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically attachHandlers> <GridScrollWrapper scrollHorizontally scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
<GridRow <GridRow {row} top={idx === 0} />
{row}
top={idx === 0}
invertY={idx >= $rowVerticalInversionIndex}
/>
{/each} {/each}
{#if $config.canAddRows} {#if $config.canAddRows}
<div <div

View File

@ -5,7 +5,6 @@
export let row export let row
export let top = false export let top = false
export let invertY = false
const { const {
focusedCellId, focusedCellId,
@ -15,7 +14,6 @@
hoveredRowId, hoveredRowId,
selectedCellMap, selectedCellMap,
focusedRow, focusedRow,
columnHorizontalInversionIndex,
contentLines, contentLines,
isDragging, isDragging,
dispatch, dispatch,
@ -38,15 +36,13 @@
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
> >
{#each $visibleColumns as column, columnIdx} {#each $visibleColumns as column}
{@const cellId = getCellID(row._id, column.name)} {@const cellId = getCellID(row._id, column.name)}
<DataCell <DataCell
{cellId} {cellId}
{column} {column}
{row} {row}
{invertY}
{rowFocused} {rowFocused}
invertX={columnIdx >= $columnHorizontalInversionIndex}
highlighted={rowHovered || rowFocused || reorderSource === column.name} highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected} selected={rowSelected}
rowIdx={row.__idx} rowIdx={row.__idx}

View File

@ -24,8 +24,6 @@
rowHeight, rowHeight,
hasNextPage, hasNextPage,
maxScrollTop, maxScrollTop,
rowVerticalInversionIndex,
columnHorizontalInversionIndex,
selectedRows, selectedRows,
loaded, loaded,
refreshing, refreshing,
@ -43,17 +41,9 @@
$: firstColumn = $stickyColumn || $visibleColumns[0] $: firstColumn = $stickyColumn || $visibleColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
$: $datasource, (visible = false) $: $datasource, (visible = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
$: selectedRowCount = Object.values($selectedRows).length $: selectedRowCount = Object.values($selectedRows).length
$: hasNoRows = !$rows.length $: hasNoRows = !$rows.length
const shouldInvertY = (offset, inversionIndex, rows) => {
if (offset === 0) {
return false
}
return rows.length >= inversionIndex
}
const addRow = async () => { const addRow = async () => {
// Blur the active cell and tick to let final value updates propagate // Blur the active cell and tick to let final value updates propagate
isAdding = true isAdding = true
@ -205,7 +195,6 @@
width={$stickyColumn.width} width={$stickyColumn.width}
{updateValue} {updateValue}
topRow={offset === 0} topRow={offset === 0}
{invertY}
> >
{#if $stickyColumn?.schema?.autocolumn} {#if $stickyColumn?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div> <div class="readonly-overlay">Can't edit auto column</div>
@ -219,7 +208,7 @@
<div class="normal-columns" transition:fade|local={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers> <GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row"> <div class="row">
{#each $visibleColumns as column, columnIdx} {#each $visibleColumns as column}
{@const cellId = `new-${column.name}`} {@const cellId = `new-${column.name}`}
<DataCell <DataCell
{cellId} {cellId}
@ -230,8 +219,6 @@
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
width={column.width} width={column.width}
topRow={offset === 0} topRow={offset === 0}
invertX={columnIdx >= $columnHorizontalInversionIndex}
{invertY}
hidden={!$columnRenderMap[column.name]} hidden={!$columnRenderMap[column.name]}
> >
{#if column?.schema?.autocolumn} {#if column?.schema?.autocolumn}

View File

@ -13,6 +13,7 @@ import JSONCell from "../cells/JSONCell.svelte"
import AttachmentCell from "../cells/AttachmentCell.svelte" import AttachmentCell from "../cells/AttachmentCell.svelte"
import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte" import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
import BBReferenceCell from "../cells/BBReferenceCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte"
import SignatureCell from "../cells/SignatureCell.svelte"
import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte" import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
const TypeComponentMap = { const TypeComponentMap = {
@ -20,6 +21,7 @@ const TypeComponentMap = {
[FieldType.OPTIONS]: OptionsCell, [FieldType.OPTIONS]: OptionsCell,
[FieldType.DATETIME]: DateCell, [FieldType.DATETIME]: DateCell,
[FieldType.BARCODEQR]: TextCell, [FieldType.BARCODEQR]: TextCell,
[FieldType.SIGNATURE_SINGLE]: SignatureCell,
[FieldType.LONGFORM]: LongFormCell, [FieldType.LONGFORM]: LongFormCell,
[FieldType.ARRAY]: MultiSelectCell, [FieldType.ARRAY]: MultiSelectCell,
[FieldType.NUMBER]: NumberCell, [FieldType.NUMBER]: NumberCell,

View File

@ -1,5 +1,4 @@
import { derived, get, writable } from "svelte/store" import { derived, get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { GutterWidth, DefaultColumnWidth } from "../lib/constants" import { GutterWidth, DefaultColumnWidth } from "../lib/constants"
export const createStores = () => { export const createStores = () => {
@ -75,72 +74,23 @@ export const deriveStores = context => {
} }
export const createActions = context => { export const createActions = context => {
const { columns, stickyColumn, datasource, definition, schema } = context const { columns, datasource, schema } = context
// Updates the datasources primary display column
const changePrimaryDisplay = async column => {
return await datasource.actions.saveDefinition({
...get(definition),
primaryDisplay: column,
})
}
// Updates the width of all columns // Updates the width of all columns
const changeAllColumnWidths = async width => { const changeAllColumnWidths = async width => {
columns.update(state => { const $schema = get(schema)
return state.map(col => ({ let mutations = {}
...col, Object.keys($schema).forEach(field => {
width, mutations[field] = { width }
}))
})
if (get(stickyColumn)) {
stickyColumn.update(state => ({
...state,
width,
}))
}
await saveChanges()
}
// Persists column changes by saving metadata against datasource schema
const saveChanges = async () => {
const $columns = get(columns)
const $definition = get(definition)
const $stickyColumn = get(stickyColumn)
let newSchema = cloneDeep(get(schema)) || {}
// Build new updated datasource schema
Object.keys(newSchema).forEach(column => {
// Respect order specified by columns
const index = $columns.findIndex(x => x.name === column)
if (index !== -1) {
newSchema[column].order = index
} else {
delete newSchema[column].order
}
// Copy over metadata
if (column === $stickyColumn?.name) {
newSchema[column].visible = true
newSchema[column].width = $stickyColumn.width || DefaultColumnWidth
} else {
newSchema[column].visible = $columns[index]?.visible ?? true
newSchema[column].width = $columns[index]?.width || DefaultColumnWidth
}
})
await datasource.actions.saveDefinition({
...$definition,
schema: newSchema,
}) })
datasource.actions.addSchemaMutations(mutations)
await datasource.actions.saveSchemaMutations()
} }
return { return {
columns: { columns: {
...columns, ...columns,
actions: { actions: {
saveChanges,
changePrimaryDisplay,
changeAllColumnWidths, changeAllColumnWidths,
}, },
}, },

View File

@ -4,15 +4,23 @@ import { memo } from "../../../utils"
export const createStores = () => { export const createStores = () => {
const definition = memo(null) const definition = memo(null)
const schemaMutations = memo({})
return { return {
definition, definition,
schemaMutations,
} }
} }
export const deriveStores = context => { export const deriveStores = context => {
const { API, definition, schemaOverrides, columnWhitelist, datasource } = const {
context API,
definition,
schemaOverrides,
columnWhitelist,
datasource,
schemaMutations,
} = context
const schema = derived(definition, $definition => { const schema = derived(definition, $definition => {
let schema = getDatasourceSchema({ let schema = getDatasourceSchema({
@ -35,42 +43,26 @@ export const deriveStores = context => {
return schema return schema
}) })
// Derives the total enriched schema, made up of the saved schema and any
// prop and user overrides
const enrichedSchema = derived( const enrichedSchema = derived(
[schema, schemaOverrides, columnWhitelist], [schema, schemaOverrides, schemaMutations, columnWhitelist],
([$schema, $schemaOverrides, $columnWhitelist]) => { ([$schema, $schemaOverrides, $schemaMutations, $columnWhitelist]) => {
if (!$schema) { if (!$schema) {
return null return null
} }
let enrichedSchema = { ...$schema } let enrichedSchema = {}
Object.keys($schema).forEach(field => {
// Apply schema overrides // Apply whitelist if provided
Object.keys($schemaOverrides || {}).forEach(field => { if ($columnWhitelist?.length && !$columnWhitelist.includes(field)) {
if (enrichedSchema[field]) { return
enrichedSchema[field] = { }
...enrichedSchema[field], enrichedSchema[field] = {
...$schemaOverrides[field], ...$schema[field],
} ...$schemaOverrides?.[field],
...$schemaMutations[field],
} }
}) })
// Apply whitelist if specified
if ($columnWhitelist?.length) {
const sortedColumns = {}
$columnWhitelist.forEach((columnKey, idx) => {
const enrichedColumn = enrichedSchema[columnKey]
if (enrichedColumn) {
sortedColumns[columnKey] = {
...enrichedColumn,
order: idx,
visible: true,
}
}
})
return sortedColumns
}
return enrichedSchema return enrichedSchema
} }
) )
@ -100,6 +92,8 @@ export const createActions = context => {
table, table,
viewV2, viewV2,
nonPlus, nonPlus,
schemaMutations,
schema,
} = context } = context
// Gets the appropriate API for the configured datasource type // Gets the appropriate API for the configured datasource type
@ -136,11 +130,81 @@ export const createActions = context => {
// Update server // Update server
if (get(config).canSaveSchema) { if (get(config).canSaveSchema) {
await getAPI()?.actions.saveDefinition(newDefinition) await getAPI()?.actions.saveDefinition(newDefinition)
}
// Broadcast change to external state can be updated, as this change // Broadcast change so external state can be updated, as this change
// will not be received by the builder websocket because we caused it ourselves // will not be received by the builder websocket because we caused it
dispatch("updatedatasource", newDefinition) // ourselves
dispatch("updatedatasource", newDefinition)
}
}
// Updates the datasources primary display column
const changePrimaryDisplay = async column => {
return await saveDefinition({
...get(definition),
primaryDisplay: column,
})
}
// Adds a schema mutation for a single field
const addSchemaMutation = (field, mutation) => {
if (!field || !mutation) {
return
}
schemaMutations.update($schemaMutations => {
return {
...$schemaMutations,
[field]: {
...$schemaMutations[field],
...mutation,
},
}
})
}
// Adds schema mutations for multiple fields at once
const addSchemaMutations = mutations => {
const fields = Object.keys(mutations || {})
if (!fields.length) {
return
}
schemaMutations.update($schemaMutations => {
let newSchemaMutations = { ...$schemaMutations }
fields.forEach(field => {
newSchemaMutations[field] = {
...newSchemaMutations[field],
...mutations[field],
}
})
return newSchemaMutations
})
}
// Saves schema changes to the server, if possible
const saveSchemaMutations = async () => {
// If we can't save schema changes then we just want to keep this in memory
if (!get(config).canSaveSchema) {
return
}
const $definition = get(definition)
const $schemaMutations = get(schemaMutations)
const $schema = get(schema)
let newSchema = {}
// Build new updated datasource schema
Object.keys($schema).forEach(column => {
newSchema[column] = {
...$schema[column],
...$schemaMutations[column],
}
})
// Save the changes, then reset our local mutations
await saveDefinition({
...$definition,
schema: newSchema,
})
schemaMutations.set({})
} }
// Adds a row to the datasource // Adds a row to the datasource
@ -185,6 +249,10 @@ export const createActions = context => {
getRow, getRow,
isDatasourceValid, isDatasourceValid,
canUseColumn, canUseColumn,
changePrimaryDisplay,
addSchemaMutation,
addSchemaMutations,
saveSchemaMutations,
}, },
}, },
} }

View File

@ -34,6 +34,7 @@ export const createActions = context => {
stickyColumn, stickyColumn,
maxScrollLeft, maxScrollLeft,
width, width,
datasource,
} = context } = context
let autoScrollInterval let autoScrollInterval
@ -173,20 +174,17 @@ export const createActions = context => {
document.removeEventListener("touchend", stopReordering) document.removeEventListener("touchend", stopReordering)
document.removeEventListener("touchcancel", stopReordering) document.removeEventListener("touchcancel", stopReordering)
// Ensure there's actually a change // Ensure there's actually a change before saving
let { sourceColumn, targetColumn } = get(reorder) const { sourceColumn, targetColumn } = get(reorder)
if (sourceColumn !== targetColumn) {
moveColumn(sourceColumn, targetColumn)
await columns.actions.saveChanges()
}
// Reset state
reorder.set(reorderInitialState) reorder.set(reorderInitialState)
if (sourceColumn !== targetColumn) {
await moveColumn(sourceColumn, targetColumn)
}
} }
// Moves a column after another columns. // Moves a column after another columns.
// An undefined target column will move the source to index 0. // An undefined target column will move the source to index 0.
const moveColumn = (sourceColumn, targetColumn) => { const moveColumn = async (sourceColumn, targetColumn) => {
let $columns = get(columns) let $columns = get(columns)
let sourceIdx = $columns.findIndex(x => x.name === sourceColumn) let sourceIdx = $columns.findIndex(x => x.name === sourceColumn)
let targetIdx = $columns.findIndex(x => x.name === targetColumn) let targetIdx = $columns.findIndex(x => x.name === targetColumn)
@ -198,14 +196,21 @@ export const createActions = context => {
} }
return state.toSpliced(targetIdx, 0, removed[0]) return state.toSpliced(targetIdx, 0, removed[0])
}) })
// Extract new orders as schema mutations
let mutations = {}
get(columns).forEach((column, idx) => {
mutations[column.name] = { order: idx }
})
datasource.actions.addSchemaMutations(mutations)
await datasource.actions.saveSchemaMutations()
} }
// Moves a column one place left (as appears visually) // Moves a column one place left (as appears visually)
const moveColumnLeft = async column => { const moveColumnLeft = async column => {
const $visibleColumns = get(visibleColumns) const $visibleColumns = get(visibleColumns)
const sourceIdx = $visibleColumns.findIndex(x => x.name === column) const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
moveColumn(column, $visibleColumns[sourceIdx - 2]?.name) await moveColumn(column, $visibleColumns[sourceIdx - 2]?.name)
await columns.actions.saveChanges()
} }
// Moves a column one place right (as appears visually) // Moves a column one place right (as appears visually)
@ -215,8 +220,7 @@ export const createActions = context => {
if (sourceIdx === $visibleColumns.length - 1) { if (sourceIdx === $visibleColumns.length - 1) {
return return
} }
moveColumn(column, $visibleColumns[sourceIdx + 1]?.name) await moveColumn(column, $visibleColumns[sourceIdx + 1]?.name)
await columns.actions.saveChanges()
} }
return { return {

View File

@ -6,7 +6,6 @@ const initialState = {
initialMouseX: null, initialMouseX: null,
initialWidth: null, initialWidth: null,
column: null, column: null,
columnIdx: null,
width: 0, width: 0,
left: 0, left: 0,
} }
@ -21,7 +20,7 @@ export const createStores = () => {
} }
export const createActions = context => { export const createActions = context => {
const { resize, columns, stickyColumn, ui } = context const { resize, ui, datasource } = context
// Starts resizing a certain column // Starts resizing a certain column
const startResizing = (column, e) => { const startResizing = (column, e) => {
@ -32,12 +31,6 @@ export const createActions = context => {
e.preventDefault() e.preventDefault()
ui.actions.blur() ui.actions.blur()
// Find and cache index
let columnIdx = get(columns).findIndex(col => col.name === column.name)
if (columnIdx === -1) {
columnIdx = "sticky"
}
// Set initial store state // Set initial store state
resize.set({ resize.set({
width: column.width, width: column.width,
@ -45,7 +38,6 @@ export const createActions = context => {
initialWidth: column.width, initialWidth: column.width,
initialMouseX: x, initialMouseX: x,
column: column.name, column: column.name,
columnIdx,
}) })
// Add mouse event listeners to handle resizing // Add mouse event listeners to handle resizing
@ -58,7 +50,7 @@ export const createActions = context => {
// Handler for moving the mouse to resize columns // Handler for moving the mouse to resize columns
const onResizeMouseMove = e => { const onResizeMouseMove = e => {
const { initialMouseX, initialWidth, width, columnIdx } = get(resize) const { initialMouseX, initialWidth, width, column } = get(resize)
const { x } = parseEventLocation(e) const { x } = parseEventLocation(e)
const dx = x - initialMouseX const dx = x - initialMouseX
const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx)) const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx))
@ -69,17 +61,7 @@ export const createActions = context => {
} }
// Update column state // Update column state
if (columnIdx === "sticky") { datasource.actions.addSchemaMutation(column, { width })
stickyColumn.update(state => ({
...state,
width: newWidth,
}))
} else {
columns.update(state => {
state[columnIdx].width = newWidth
return [...state]
})
}
// Update state // Update state
resize.update(state => ({ resize.update(state => ({
@ -101,26 +83,16 @@ export const createActions = context => {
// Persist width if it changed // Persist width if it changed
if ($resize.width !== $resize.initialWidth) { if ($resize.width !== $resize.initialWidth) {
await columns.actions.saveChanges() await datasource.actions.saveSchemaMutations()
} }
} }
// Resets a column size back to default // Resets a column size back to default
const resetSize = async column => { const resetSize = async column => {
const $stickyColumn = get(stickyColumn) datasource.actions.addSchemaMutation(column.name, {
if (column.name === $stickyColumn?.name) { width: DefaultColumnWidth,
stickyColumn.update(state => ({ })
...state, await datasource.actions.saveSchemaMutations()
width: DefaultColumnWidth,
}))
} else {
columns.update(state => {
const columnIdx = state.findIndex(x => x.name === column.name)
state[columnIdx].width = DefaultColumnWidth
return [...state]
})
}
await columns.actions.saveChanges()
} }
return { return {

View File

@ -1,9 +1,5 @@
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { import { MinColumnWidth } from "../lib/constants"
MaxCellRenderOverflow,
MinColumnWidth,
ScrollBarSize,
} from "../lib/constants"
export const deriveStores = context => { export const deriveStores = context => {
const { const {
@ -85,51 +81,10 @@ export const deriveStores = context => {
} }
) )
// Determine the row index at which we should start vertically inverting cell
// dropdowns
const rowVerticalInversionIndex = derived(
[height, rowHeight, scrollTop],
([$height, $rowHeight, $scrollTop]) => {
const offset = $scrollTop % $rowHeight
// Compute the last row index with space to render popovers below it
const minBottom =
$height - ScrollBarSize * 3 - MaxCellRenderOverflow + offset
const lastIdx = Math.floor(minBottom / $rowHeight)
// Compute the first row index with space to render popovers above it
const minTop = MaxCellRenderOverflow + offset
const firstIdx = Math.ceil(minTop / $rowHeight)
// Use the greater of the two indices so that we prefer content below,
// unless there is room to render the entire popover above
return Math.max(lastIdx, firstIdx)
}
)
// Determine the column index at which we should start horizontally inverting
// cell dropdowns
const columnHorizontalInversionIndex = derived(
[visibleColumns, scrollLeft, width],
([$visibleColumns, $scrollLeft, $width]) => {
const cutoff = $width + $scrollLeft - ScrollBarSize * 3
let inversionIdx = $visibleColumns.length
for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderOverflow <= cutoff) {
break
}
}
return inversionIdx
}
)
return { return {
scrolledRowCount, scrolledRowCount,
visualRowCapacity, visualRowCapacity,
renderedRows, renderedRows,
columnRenderMap, columnRenderMap,
rowVerticalInversionIndex,
columnHorizontalInversionIndex,
} }
} }

View File

@ -1,5 +1,6 @@
export { default as SplitPage } from "./SplitPage.svelte" export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte" export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as SignatureModal } from "./SignatureModal.svelte"
export { default as Testimonial } from "./Testimonial.svelte" export { default as Testimonial } from "./Testimonial.svelte"
export { default as UserAvatar } from "./UserAvatar.svelte" export { default as UserAvatar } from "./UserAvatar.svelte"
export { default as UserAvatars } from "./UserAvatars.svelte" export { default as UserAvatars } from "./UserAvatars.svelte"

View File

@ -121,6 +121,7 @@ export const TypeIconMap = {
[FieldType.OPTIONS]: "Dropdown", [FieldType.OPTIONS]: "Dropdown",
[FieldType.DATETIME]: "Calendar", [FieldType.DATETIME]: "Calendar",
[FieldType.BARCODEQR]: "Camera", [FieldType.BARCODEQR]: "Camera",
[FieldType.SIGNATURE_SINGLE]: "AnnotatePen",
[FieldType.LONGFORM]: "TextAlignLeft", [FieldType.LONGFORM]: "TextAlignLeft",
[FieldType.ARRAY]: "Duplicate", [FieldType.ARRAY]: "Duplicate",
[FieldType.NUMBER]: "123", [FieldType.NUMBER]: "123",

View File

@ -17,8 +17,10 @@ module FetchMock {
raw: () => { raw: () => {
return { "content-type": ["application/json"] } return { "content-type": ["application/json"] }
}, },
get: () => { get: (name: string) => {
return ["application/json"] if (name.toLowerCase() === "content-type") {
return ["application/json"]
}
}, },
}, },
json: async () => { json: async () => {

View File

@ -79,7 +79,7 @@
"dotenv": "8.2.0", "dotenv": "8.2.0",
"form-data": "4.0.0", "form-data": "4.0.0",
"global-agent": "3.0.0", "global-agent": "3.0.0",
"google-spreadsheet": "4.1.2", "google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.2",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"isolated-vm": "^4.7.2", "isolated-vm": "^4.7.2",
"jimp": "0.22.10", "jimp": "0.22.10",

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