1
0
Fork 0
mirror of synced 2024-08-24 06:21:37 +12:00

Merge pull request #13418 from Budibase/feature/signature-field-and-component

Signature field and component support
This commit is contained in:
deanhannigan 2024-05-20 12:05:50 +01:00 committed by GitHub
commit d8113ddb5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1195 additions and 239 deletions

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

@ -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,11 +9,16 @@
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 meta export let meta
export let value export let value
@ -38,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}
@ -58,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}
@ -94,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,6 +1,6 @@
<script> <script>
import { datasources, tables, integrations, appStore } from "stores/builder" import { datasources, tables, integrations, appStore } from "stores/builder"
import { admin } from "stores/portal" 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"
@ -38,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
@ -56,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}

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

@ -412,6 +412,7 @@
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.JSON, FIELDS.JSON,
FIELDS.BARCODEQR, FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT, FIELDS.BIGINT,
FIELDS.AUTO, FIELDS.AUTO,
] ]

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

@ -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

@ -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

@ -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

@ -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

@ -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,

View file

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

View file

@ -4115,6 +4115,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",
@ -4380,7 +4429,7 @@
"defaultValue": false "defaultValue": false
}, },
{ {
"type": "validation/attachment", "type": "validation/attachment_single",
"label": "Validation", "label": "Validation",
"key": "validation" "key": "validation"
}, },

View file

@ -34,7 +34,8 @@
"screenfull": "^6.0.1", "screenfull": "^6.0.1",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-apexcharts": "^1.0.2", "svelte-apexcharts": "^1.0.2",
"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

@ -37,8 +37,10 @@
let grid let grid
let gridContext let gridContext
let minHeight 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)
@ -154,6 +156,7 @@
{API} {API}
{stripeRows} {stripeRows}
{quiet} {quiet}
{darkMode}
{initialFilter} {initialFilter}
{initialSortColumn} {initialSortColumn}
{initialSortOrder} {initialSortOrder}

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

@ -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,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
export let schema export let schema
export let maximum export let maximum
@ -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}

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

@ -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

@ -54,6 +54,7 @@
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 export let isCloud = null
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
@ -109,6 +110,7 @@
notifySuccess, notifySuccess,
notifyError, notifyError,
buttons, buttons,
darkMode,
isCloud, isCloud,
}) })

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,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

@ -309,6 +309,11 @@ describe.each([
name: "attachments", name: "attachments",
constraints: { type: "array", presence: false }, constraints: { type: "array", presence: false },
} }
const signature: FieldSchema = {
type: FieldType.SIGNATURE_SINGLE,
name: "signature",
constraints: { presence: false },
}
const bool: FieldSchema = { const bool: FieldSchema = {
type: FieldType.BOOLEAN, type: FieldType.BOOLEAN,
name: "boolean", name: "boolean",
@ -375,6 +380,8 @@ describe.each([
attachmentListUndefined: attachmentList, attachmentListUndefined: attachmentList,
attachmentListEmpty: attachmentList, attachmentListEmpty: attachmentList,
attachmentListEmptyArrayStr: attachmentList, attachmentListEmptyArrayStr: attachmentList,
signatureNull: signature,
signatureUndefined: signature,
arrayFieldEmptyArrayStr: arrayField, arrayFieldEmptyArrayStr: arrayField,
arrayFieldArrayStrKnown: arrayField, arrayFieldArrayStrKnown: arrayField,
arrayFieldNull: arrayField, arrayFieldNull: arrayField,
@ -416,6 +423,8 @@ describe.each([
attachmentListUndefined: undefined, attachmentListUndefined: undefined,
attachmentListEmpty: "", attachmentListEmpty: "",
attachmentListEmptyArrayStr: "[]", attachmentListEmptyArrayStr: "[]",
signatureNull: null,
signatureUndefined: undefined,
arrayFieldEmptyArrayStr: "[]", arrayFieldEmptyArrayStr: "[]",
arrayFieldUndefined: undefined, arrayFieldUndefined: undefined,
arrayFieldNull: null, arrayFieldNull: null,
@ -450,6 +459,8 @@ describe.each([
expect(row.attachmentListUndefined).toBe(undefined) expect(row.attachmentListUndefined).toBe(undefined)
expect(row.attachmentListEmpty).toEqual([]) expect(row.attachmentListEmpty).toEqual([])
expect(row.attachmentListEmptyArrayStr).toEqual([]) expect(row.attachmentListEmptyArrayStr).toEqual([])
expect(row.signatureNull).toEqual(null)
expect(row.signatureUndefined).toBe(undefined)
expect(row.arrayFieldEmptyArrayStr).toEqual([]) expect(row.arrayFieldEmptyArrayStr).toEqual([])
expect(row.arrayFieldNull).toEqual([]) expect(row.arrayFieldNull).toEqual([])
expect(row.arrayFieldUndefined).toEqual(undefined) expect(row.arrayFieldUndefined).toEqual(undefined)
@ -894,70 +905,91 @@ describe.each([
}) })
isInternal && isInternal &&
describe("attachments", () => { describe("attachments and signatures", () => {
it("should allow enriching single attachment rows", async () => { const coreAttachmentEnrichment = async (
const table = await config.api.table.save( schema: any,
field: string,
attachmentCfg: string | string[]
) => {
const testTable = await config.api.table.save(
defaultTable({ defaultTable({
schema: { schema,
attachment: {
type: FieldType.ATTACHMENT_SINGLE,
name: "attachment",
constraints: { presence: false },
},
},
}) })
) )
const attachmentId = `${uuid.v4()}.csv` const attachmentToStoreKey = (attachmentId: string) => {
const row = await config.api.row.save(table._id!, { return {
key: `${config.getAppId()}/attachments/${attachmentId}`,
}
}
const draftRow = {
name: "test", name: "test",
description: "test", description: "test",
attachment: { [field]:
key: `${config.getAppId()}/attachments/${attachmentId}`, typeof attachmentCfg === "string"
}, ? attachmentToStoreKey(attachmentCfg)
: attachmentCfg.map(attachmentToStoreKey),
tableId: testTable._id,
}
const row = await config.api.row.save(testTable._id!, draftRow)
tableId: table._id,
})
await config.withEnv({ SELF_HOSTED: "true" }, async () => { await config.withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row]) const enriched: Row[] = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment.url.split("?")[0]).toBe( const [targetRow] = enriched
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` const attachmentEntries = Array.isArray(targetRow[field])
) ? targetRow[field]
: [targetRow[field]]
for (const entry of attachmentEntries) {
const attachmentId = entry.key.split("/").pop()
expect(entry.url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
)
}
}) })
}) })
}
it("should allow enriching single attachment rows", async () => {
await coreAttachmentEnrichment(
{
attachment: {
type: FieldType.ATTACHMENT_SINGLE,
name: "attachment",
constraints: { presence: false },
},
},
"attachment",
`${uuid.v4()}.csv`
)
}) })
it("should allow enriching attachment list rows", async () => { it("should allow enriching attachment list rows", async () => {
const table = await config.api.table.save( await coreAttachmentEnrichment(
defaultTable({ {
schema: { attachments: {
attachment: { type: FieldType.ATTACHMENTS,
type: FieldType.ATTACHMENTS, name: "attachments",
name: "attachment", constraints: { type: "array", presence: false },
constraints: { type: "array", presence: false },
},
}, },
}) },
"attachments",
[`${uuid.v4()}.csv`]
) )
const attachmentId = `${uuid.v4()}.csv` })
const row = await config.api.row.save(table._id!, {
name: "test", it("should allow enriching signature rows", async () => {
description: "test", await coreAttachmentEnrichment(
attachment: [ {
{ signature: {
key: `${config.getAppId()}/attachments/${attachmentId}`, type: FieldType.SIGNATURE_SINGLE,
name: "signature",
constraints: { presence: false },
}, },
], },
tableId: table._id, "signature",
}) `${uuid.v4()}.png`
await config.withEnv({ SELF_HOSTED: "true" }, async () => { )
return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment[0].url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
)
})
})
}) })
}) })

View file

@ -19,9 +19,10 @@ import {
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper" import tk from "timekeeper"
import { mocks } from "@budibase/backend-core/tests"
import { encodeJSBinding } from "@budibase/string-templates" import { encodeJSBinding } from "@budibase/string-templates"
const serverTime = new Date("2024-05-06T00:00:00.000Z") const serverTime = mocks.date.MOCK_DATE
tk.freeze(serverTime) tk.freeze(serverTime)
describe.each([ describe.each([
@ -407,8 +408,7 @@ describe.each([
}) })
it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => { it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => {
const jsBinding = const jsBinding = `const currentTime = new Date(${Date.now()})\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();`
"const currentTime = new Date()\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();"
const encodedBinding = encodeJSBinding(jsBinding) const encodedBinding = encodeJSBinding(jsBinding)
await expectQuery({ await expectQuery({

View file

@ -113,7 +113,8 @@ export async function sendAutomationAttachmentsToStorage(
const schema = table.schema[prop] const schema = table.schema[prop]
if ( if (
schema?.type === FieldType.ATTACHMENTS || schema?.type === FieldType.ATTACHMENTS ||
schema?.type === FieldType.ATTACHMENT_SINGLE schema?.type === FieldType.ATTACHMENT_SINGLE ||
schema?.type === FieldType.SIGNATURE_SINGLE
) { ) {
attachmentRows[prop] = value attachmentRows[prop] = value
} }

View file

@ -125,6 +125,7 @@ function generateSchema(
break break
case FieldType.ATTACHMENTS: case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE: case FieldType.ATTACHMENT_SINGLE:
case FieldType.SIGNATURE_SINGLE:
case FieldType.AUTO: case FieldType.AUTO:
case FieldType.JSON: case FieldType.JSON:
case FieldType.INTERNAL: case FieldType.INTERNAL:

View file

@ -72,6 +72,7 @@ const isTypeAllowed: Record<FieldType, boolean> = {
[FieldType.JSON]: false, [FieldType.JSON]: false,
[FieldType.INTERNAL]: false, [FieldType.INTERNAL]: false,
[FieldType.BIGINT]: false, [FieldType.BIGINT]: false,
[FieldType.SIGNATURE_SINGLE]: false,
} }
const ALLOWED_TYPES = Object.entries(isTypeAllowed) const ALLOWED_TYPES = Object.entries(isTypeAllowed)

View file

@ -381,6 +381,7 @@ function copyExistingPropsOver(
case FieldType.ARRAY: case FieldType.ARRAY:
case FieldType.ATTACHMENTS: case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE: case FieldType.ATTACHMENT_SINGLE:
case FieldType.SIGNATURE_SINGLE:
case FieldType.JSON: case FieldType.JSON:
case FieldType.BB_REFERENCE: case FieldType.BB_REFERENCE:
case FieldType.BB_REFERENCE_SINGLE: case FieldType.BB_REFERENCE_SINGLE:

View file

@ -68,7 +68,8 @@ export async function updateAttachmentColumns(prodAppId: string, db: Database) {
rewriteAttachmentUrl(prodAppId, attachment) rewriteAttachmentUrl(prodAppId, attachment)
) )
} else if ( } else if (
columnType === FieldType.ATTACHMENT_SINGLE && (columnType === FieldType.ATTACHMENT_SINGLE ||
columnType === FieldType.SIGNATURE_SINGLE) &&
row[column] row[column]
) { ) {
row[column] = rewriteAttachmentUrl(prodAppId, row[column]) row[column] = rewriteAttachmentUrl(prodAppId, row[column])

View file

@ -32,7 +32,8 @@ export async function getRowsWithAttachments(appId: string, table: Table) {
for (let [key, column] of Object.entries(table.schema)) { for (let [key, column] of Object.entries(table.schema)) {
if ( if (
column.type === FieldType.ATTACHMENTS || column.type === FieldType.ATTACHMENTS ||
column.type === FieldType.ATTACHMENT_SINGLE column.type === FieldType.ATTACHMENT_SINGLE ||
column.type === FieldType.SIGNATURE_SINGLE
) { ) {
attachmentCols.push(key) attachmentCols.push(key)
} }

View file

@ -42,6 +42,7 @@ const FieldTypeMap: Record<FieldType, SQLiteType> = {
[FieldType.BARCODEQR]: SQLiteType.BLOB, [FieldType.BARCODEQR]: SQLiteType.BLOB,
[FieldType.ATTACHMENTS]: SQLiteType.BLOB, [FieldType.ATTACHMENTS]: SQLiteType.BLOB,
[FieldType.ATTACHMENT_SINGLE]: SQLiteType.BLOB, [FieldType.ATTACHMENT_SINGLE]: SQLiteType.BLOB,
[FieldType.SIGNATURE_SINGLE]: SQLiteType.BLOB,
[FieldType.ARRAY]: SQLiteType.BLOB, [FieldType.ARRAY]: SQLiteType.BLOB,
[FieldType.LINK]: SQLiteType.BLOB, [FieldType.LINK]: SQLiteType.BLOB,
[FieldType.BIGINT]: SQLiteType.TEXT, [FieldType.BIGINT]: SQLiteType.TEXT,

View file

@ -23,13 +23,35 @@ describe("should be able to re-write attachment URLs", () => {
await config.init() await config.init()
}) })
it("should update URLs on a number of rows over the limit", async () => { const coreBehaviour = async (tblSchema: any, row: any) => {
const table = await config.api.table.save({ const table = await config.api.table.save({
name: "photos", name: "photos",
type: "table", type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID, sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL, sourceType: TableSourceType.INTERNAL,
schema: { schema: tblSchema,
})
for (let i = 0; i < FIND_LIMIT * 4; i++) {
await config.api.row.save(table._id!, {
...row,
})
}
const db = dbCore.getDB(config.getAppId())
await sdk.backups.updateAttachmentColumns(db.name, db)
return {
db,
rows: (await sdk.rows.getAllInternalRows(db.name)).filter(
row => row.tableId === table._id
),
}
}
it("Attachment field, should update URLs on a number of rows over the limit", async () => {
const { rows, db } = await coreBehaviour(
{
photo: { photo: {
type: FieldType.ATTACHMENT_SINGLE, type: FieldType.ATTACHMENT_SINGLE,
name: "photo", name: "photo",
@ -43,21 +65,11 @@ describe("should be able to re-write attachment URLs", () => {
name: "otherCol", name: "otherCol",
}, },
}, },
}) {
for (let i = 0; i < FIND_LIMIT * 4; i++) {
await config.api.row.save(table._id!, {
photo: { ...attachment }, photo: { ...attachment },
gallery: [{ ...attachment }, { ...attachment }], gallery: [{ ...attachment }, { ...attachment }],
otherCol: "string", otherCol: "string",
}) }
}
const db = dbCore.getDB(config.getAppId())
await sdk.backups.updateAttachmentColumns(db.name, db)
const rows = (await sdk.rows.getAllInternalRows(db.name)).filter(
row => row.tableId === table._id
) )
for (const row of rows) { for (const row of rows) {
expect(row.otherCol).toBe("string") expect(row.otherCol).toBe("string")
@ -69,4 +81,27 @@ describe("should be able to re-write attachment URLs", () => {
expect(row.gallery[1].key).toBe(`${db.name}/attachments/a.png`) expect(row.gallery[1].key).toBe(`${db.name}/attachments/a.png`)
} }
}) })
it("Signature field, should update URLs on a number of rows over the limit", async () => {
const { rows, db } = await coreBehaviour(
{
signature: {
type: FieldType.SIGNATURE_SINGLE,
name: "signature",
},
otherCol: {
type: FieldType.STRING,
name: "otherCol",
},
},
{
signature: { ...attachment },
otherCol: "string",
}
)
for (const row of rows) {
expect(row.otherCol).toBe("string")
expect(row.signature.url).toBe("")
expect(row.signature.key).toBe(`${db.name}/attachments/a.png`)
}
})
}) })

View file

@ -31,7 +31,8 @@ export class AttachmentCleanup {
): string[] { ): string[] {
if ( if (
type !== FieldType.ATTACHMENTS && type !== FieldType.ATTACHMENTS &&
type !== FieldType.ATTACHMENT_SINGLE type !== FieldType.ATTACHMENT_SINGLE &&
type !== FieldType.SIGNATURE_SINGLE
) { ) {
return [] return []
} }
@ -62,7 +63,8 @@ export class AttachmentCleanup {
for (let [key, schema] of Object.entries(tableSchema)) { for (let [key, schema] of Object.entries(tableSchema)) {
if ( if (
schema.type !== FieldType.ATTACHMENTS && schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE schema.type !== FieldType.ATTACHMENT_SINGLE &&
schema.type !== FieldType.SIGNATURE_SINGLE
) { ) {
continue continue
} }
@ -100,10 +102,12 @@ export class AttachmentCleanup {
for (let [key, schema] of Object.entries(table.schema)) { for (let [key, schema] of Object.entries(table.schema)) {
if ( if (
schema.type !== FieldType.ATTACHMENTS && schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE schema.type !== FieldType.ATTACHMENT_SINGLE &&
schema.type !== FieldType.SIGNATURE_SINGLE
) { ) {
continue continue
} }
rows.forEach(row => { rows.forEach(row => {
files = files.concat( files = files.concat(
AttachmentCleanup.extractAttachmentKeys(schema.type, row[key]) AttachmentCleanup.extractAttachmentKeys(schema.type, row[key])
@ -120,7 +124,8 @@ export class AttachmentCleanup {
for (let [key, schema] of Object.entries(table.schema)) { for (let [key, schema] of Object.entries(table.schema)) {
if ( if (
schema.type !== FieldType.ATTACHMENTS && schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE schema.type !== FieldType.ATTACHMENT_SINGLE &&
schema.type !== FieldType.SIGNATURE_SINGLE
) { ) {
continue continue
} }

View file

@ -158,7 +158,10 @@ export async function inputProcessing(
delete attachment.url delete attachment.url
}) })
} }
} else if (field.type === FieldType.ATTACHMENT_SINGLE) { } else if (
field.type === FieldType.ATTACHMENT_SINGLE ||
field.type === FieldType.SIGNATURE_SINGLE
) {
const attachment = clonedRow[key] const attachment = clonedRow[key]
if (attachment?.url) { if (attachment?.url) {
delete clonedRow[key].url delete clonedRow[key].url
@ -230,7 +233,8 @@ export async function outputProcessing<T extends Row[] | Row>(
for (let [property, column] of Object.entries(table.schema)) { for (let [property, column] of Object.entries(table.schema)) {
if ( if (
column.type === FieldType.ATTACHMENTS || column.type === FieldType.ATTACHMENTS ||
column.type === FieldType.ATTACHMENT_SINGLE column.type === FieldType.ATTACHMENT_SINGLE ||
column.type === FieldType.SIGNATURE_SINGLE
) { ) {
for (let row of enriched) { for (let row of enriched) {
if (row[property] == null) { if (row[property] == null) {

View file

@ -1,22 +1,30 @@
import { AttachmentCleanup } from "../attachments" import { AttachmentCleanup } from "../attachments"
import { FieldType, Table, Row, TableSourceType } from "@budibase/types" import { FieldType, Table, Row, TableSourceType } from "@budibase/types"
import { DEFAULT_BB_DATASOURCE_ID } from "../../../constants" import { DEFAULT_BB_DATASOURCE_ID } from "../../../constants"
import { objectStore } from "@budibase/backend-core" import { objectStore, db, context } from "@budibase/backend-core"
import * as uuid from "uuid"
const BUCKET = "prod-budi-app-assets" const BUCKET = "prod-budi-app-assets"
const FILE_NAME = "file/thing.jpg" const FILE_NAME = "file/thing.jpg"
const DEV_APPID = "abc_dev_123"
const PROD_APPID = "abc_123"
jest.mock("@budibase/backend-core", () => { jest.mock("@budibase/backend-core", () => {
const actual = jest.requireActual("@budibase/backend-core") const actual = jest.requireActual("@budibase/backend-core")
return { return {
...actual, ...actual,
context: {
...actual.context,
getAppId: jest.fn(),
},
objectStore: { objectStore: {
deleteFiles: jest.fn(), deleteFiles: jest.fn(),
ObjectStoreBuckets: actual.objectStore.ObjectStoreBuckets, ObjectStoreBuckets: actual.objectStore.ObjectStoreBuckets,
}, },
db: { db: {
isProdAppID: () => jest.fn(() => false), isProdAppID: jest.fn(),
dbExists: () => jest.fn(() => false), getProdAppID: jest.fn(),
dbExists: jest.fn(),
}, },
} }
}) })
@ -27,12 +35,18 @@ const mockedDeleteFiles = objectStore.deleteFiles as jest.MockedFunction<
const rowGenerators: [ const rowGenerators: [
string, string,
FieldType.ATTACHMENT_SINGLE | FieldType.ATTACHMENTS, (
| FieldType.ATTACHMENT_SINGLE
| FieldType.ATTACHMENTS
| FieldType.SIGNATURE_SINGLE
),
string,
(fileKey?: string) => Row (fileKey?: string) => Row
][] = [ ][] = [
[ [
"row with a attachment list column", "row with a attachment list column",
FieldType.ATTACHMENTS, FieldType.ATTACHMENTS,
"attach",
function rowWithAttachments(fileKey: string = FILE_NAME): Row { function rowWithAttachments(fileKey: string = FILE_NAME): Row {
return { return {
attach: [ attach: [
@ -48,6 +62,7 @@ const rowGenerators: [
[ [
"row with a single attachment column", "row with a single attachment column",
FieldType.ATTACHMENT_SINGLE, FieldType.ATTACHMENT_SINGLE,
"attach",
function rowWithAttachments(fileKey: string = FILE_NAME): Row { function rowWithAttachments(fileKey: string = FILE_NAME): Row {
return { return {
attach: { attach: {
@ -58,11 +73,25 @@ const rowGenerators: [
} }
}, },
], ],
[
"row with a single signature column",
FieldType.SIGNATURE_SINGLE,
"signature",
function rowWithSignature(): Row {
return {
signature: {
size: 1,
extension: "png",
key: `${uuid.v4()}.png`,
},
}
},
],
] ]
describe.each(rowGenerators)( describe.each(rowGenerators)(
"attachment cleanup", "attachment cleanup",
(_, attachmentFieldType, rowGenerator) => { (_, attachmentFieldType, colKey, rowGenerator) => {
function tableGenerator(): Table { function tableGenerator(): Table {
return { return {
name: "table", name: "table",
@ -75,97 +104,158 @@ describe.each(rowGenerators)(
type: attachmentFieldType, type: attachmentFieldType,
constraints: {}, constraints: {},
}, },
signature: {
name: "signature",
type: FieldType.SIGNATURE_SINGLE,
constraints: {},
},
}, },
} }
} }
const getRowKeys = (row: any, col: string) => {
return Array.isArray(row[col])
? row[col].map((entry: any) => entry.key)
: [row[col]?.key]
}
beforeEach(() => { beforeEach(() => {
mockedDeleteFiles.mockClear() mockedDeleteFiles.mockClear()
jest.resetAllMocks()
jest.spyOn(context, "getAppId").mockReturnValue(DEV_APPID)
jest.spyOn(db, "isProdAppID").mockReturnValue(false)
jest.spyOn(db, "getProdAppID").mockReturnValue(PROD_APPID)
jest.spyOn(db, "dbExists").mockReturnValue(Promise.resolve(false))
}) })
it("should be able to cleanup a table update", async () => { // Ignore calls to prune attachments when app is in production.
it(`${attachmentFieldType} - should not attempt to delete attachments/signatures if a published app exists`, async () => {
jest.spyOn(db, "dbExists").mockReturnValue(Promise.resolve(true))
const originalTable = tableGenerator() const originalTable = tableGenerator()
delete originalTable.schema["attach"] delete originalTable.schema[colKey]
await AttachmentCleanup.tableUpdate(originalTable, [rowGenerator()], { await AttachmentCleanup.tableUpdate(originalTable, [rowGenerator()], {
oldTable: tableGenerator(), oldTable: tableGenerator(),
}) })
expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME])
})
it("should be able to cleanup a table deletion", async () => {
await AttachmentCleanup.tableDelete(tableGenerator(), [rowGenerator()])
expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME])
})
it("should handle table column renaming", async () => {
const updatedTable = tableGenerator()
updatedTable.schema.attach2 = updatedTable.schema.attach
delete updatedTable.schema.attach
await AttachmentCleanup.tableUpdate(updatedTable, [rowGenerator()], {
oldTable: tableGenerator(),
rename: { old: "attach", updated: "attach2" },
})
expect(mockedDeleteFiles).not.toHaveBeenCalled() expect(mockedDeleteFiles).not.toHaveBeenCalled()
}) })
it("shouldn't cleanup if no table changes", async () => { it(`${attachmentFieldType} - should be able to cleanup a table update`, async () => {
const originalTable = tableGenerator()
delete originalTable.schema[colKey]
const targetRow = rowGenerator()
await AttachmentCleanup.tableUpdate(originalTable, [targetRow], {
oldTable: tableGenerator(),
})
expect(mockedDeleteFiles).toHaveBeenCalledWith(
BUCKET,
getRowKeys(targetRow, colKey)
)
})
it(`${attachmentFieldType} - should be able to cleanup a table deletion`, async () => {
const targetRow = rowGenerator()
await AttachmentCleanup.tableDelete(tableGenerator(), [targetRow])
expect(mockedDeleteFiles).toHaveBeenCalledWith(
BUCKET,
getRowKeys(targetRow, colKey)
)
})
it(`${attachmentFieldType} - should handle table column renaming`, async () => {
const updatedTable = tableGenerator()
updatedTable.schema.col2 = updatedTable.schema[colKey]
delete updatedTable.schema.attach
await AttachmentCleanup.tableUpdate(updatedTable, [rowGenerator()], {
oldTable: tableGenerator(),
rename: { old: colKey, updated: "col2" },
})
expect(mockedDeleteFiles).not.toHaveBeenCalled()
})
it(`${attachmentFieldType} - shouldn't cleanup if no table changes`, async () => {
await AttachmentCleanup.tableUpdate(tableGenerator(), [rowGenerator()], { await AttachmentCleanup.tableUpdate(tableGenerator(), [rowGenerator()], {
oldTable: tableGenerator(), oldTable: tableGenerator(),
}) })
expect(mockedDeleteFiles).not.toHaveBeenCalled() expect(mockedDeleteFiles).not.toHaveBeenCalled()
}) })
it("should handle row updates", async () => { it(`${attachmentFieldType} - should handle row updates`, async () => {
const updatedRow = rowGenerator() const updatedRow = rowGenerator()
delete updatedRow.attach delete updatedRow[colKey]
const targetRow = rowGenerator()
await AttachmentCleanup.rowUpdate(tableGenerator(), { await AttachmentCleanup.rowUpdate(tableGenerator(), {
row: updatedRow, row: updatedRow,
oldRow: rowGenerator(), oldRow: targetRow,
}) })
expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME])
expect(mockedDeleteFiles).toHaveBeenCalledWith(
BUCKET,
getRowKeys(targetRow, colKey)
)
}) })
it("should handle row deletion", async () => { it(`${attachmentFieldType} - should handle row deletion`, async () => {
await AttachmentCleanup.rowDelete(tableGenerator(), [rowGenerator()]) const targetRow = rowGenerator()
expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) await AttachmentCleanup.rowDelete(tableGenerator(), [targetRow])
expect(mockedDeleteFiles).toHaveBeenCalledWith(
BUCKET,
getRowKeys(targetRow, colKey)
)
}) })
it("should handle row deletion and not throw when attachments are undefined", async () => { it(`${attachmentFieldType} - should handle row deletion, prune signature`, async () => {
const targetRow = rowGenerator()
await AttachmentCleanup.rowDelete(tableGenerator(), [targetRow])
expect(mockedDeleteFiles).toHaveBeenCalledWith(
BUCKET,
getRowKeys(targetRow, colKey)
)
})
it(`${attachmentFieldType} - should handle row deletion and not throw when attachments are undefined`, async () => {
await AttachmentCleanup.rowDelete(tableGenerator(), [ await AttachmentCleanup.rowDelete(tableGenerator(), [
{ {
multipleAttachments: undefined, [colKey]: undefined,
}, },
]) ])
}) })
it("shouldn't cleanup attachments if row not updated", async () => { it(`${attachmentFieldType} - shouldn't cleanup attachments if row not updated`, async () => {
const targetRow = rowGenerator()
await AttachmentCleanup.rowUpdate(tableGenerator(), { await AttachmentCleanup.rowUpdate(tableGenerator(), {
row: rowGenerator(), row: targetRow,
oldRow: rowGenerator(), oldRow: targetRow,
}) })
expect(mockedDeleteFiles).not.toHaveBeenCalled() expect(mockedDeleteFiles).not.toHaveBeenCalled()
}) })
it("should be able to cleanup a column and not throw when attachments are undefined", async () => { it(`${attachmentFieldType} - should be able to cleanup a column and not throw when attachments are undefined`, async () => {
const originalTable = tableGenerator() const originalTable = tableGenerator()
delete originalTable.schema["attach"] delete originalTable.schema[colKey]
const row1 = rowGenerator("file 1")
const row2 = rowGenerator("file 2")
await AttachmentCleanup.tableUpdate( await AttachmentCleanup.tableUpdate(
originalTable, originalTable,
[rowGenerator("file 1"), { attach: undefined }, rowGenerator("file 2")], [row1, { [colKey]: undefined }, row2],
{ {
oldTable: tableGenerator(), oldTable: tableGenerator(),
} }
) )
const expectedKeys = [row1, row2].reduce((acc: string[], row) => {
acc = [...acc, ...getRowKeys(row, colKey)]
return acc
}, [])
expect(mockedDeleteFiles).toHaveBeenCalledTimes(1) expect(mockedDeleteFiles).toHaveBeenCalledTimes(1)
expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [ expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, expectedKeys)
"file 1",
"file 2",
])
}) })
it("should be able to cleanup a column and not throw when ALL attachments are undefined", async () => { it(`${attachmentFieldType} - should be able to cleanup a column and not throw when ALL attachments are undefined`, async () => {
const originalTable = tableGenerator() const originalTable = tableGenerator()
delete originalTable.schema["attach"] delete originalTable.schema[colKey]
await AttachmentCleanup.tableUpdate( await AttachmentCleanup.tableUpdate(
originalTable, originalTable,
[{}, { attach: undefined }], [{}, { attach: undefined }],

View file

@ -151,7 +151,8 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
parsedRow[columnName] = parsedValue?._id parsedRow[columnName] = parsedValue?._id
} else if ( } else if (
(columnType === FieldType.ATTACHMENTS || (columnType === FieldType.ATTACHMENTS ||
columnType === FieldType.ATTACHMENT_SINGLE) && columnType === FieldType.ATTACHMENT_SINGLE ||
columnType === FieldType.SIGNATURE_SINGLE) &&
typeof columnData === "string" typeof columnData === "string"
) { ) {
parsedRow[columnName] = parseCsvExport(columnData) parsedRow[columnName] = parseCsvExport(columnData)

View file

@ -15,6 +15,7 @@ const allowDisplayColumnByType: Record<FieldType, boolean> = {
[FieldType.ARRAY]: false, [FieldType.ARRAY]: false,
[FieldType.ATTACHMENTS]: false, [FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false, [FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.SIGNATURE_SINGLE]: false,
[FieldType.LINK]: false, [FieldType.LINK]: false,
[FieldType.JSON]: false, [FieldType.JSON]: false,
[FieldType.BB_REFERENCE]: false, [FieldType.BB_REFERENCE]: false,
@ -33,10 +34,10 @@ const allowSortColumnByType: Record<FieldType, boolean> = {
[FieldType.BIGINT]: true, [FieldType.BIGINT]: true,
[FieldType.BOOLEAN]: true, [FieldType.BOOLEAN]: true,
[FieldType.JSON]: true, [FieldType.JSON]: true,
[FieldType.FORMULA]: false, [FieldType.FORMULA]: false,
[FieldType.ATTACHMENTS]: false, [FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false, [FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.SIGNATURE_SINGLE]: false,
[FieldType.ARRAY]: false, [FieldType.ARRAY]: false,
[FieldType.LINK]: false, [FieldType.LINK]: false,
[FieldType.BB_REFERENCE]: false, [FieldType.BB_REFERENCE]: false,

View file

@ -93,6 +93,11 @@ export enum FieldType {
* type is found. The column will contain the contents of any barcode scanned. * type is found. The column will contain the contents of any barcode scanned.
*/ */
BARCODEQR = "barcodeqr", BARCODEQR = "barcodeqr",
/**
* a JSON type, called Signature within Budibase. This type functions much the same as ATTACHMENTS but restricted
* only to signatures.
*/
SIGNATURE_SINGLE = "signature_single",
/** /**
* a string type, this allows representing very large integers, but they are held/managed within Budibase as * a string type, this allows representing very large integers, but they are held/managed within Budibase as
* strings. When stored in external databases Budibase will attempt to use a real big integer type and depend * strings. When stored in external databases Budibase will attempt to use a real big integer type and depend