1
0
Fork 0
mirror of synced 2024-09-29 16:51:33 +13:00

Merge branch 'master' into BUDI-7641/trigger-new-pipeline

This commit is contained in:
Adria Navarro 2023-11-06 12:14:53 +01:00 committed by GitHub
commit 00b9cdb627
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 981 additions and 654 deletions

View file

@ -17,6 +17,6 @@ jobs:
token: ${{ secrets.GH_ACCESS_TOKEN }}
client-payload: |-
{
"PAYLOAD_VERSION": "${{ github.sha }}",
"VERSION": "${{ github.sha }}",
"REF_NAME": "${{ github.ref_name}}"
}

View file

@ -169,10 +169,10 @@ jobs:
- uses: peter-evans/repository-dispatch@v2
with:
repository: budibase/budibase-deploys
event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}
event-type: budicloud-qa-deploy
token: ${{ secrets.GH_ACCESS_TOKEN }}
client-payload: |-
{
"PAYLOAD_VERSION": "${{ github.ref_name }}",
"VERSION": "${{ github.ref_name }}",
"REF_NAME": "${{ github.ref_name}}"
}

View file

@ -77,7 +77,7 @@ mkdir -p ${DATA_DIR}/minio
chown -R couchdb:couchdb ${DATA_DIR}/couch
redis-server --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 &
/bbcouch-runner.sh &
minio server --console-address ":9001" ${DATA_DIR}/minio > /dev/stdout 2>&1 &
/minio/minio server --console-address ":9001" ${DATA_DIR}/minio > /dev/stdout 2>&1 &
/etc/init.d/nginx restart
if [[ ! -z "${CUSTOM_DOMAIN}" ]]; then
# Add monthly cron job to renew certbot certificate

View file

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

View file

@ -30,7 +30,6 @@ export * as timers from "./timers"
export { default as env } from "./environment"
export * as blacklist from "./blacklist"
export * as docUpdates from "./docUpdates"
export * from "./utils/Duration"
export { SearchParams } from "./db"
// Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal

View file

@ -18,8 +18,12 @@ export const ObjectStoreBuckets = {
}
const bbTmp = join(tmpdir(), ".budibase")
if (!fs.existsSync(bbTmp)) {
try {
fs.mkdirSync(bbTmp)
} catch (e: any) {
if (e.code !== "EEXIST") {
throw e
}
}
export function budibaseTempDir() {

View file

@ -36,7 +36,7 @@ class InMemoryQueue {
* @param opts This is not used by the in memory queue as there is no real use
* case when in memory, but is the same API as Bull
*/
constructor(name: string, opts?: any) {
constructor(name: string, opts = null) {
this._name = name
this._opts = opts
this._messages = []

View file

@ -2,18 +2,11 @@ import env from "../environment"
import { getRedisOptions } from "../redis/utils"
import { JobQueue } from "./constants"
import InMemoryQueue from "./inMemoryQueue"
import BullQueue, { QueueOptions } from "bull"
import BullQueue from "bull"
import { addListeners, StalledFn } from "./listeners"
import { Duration } from "../utils"
import * as timers from "../timers"
import * as Redis from "ioredis"
// the queue lock is held for 5 minutes
const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
// queue lock is refreshed every 30 seconds
const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs()
// cleanup the queue every 60 seconds
const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs()
const CLEANUP_PERIOD_MS = 60 * 1000
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
let cleanupInterval: NodeJS.Timeout
@ -28,14 +21,7 @@ export function createQueue<T>(
opts: { removeStalledCb?: StalledFn } = {}
): BullQueue.Queue<T> {
const { opts: redisOpts, redisProtocolUrl } = getRedisOptions()
const queueConfig: QueueOptions = {
redis: redisProtocolUrl! || (redisOpts as Redis.RedisOptions),
settings: {
maxStalledCount: 0,
lockDuration: QUEUE_LOCK_MS,
lockRenewTime: QUEUE_LOCK_RENEW_INTERNAL_MS,
},
}
const queueConfig: any = redisProtocolUrl || { redis: redisOpts }
let queue: any
if (!env.isTest()) {
queue = new BullQueue(jobQueue, queueConfig)

View file

@ -1,49 +0,0 @@
export enum DurationType {
MILLISECONDS = "milliseconds",
SECONDS = "seconds",
MINUTES = "minutes",
HOURS = "hours",
DAYS = "days",
}
const conversion: Record<DurationType, number> = {
milliseconds: 1,
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
}
export class Duration {
static convert(from: DurationType, to: DurationType, duration: number) {
const milliseconds = duration * conversion[from]
return milliseconds / conversion[to]
}
static from(from: DurationType, duration: number) {
return {
to: (to: DurationType) => {
return Duration.convert(from, to, duration)
},
toMs: () => {
return Duration.convert(from, DurationType.MILLISECONDS, duration)
},
}
}
static fromSeconds(duration: number) {
return Duration.from(DurationType.SECONDS, duration)
}
static fromMinutes(duration: number) {
return Duration.from(DurationType.MINUTES, duration)
}
static fromHours(duration: number) {
return Duration.from(DurationType.HOURS, duration)
}
static fromDays(duration: number) {
return Duration.from(DurationType.DAYS, duration)
}
}

View file

@ -1,4 +1,3 @@
export * from "./hashing"
export * from "./utils"
export * from "./stringUtils"
export * from "./Duration"

View file

@ -1,19 +0,0 @@
import { Duration, DurationType } from "../Duration"
describe("duration", () => {
it("should convert minutes to milliseconds", () => {
expect(Duration.fromMinutes(5).toMs()).toBe(300000)
})
it("should convert seconds to milliseconds", () => {
expect(Duration.fromSeconds(30).toMs()).toBe(30000)
})
it("should convert days to milliseconds", () => {
expect(Duration.fromDays(1).toMs()).toBe(86400000)
})
it("should convert minutes to days", () => {
expect(Duration.fromMinutes(1440).to(DurationType.DAYS)).toBe(1)
})
})

View file

@ -6,3 +6,4 @@ release/
dist/
routify
.routify/
svelte.config.js

View file

@ -3,6 +3,7 @@ import { API } from "api"
import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { selectedAutomation } from "builderStore"
import { notifications } from "@budibase/bbui"
const initialAutomationState = {
automations: [],
@ -21,6 +22,37 @@ export const getAutomationStore = () => {
return store
}
const updateReferencesInObject = (obj, modifiedIndex, action) => {
const regex = /{{\s*steps\.(\d+)\./g
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = regex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (action === "add" && referencedStep >= modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep + 1}.`
)
} else if (action === "delete" && referencedStep > modifiedIndex) {
obj[key] = obj[key].replace(
`{{ steps.${referencedStep}.`,
`{{ steps.${referencedStep - 1}.`
)
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject(obj[key], modifiedIndex, action)
}
}
}
const updateStepReferences = (steps, modifiedIndex, action) => {
steps.forEach(step => {
updateReferencesInObject(step.inputs, modifiedIndex, action)
})
}
const automationActions = store => ({
definitions: async () => {
const response = await API.getAutomationDefinitions()
@ -218,10 +250,40 @@ const automationActions = store => ({
if (!automation) {
return
}
try {
updateStepReferences(newAutomation.definition.steps, blockIdx, "add")
} catch (e) {
notifications.error("Error adding automation block")
}
newAutomation.definition.steps.splice(blockIdx, 0, block)
await store.actions.save(newAutomation)
},
deleteAutomationBlock: async block => {
saveAutomationName: async (blockId, name) => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
if (!automation) {
return
}
newAutomation.definition.stepNames = {
...newAutomation.definition.stepNames,
[blockId]: name.trim(),
}
await store.actions.save(newAutomation)
},
deleteAutomationName: async blockId => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
if (!automation) {
return
}
delete newAutomation.definition.stepNames[blockId]
await store.actions.save(newAutomation)
},
deleteAutomationBlock: async (block, blockIdx) => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
@ -233,7 +295,14 @@ const automationActions = store => ({
newAutomation.definition.steps = newAutomation.definition.steps.filter(
step => step.id !== block.id
)
delete newAutomation.definition.stepNames?.[block.id]
}
try {
updateStepReferences(newAutomation.definition.steps, blockIdx, "delete")
} catch (e) {
notifications.error("Error deleting automation block")
}
await store.actions.save(newAutomation)
},
replace: async (automationId, automation) => {

View file

@ -5,13 +5,7 @@
import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate"
import { fly } from "svelte/transition"
import {
Heading,
Icon,
ActionButton,
notifications,
Modal,
} from "@budibase/bbui"
import { Icon, notifications, Modal } from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { automationHistoryStore } from "builderStore"
@ -20,9 +14,8 @@
let testDataModal
let confirmDeleteDialog
$: blocks = getBlocks(automation)
let scrolling = false
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP)
const getBlocks = automation => {
let blocks = []
if (automation.definition.trigger) {
@ -32,58 +25,72 @@
return blocks
}
async function deleteAutomation() {
const deleteAutomation = async () => {
try {
await automationStore.actions.delete($selectedAutomation)
} catch (error) {
notifications.error("Error deleting automation")
}
}
const handleScroll = e => {
if (e.target.scrollTop >= 30) {
scrolling = true
} else if (e.target.scrollTop) {
// Set scrolling back to false if scrolled back to less than 100px
scrolling = false
}
}
</script>
<div class="canvas">
<div class="header">
<Heading size="S">{automation.name}</Heading>
<div class="controls">
<UndoRedoControl store={automationHistoryStore} />
<div class="header" class:scrolling>
<div class="header-left">
<UndoRedoControl store={automationHistoryStore} />
</div>
<div class="controls">
<div class="buttons">
<Icon hoverable size="M" name="Play" />
<div
on:click={() => {
testDataModal.show()
}}
>
Run test
</div>
</div>
<div class="buttons">
<Icon
on:click={confirmDeleteDialog.show}
disabled={!$automationStore.testResults}
hoverable
size="M"
name="DeleteOutline"
name="Multiple"
/>
<div class="buttons">
<ActionButton
on:click={() => {
testDataModal.show()
}}
icon="MultipleCheck"
size="M">Run test</ActionButton
>
<ActionButton
disabled={!$automationStore.testResults}
on:click={() => {
$automationStore.showTestPanel = true
}}
size="M">Test Details</ActionButton
>
<div
class:disabled={!$automationStore.testResults}
on:click={() => {
$automationStore.showTestPanel = true
}}
>
Test details
</div>
</div>
</div>
</div>
<div class="content">
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 500 }}
in:fly={{ x: 500, duration: 500 }}
out:fly|local={{ x: 500, duration: 500 }}
>
{#if block.stepId !== ActionStepID.LOOP}
<FlowItem {testDataModal} {block} {idx} />
{/if}
</div>
{/each}
<div class="canvas" on:scroll={handleScroll}>
<div class="content">
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 500 }}
in:fly={{ x: 500, duration: 500 }}
out:fly|local={{ x: 500, duration: 500 }}
>
{#if block.stepId !== ActionStepID.LOOP}
<FlowItem {testDataModal} {block} {idx} />
{/if}
</div>
{/each}
</div>
</div>
<ConfirmDialog
bind:this={confirmDeleteDialog}
@ -103,6 +110,12 @@
<style>
.canvas {
padding: var(--spacing-l) var(--spacing-xl);
overflow-y: auto;
max-height: 100%;
}
.header-left :global(div) {
border-right: none;
}
/* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child {
@ -117,23 +130,45 @@
}
.content {
display: inline-block;
text-align: left;
flex-grow: 1;
padding: 23px 23px 80px;
box-sizing: border-box;
}
.header.scrolling {
background: var(--background);
border-bottom: var(--border-light);
border-left: var(--border-light);
z-index: 1;
}
.header {
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: var(--spacing-l);
transition: background 130ms ease-out;
flex: 0 0 48px;
padding-right: var(--spacing-xl);
}
.controls {
display: flex;
gap: var(--spacing-xl);
}
.controls,
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.buttons {
gap: var(--spacing-s);
}
.buttons:hover {
cursor: pointer;
}
.disabled {
pointer-events: none;
color: var(--spectrum-global-color-gray-500) !important;
}
</style>

View file

@ -7,20 +7,16 @@
Detail,
Modal,
Button,
ActionButton,
notifications,
Label,
AbsTooltip,
} from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import {
ActionStepID,
TriggerStepID,
Features,
} from "constants/backend/automations"
import { ActionStepID, TriggerStepID } from "constants/backend/automations"
import { permissions } from "stores/backend"
export let block
@ -86,7 +82,7 @@
if (loopBlock) {
await automationStore.actions.deleteAutomationBlock(loopBlock)
}
await automationStore.actions.deleteAutomationBlock(block)
await automationStore.actions.deleteAutomationBlock(block, blockIdx)
} catch (error) {
notifications.error("Error saving automation")
}
@ -129,6 +125,10 @@
</div>
<div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping">
<Icon on:click={removeLooping} hoverable name="DeleteOutline" />
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} />
</div>
@ -139,9 +139,6 @@
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<div class="block-options">
<ActionButton on:click={() => removeLooping()} icon="DeleteOutline" />
</div>
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
@ -162,31 +159,19 @@
{block}
{testDataModal}
{idx}
{addLooping}
{deleteStep}
on:toggle={() => (open = !open)}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if !isTrigger}
<div>
<div class="block-options">
{#if !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>
{/if}
<ActionButton
on:click={() => deleteStep()}
icon="DeleteOutline"
/>
</div>
</div>
{/if}
{#if isAppAction}
<Label>Role</Label>
<RoleSelect bind:value={role} />
<div>
<Label>Role</Label>
<RoleSelect bind:value={role} />
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(block.schema.inputs.properties)}
@ -270,5 +255,6 @@
.blockTitle {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style>

View file

@ -1,8 +1,9 @@
<script>
import { automationStore } from "builderStore"
import { Icon, Body, Detail, StatusLight } from "@budibase/bbui"
import { automationStore, selectedAutomation } from "builderStore"
import { Icon, Body, StatusLight, AbsTooltip } from "@budibase/bbui"
import { externalActions } from "./ExternalActions"
import { createEventDispatcher } from "svelte"
import { Features } from "constants/backend/automations"
export let block
export let open
@ -10,9 +11,20 @@
export let testResult
export let isTrigger
export let idx
export let addLooping
export let deleteStep
let validRegex = /^[A-Za-z0-9_\s]+$/
let typing = false
const dispatch = createEventDispatcher()
$: stepNames = $selectedAutomation.definition.stepNames
$: automationName = stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult, isTrigger)
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
$: {
if (!testResult) {
testResult = $automationStore.testResults?.steps?.filter(step =>
@ -20,8 +32,9 @@
)?.[0]
}
}
$: isTrigger = isTrigger || block.type === "TRIGGER"
$: status = updateStatus(testResult, isTrigger)
$: loopBlock = $selectedAutomation.definition.steps.find(
x => x.blockToLoop === block?.id
)
async function onSelect(block) {
await automationStore.update(state => {
@ -43,10 +56,49 @@
return { negative: true, message: "Error" }
}
}
const getAutomationNameError = name => {
if (stepNames) {
for (const [key, value] of Object.entries(stepNames)) {
if (name === value && key !== block.id) {
return "This name already exists, please enter a unique name"
}
}
}
if (name !== block.name && name?.length > 0) {
let invalidRoleName = !validRegex.test(name)
if (invalidRoleName) {
return "Please enter a role name consisting of only alphanumeric symbols and underscores"
}
return null
}
}
const startTyping = async () => {
typing = true
}
const saveName = async () => {
if (automationNameError || block.name === automationName) {
return
}
if (automationName.length === 0) {
await automationStore.actions.deleteAutomationName(block.id)
} else {
await automationStore.actions.saveAutomationName(block.id, automationName)
}
}
</script>
<div class="blockSection">
<div on:click={() => dispatch("toggle")} class="splitHeader">
<div
class:typing={typing && !automationNameError}
class:typing-error={automationNameError}
class="blockSection"
>
<div class="splitHeader">
<div class="center-items">
{#if externalActions[block.stepId]}
<img
@ -67,40 +119,104 @@
</svg>
{/if}
<div class="iconAlign">
{#if isTrigger}
{#if isHeaderTrigger}
<Body size="XS"><b>Trigger</b></Body>
<Body size="XS">When this happens:</Body>
{:else}
<Body size="XS"><b>Step {idx}</b></Body>
<Body size="XS">Do this:</Body>
<div style="margin-left: 2px;">
<Body size="XS"><b>Step {idx}</b></Body>
</div>
{/if}
<Detail size="S">{block?.name?.toUpperCase() || ""}</Detail>
<input
placeholder="Enter some text"
name="name"
autocomplete="off"
value={automationName}
on:input={e => {
automationName = e.target.value.trim()
}}
on:click={startTyping}
on:blur={async () => {
typing = false
if (automationNameError) {
automationName = stepNames[block.id] || block?.name
} else {
await saveName()
}
}}
/>
</div>
</div>
<div class="blockTitle">
{#if showTestStatus && testResult}
<div style="float: right;">
<StatusLight
positive={status?.positive}
yellow={status?.yellow}
negative={status?.negative}
><Body size="XS">{status?.message}</Body></StatusLight
>
<div class="status-container">
<div style="float:right;">
<StatusLight
positive={status?.positive}
yellow={status?.yellow}
negative={status?.negative}
>
<Body size="XS">{status?.message}</Body>
</StatusLight>
</div>
<Icon
on:click={() => dispatch("toggle")}
hoverable
name={open ? "ChevronUp" : "ChevronDown"}
/>
</div>
{/if}
<div
style="margin-left: 10px; margin-bottom: var(--spacing-xs);"
class="context-actions"
class:hide-context-actions={typing}
on:click={() => {
onSelect(block)
}}
>
<Icon hoverable name={open ? "ChevronUp" : "ChevronDown"} />
{#if !showTestStatus}
{#if !isHeaderTrigger && !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)}
<AbsTooltip type="info" text="Add looping">
<Icon on:click={addLooping} hoverable name="RotateCW" />
</AbsTooltip>
{/if}
<AbsTooltip type="negative" text="Delete step">
<Icon on:click={deleteStep} hoverable name="DeleteOutline" />
</AbsTooltip>
{/if}
{#if !showTestStatus}
<Icon
on:click={() => dispatch("toggle")}
hoverable
name={open ? "ChevronUp" : "ChevronDown"}
/>
{/if}
</div>
{#if automationNameError}
<div class="error-container">
<AbsTooltip type="negative" text={automationNameError}>
<div class="error-icon">
<Icon size="S" name="Alert" />
</div>
</AbsTooltip>
</div>
{/if}
</div>
</div>
</div>
<style>
.status-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-m);
/* You can also add padding or margin to adjust the spacing between the text and the chevron if needed. */
}
.context-actions {
display: flex;
gap: var(--spacing-l);
margin-bottom: var(--spacing-xs);
}
.center-items {
display: flex;
align-items: center;
@ -117,10 +233,55 @@
.blockSection {
padding: var(--spacing-xl);
border: 1px solid transparent;
}
.blockTitle {
display: flex;
align-items: center;
}
.hide-context-actions {
display: none;
}
input {
font-family: var(--font-sans);
color: var(--ink);
background-color: transparent;
border: 1px solid transparent;
font-size: var(--spectrum-alias-font-size-default);
width: 230px;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
input:focus {
outline: none;
}
/* Hide arrows for number fields */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.typing {
border: 1px solid var(--spectrum-global-color-static-blue-500);
border-radius: 4px 4px 4px 4px;
}
.typing-error {
border: 1px solid var(--spectrum-global-color-static-red-500);
border-radius: 4px 4px 4px 4px;
}
.error-icon :global(.spectrum-Icon) {
fill: var(--spectrum-global-color-red-400);
}
.error-container {
padding-top: var(--spacing-xl);
}
</style>

View file

@ -60,6 +60,7 @@
<ModalContent
title="Add test data"
confirmText="Test"
size="M"
showConfirmButton={true}
disabled={isError}
onConfirm={testAutomation}

View file

@ -58,7 +58,6 @@
let fillWidth = true
let inputData
let codeBindingOpen = false
$: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters
$: stepId = block.stepId
@ -155,7 +154,7 @@
}
let blockIdx = allSteps.findIndex(step => step.id === block.id)
// Extract all outputs from all previous steps as available bindins
// Extract all outputs from all previous steps as available bindingsx§x
let bindings = []
let loopBlockCount = 0
for (let idx = 0; idx < blockIdx; idx++) {
@ -183,20 +182,19 @@
}
}
const outputs = Object.entries(schema)
let bindingIcon = ""
let bindindingRank = 0
let bindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindindingRank = idx + 1
bindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindindingRank = idx - loopBlockCount
bindingRank = idx - loopBlockCount
}
let bindingName =
automation.stepNames?.[allSteps[idx - loopBlockCount].id]
bindings = bindings.concat(
outputs.map(([name, value]) => {
let runtimeName = isLoopBlock
@ -205,14 +203,20 @@
? `steps[${idx - loopBlockCount}].${name}`
: `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
const categoryName =
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`
let categoryName
if (idx === 0) {
categoryName = "Trigger outputs"
} else if (isLoopBlock) {
categoryName = "Loop Outputs"
} else if (bindingName) {
categoryName = `${bindingName} outputs`
} else {
categoryName = `Step ${idx - loopBlockCount} outputs`
}
return {
readableBinding: runtime,
readableBinding: bindingName ? `${bindingName}.${name}` : runtime,
runtimeBinding: runtime,
type: value.type,
description: value.description,
@ -221,7 +225,7 @@
display: {
type: value.type,
name: name,
rank: bindindingRank,
rank: bindingRank,
},
}
})
@ -277,6 +281,16 @@
return !dependsOn || !!inputData[dependsOn]
}
function shouldRenderField(value) {
return (
value.customType !== "row" &&
value.customType !== "code" &&
value.customType !== "queryParams" &&
value.customType !== "cron" &&
value.customType !== "triggerSchema"
)
}
onMount(async () => {
try {
await environment.loadVariables()
@ -289,245 +303,248 @@
<div class="fields">
{#each schemaProperties as [key, value]}
{#if canShowField(key, value)}
<div class="block-field">
{#if key !== "fields" && value.type !== "boolean"}
<div class:block-field={shouldRenderField(value)}>
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
<Label
tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label
>
{/if}
{#if value.type === "string" && value.enum && canShowField(key, value)}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
onChange(e, key)
}}
/>
{:else if value.type === "boolean"}
<div style="margin-top: 10px">
<Checkbox
text={value.title}
value={inputData[key]}
<div class:field-width={shouldRenderField(value)}>
{#if value.type === "string" && value.enum && canShowField(key, value)}
<Select
on:change={e => onChange(e, key)}
/>
</div>
{:else if value.type === "date"}
<DrawerBindableSlot
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type={"date"}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) =>
value.pretty ? value.pretty[idx] : x}
/>
</DrawerBindableSlot>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
onChange(e, key)
}}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput
{:else if value.type === "boolean"}
<div style="margin-top: 10px">
<Checkbox
text={value.title}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</div>
{:else if value.type === "date"}
<DrawerBindableSlot
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type="email"
type={"date"}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
/>
{/if}
{:else if value.customType === "query"}
<QuerySelector
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "code"}
<CodeEditorModal>
{#if codeMode == EditorModes.JS}
<ActionButton
on:click={() => (codeBindingOpen = !codeBindingOpen)}
quiet
icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Bindings</Detail>
</ActionButton>
{#if codeBindingOpen}
<pre>{JSON.stringify(bindings, null, 2)}</pre>
{/if}
{/if}
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={stepCompletions}
mode={codeMode}
autocompleteEnabled={codeMode != EditorModes.JS}
height={500}
/>
<div class="messaging">
{#if codeMode == EditorModes.Handlebars}
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing <strong>
&#125;&#125;
</strong>
</div>
</div>
{/if}
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
>
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</DrawerBindableSlot>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else}
<div class="test">
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput
fillWidth={true}
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type={value.customType}
type="email"
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
updateOnChange={false}
placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px"
/>
</div>
{/if}
{:else if value.customType === "query"}
<QuerySelector
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "code"}
<CodeEditorModal>
{#if codeMode == EditorModes.JS}
<ActionButton
on:click={() => (codeBindingOpen = !codeBindingOpen)}
quiet
icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Bindings</Detail>
</ActionButton>
{#if codeBindingOpen}
<pre>{JSON.stringify(bindings, null, 2)}</pre>
{/if}
{/if}
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={stepCompletions}
mode={codeMode}
autocompleteEnabled={codeMode != EditorModes.JS}
height={500}
/>
<div class="messaging">
{#if codeMode == EditorModes.Handlebars}
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing <strong>
&#125;&#125;
</strong>
</div>
</div>
{/if}
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
/>
{:else}
<div class="test">
<DrawerBindableInput
fillWidth={true}
title={value.title}
panel={AutomationBindingPanel}
type={value.customType}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px"
/>
</div>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
{/each}
@ -541,6 +558,10 @@
{/if}
<style>
.field-width {
width: 320px;
}
.messaging {
display: flex;
align-items: center;
@ -555,8 +576,13 @@
}
.block-field {
display: grid;
grid-gap: 5px;
display: flex; /* Use Flexbox */
justify-content: space-between;
align-items: center;
flex-direction: row; /* Arrange label and field side by side */
align-items: center; /* Align vertically in the center */
gap: 10px; /* Add some space between label and field */
flex: 1;
}
.test :global(.drawer) {

View file

@ -23,7 +23,9 @@
</div>
</ModalContent>
</Modal>
<Button primary on:click={show}>Edit Code</Button>
<div class="center">
<Button primary on:click={show}>Edit Code</Button>
</div>
<style>
.container :global(section > header) {
@ -33,4 +35,9 @@
.container :global(textarea) {
min-height: 60px;
}
.center {
display: flex;
justify-content: center;
}
</style>

View file

@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from "svelte"
import { queries } from "stores/backend"
import { Select } from "@budibase/bbui"
import { Select, Label } from "@budibase/bbui"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
@ -27,41 +27,55 @@
$: if (value?.queryId == null) value = { queryId: "" }
</script>
<div class="block-field">
<Select
label="Query"
on:change={onChangeQuery}
value={value.queryId}
options={$queries.list}
getOptionValue={query => query._id}
getOptionLabel={query => query.name}
/>
<div class="schema-fields">
<Label>Query</Label>
<div class="field-width">
<Select
on:change={onChangeQuery}
value={value.queryId}
options={$queries.list}
getOptionValue={query => query._id}
getOptionLabel={query => query.name}
/>
</div>
</div>
{#if parameters.length}
<div class="schema-fields">
{#each parameters as field}
<DrawerBindableInput
panel={AutomationBindingPanel}
extraThin
value={value[field.name]}
on:change={e => onChange(e, field)}
label={field.name}
type="string"
{bindings}
fillWidth={true}
updateOnChange={false}
/>
<Label>{field.name}</Label>
<div class="field-width">
<DrawerBindableInput
panel={AutomationBindingPanel}
extraThin
value={value[field.name]}
on:change={e => onChange(e, field)}
type="string"
{bindings}
fillWidth={true}
updateOnChange={false}
/>
</div>
{/each}
</div>
{/if}
<style>
.schema-fields {
display: grid;
grid-gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
.field-width {
width: 320px;
}
.schema-fields {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-fields :global(label) {
text-transform: capitalize;
}

View file

@ -1,10 +1,11 @@
<script>
import { tables } from "stores/backend"
import { Select, Checkbox } from "@budibase/bbui"
import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { TableNames } from "constants"
const dispatch = createEventDispatcher()
@ -99,41 +100,25 @@
$: if (value?.tableId == null) value = { tableId: "" }
</script>
<Select
on:change={onChangeTable}
value={value.tableId}
options={$tables.list}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
<div class="schema-fields">
<Label>Table</Label>
<div class="field-width">
<Select
on:change={onChangeTable}
value={value.tableId}
options={$tables.list.filter(table => table._id !== TableNames.USERS)}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
</div>
</div>
{#if schemaFields.length}
<div class="schema-fields">
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"}
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
/>
{:else}
<DrawerBindableSlot
fillWidth
title={value.title}
label={field}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={value[field]}
on:change={e => onChange(e, field)}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
{#each schemaFields as [field, schema]}
<div class="schema-fields">
<Label>{field}</Label>
<div class="field-width">
{#if !schema.autocolumn && schema.type !== "attachment"}
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
@ -142,28 +127,61 @@
{value}
{onChange}
/>
</DrawerBindableSlot>
{:else}
<DrawerBindableSlot
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={value[field]}
on:change={e => onChange(e, field)}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
/>
</DrawerBindableSlot>
{/if}
{/if}
{/if}
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
/>
</div>
{/if}
{/each}
</div>
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
/>
</div>
{/if}
</div>
</div>
{/each}
{/if}
<style>
.field-width {
width: 320px;
}
.schema-fields {
display: grid;
grid-gap: var(--spacing-s);
margin-top: var(--spacing-s);
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-fields :global(label) {
text-transform: capitalize;

View file

@ -1,11 +1,5 @@
<script>
import {
Select,
DatePicker,
Multiselect,
TextArea,
Label,
} from "@budibase/bbui"
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
@ -33,20 +27,14 @@
{#if schemaHasOptions(schema) && schema.type !== "array"}
<Select
on:change={e => onChange(e, field)}
label={field}
value={value[field]}
options={schema.constraints.inclusion}
/>
{:else if schema.type === "datetime"}
<DatePicker
label={field}
value={value[field]}
on:change={e => onChange(e, field)}
/>
<DatePicker value={value[field]} on:change={e => onChange(e, field)} />
{:else if schema.type === "boolean"}
<Select
on:change={e => onChange(e, field)}
label={field}
value={value[field]}
options={[
{ label: "True", value: "true" },
@ -56,19 +44,13 @@
{:else if schema.type === "array"}
<Multiselect
bind:value={value[field]}
label={field}
options={schema.constraints.inclusion}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "longform"}
<TextArea
label={field}
bind:value={value[field]}
on:change={e => onChange(e, field)}
/>
<TextArea bind:value={value[field]} on:change={e => onChange(e, field)} />
{:else if schema.type === "json"}
<span>
<Label>{field}</Label>
<Editor
editorHeight="150"
mode="json"
@ -92,7 +74,6 @@
panel={AutomationBindingPanel}
value={value[field]}
on:change={e => onChange(e, field)}
label={field}
type="string"
bindings={parsedBindings}
fillWidth={true}

View file

@ -22,7 +22,7 @@
<Select
on:change={onChange}
bind:value
options={filteredTables}
options={filteredTables.filter(table => table._id !== TableNames.USERS)}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>

View file

@ -30,15 +30,15 @@
part2: PrettyRelationshipDefinitions.MANY,
},
[RelationshipType.MANY_TO_ONE]: {
part1: PrettyRelationshipDefinitions.ONE,
part2: PrettyRelationshipDefinitions.MANY,
part1: PrettyRelationshipDefinitions.MANY,
part2: PrettyRelationshipDefinitions.ONE,
},
}
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
let relationshipPart1 = PrettyRelationshipDefinitions.MANY
let relationshipPart2 = PrettyRelationshipDefinitions.ONE
let relationshipPart1 = PrettyRelationshipDefinitions.ONE
let relationshipPart2 = PrettyRelationshipDefinitions.MANY
let originalFromColumnName = toRelationship.name,
originalToColumnName = fromRelationship.name

View file

@ -206,12 +206,12 @@
.text-area-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 26px !important;
top: 1px !important;
}
.json-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 23px !important;
top: 1px !important;
right: 0px !important;
}

View file

@ -91,7 +91,6 @@
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-l);
overflow: auto;
}
.centered {

View file

@ -1,27 +1,10 @@
<script context="module">
// We can create a module level cache for all relationship cells to avoid
// having to fetch the table definition one time for each cell
let primaryDisplayCache = {}
const getPrimaryDisplayForTableId = async (API, tableId) => {
if (primaryDisplayCache[tableId]) {
return primaryDisplayCache[tableId]
}
const definition = await API.fetchTableDefinition(tableId)
const primaryDisplay =
definition?.primaryDisplay || definition?.schema?.[0]?.name
primaryDisplayCache[tableId] = primaryDisplay
return primaryDisplay
}
</script>
<script>
import { getColor } from "../lib/utils"
import { onMount, getContext } from "svelte"
import { Icon, Input, ProgressCircle, clickOutside } from "@budibase/bbui"
import { debounce } from "../../../utils/utils"
const { API, dispatch } = getContext("grid")
const { API, dispatch, cache } = getContext("grid")
export let value
export let api
@ -147,7 +130,9 @@
// Find the primary display for the related table
if (!primaryDisplay) {
searching = true
primaryDisplay = await getPrimaryDisplayForTableId(API, schema.tableId)
primaryDisplay = await cache.actions.getPrimaryDisplayForTableId(
schema.tableId
)
}
// Show initial list of results
@ -195,7 +180,7 @@
const toggleRow = async row => {
if (value?.some(x => x._id === row._id)) {
// If the row is already included, remove it and update the candidate
// row to be the the same position if possible
// row to be the same position if possible
if (oneRowOnly) {
await onChange([])
} else {
@ -260,31 +245,29 @@
class:wrap={editable || contentLines > 1}
on:wheel={e => (focused ? e.stopPropagation() : null)}
>
{#if Array.isArray(value) && value.length}
{#each value as relationship}
{#if relationship[primaryDisplay] || relationship.primaryDisplay}
<div class="badge">
<span
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable(
relationship[primaryDisplay] || relationship.primaryDisplay
)}
</span>
{#if editable}
<Icon
name="Close"
size="XS"
hoverable
on:click={() => toggleRow(relationship)}
/>
{/if}
</div>
{/if}
{/each}
{/if}
{#each value || [] as relationship}
{#if relationship[primaryDisplay] || relationship.primaryDisplay}
<div class="badge">
<span
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable(
relationship[primaryDisplay] || relationship.primaryDisplay
)}
</span>
{#if editable}
<Icon
name="Close"
size="XS"
hoverable
on:click={() => toggleRow(relationship)}
/>
{/if}
</div>
{/if}
{/each}
{#if editable}
<div class="add" on:click={open}>
<Icon name="Add" size="S" />
@ -320,7 +303,7 @@
<div class="searching">
<ProgressCircle size="S" />
</div>
{:else if Array.isArray(searchResults) && searchResults.length}
{:else if searchResults?.length}
<div class="results">
{#each searchResults as row, idx}
<div

View file

@ -0,0 +1,47 @@
export const createActions = context => {
const { API } = context
// Cache for the primary display columns of different tables.
// If we ever need to cache table definitions for other purposes then we can
// expand this to be a more generic cache.
let primaryDisplayCache = {}
const resetPrimaryDisplayCache = () => {
primaryDisplayCache = {}
}
const getPrimaryDisplayForTableId = async tableId => {
// If we've never encountered this tableId before then store a promise that
// resolves to the primary display so that subsequent invocations before the
// promise completes can reuse this promise
if (!primaryDisplayCache[tableId]) {
primaryDisplayCache[tableId] = new Promise(resolve => {
API.fetchTableDefinition(tableId).then(def => {
const display = def?.primaryDisplay || def?.schema?.[0]?.name
primaryDisplayCache[tableId] = display
resolve(display)
})
})
}
// We await the result so that we account for both promises and primitives
return await primaryDisplayCache[tableId]
}
return {
cache: {
actions: {
getPrimaryDisplayForTableId,
resetPrimaryDisplayCache,
},
},
}
}
export const initialise = context => {
const { datasource, cache } = context
// Wipe the caches whenever the datasource changes to ensure we aren't
// storing any stale information
datasource.subscribe(cache.actions.resetPrimaryDisplayCache)
}

View file

@ -160,11 +160,6 @@ export const createActions = context => {
return getAPI()?.actions.canUseColumn(name)
}
// Gets the default number of rows for a single page
const getFeatures = () => {
return getAPI()?.actions.getFeatures()
}
return {
datasource: {
...datasource,
@ -177,7 +172,6 @@ export const createActions = context => {
getRow,
isDatasourceValid,
canUseColumn,
getFeatures,
},
},
}

View file

@ -35,11 +35,6 @@ export const createActions = context => {
return $columns.some(col => col.name === name) || $sticky?.name === name
}
const getFeatures = () => {
// We don't support any features
return {}
}
return {
nonPlus: {
actions: {
@ -50,7 +45,6 @@ export const createActions = context => {
getRow,
isDatasourceValid,
canUseColumn,
getFeatures,
},
},
}

View file

@ -1,5 +1,4 @@
import { get } from "svelte/store"
import TableFetch from "../../../../fetch/TableFetch"
const SuppressErrors = true
@ -46,10 +45,6 @@ export const createActions = context => {
return $columns.some(col => col.name === name) || $sticky?.name === name
}
const getFeatures = () => {
return new TableFetch({ API }).determineFeatureFlags()
}
return {
table: {
actions: {
@ -60,7 +55,6 @@ export const createActions = context => {
getRow,
isDatasourceValid,
canUseColumn,
getFeatures,
},
},
}

View file

@ -1,5 +1,4 @@
import { get } from "svelte/store"
import ViewV2Fetch from "../../../../fetch/ViewV2Fetch"
const SuppressErrors = true
@ -46,10 +45,6 @@ export const createActions = context => {
)
}
const getFeatures = () => {
return new ViewV2Fetch({ API }).determineFeatureFlags()
}
return {
viewV2: {
actions: {
@ -60,7 +55,6 @@ export const createActions = context => {
getRow,
isDatasourceValid,
canUseColumn,
getFeatures,
},
},
}

View file

@ -19,6 +19,7 @@ import * as Datasource from "./datasource"
import * as Table from "./datasources/table"
import * as ViewV2 from "./datasources/viewV2"
import * as NonPlus from "./datasources/nonPlus"
import * as Cache from "./cache"
const DependencyOrderedStores = [
Sort,
@ -42,6 +43,7 @@ const DependencyOrderedStores = [
Clipboard,
Config,
Notifications,
Cache,
]
export const attachStores = context => {

View file

@ -114,10 +114,6 @@ export const createActions = context => {
const $allFilters = get(allFilters)
const $sort = get(sort)
// Determine how many rows to fetch per page
const features = datasource.actions.getFeatures()
const limit = features?.supportsPagination ? RowPageSize : null
// Create new fetch model
const newFetch = fetchData({
API,
@ -126,8 +122,12 @@ export const createActions = context => {
filter: $allFilters,
sortColumn: $sort.column,
sortOrder: $sort.order,
limit,
limit: RowPageSize,
paginate: true,
// Disable client side limiting, so that for queries and custom data
// sources we don't impose fake row limits. We want all the data.
clientSideLimiting: false,
},
})

View file

@ -43,6 +43,11 @@ export default class DataFetch {
// Pagination config
paginate: true,
// Client side feature customisation
clientSideSearching: true,
clientSideSorting: true,
clientSideLimiting: true,
}
// State of the fetch
@ -208,24 +213,32 @@ export default class DataFetch {
* Fetches some filtered, sorted and paginated data
*/
async getPage() {
const { sortColumn, sortOrder, sortType, limit } = this.options
const {
sortColumn,
sortOrder,
sortType,
limit,
clientSideSearching,
clientSideSorting,
clientSideLimiting,
} = this.options
const { query } = get(this.store)
// Get the actual data
let { rows, info, hasNextPage, cursor, error } = await this.getData()
// If we don't support searching, do a client search
if (!this.features.supportsSearch) {
if (!this.features.supportsSearch && clientSideSearching) {
rows = runLuceneQuery(rows, query)
}
// If we don't support sorting, do a client-side sort
if (!this.features.supportsSort) {
if (!this.features.supportsSort && clientSideSorting) {
rows = luceneSort(rows, sortColumn, sortOrder, sortType)
}
// If we don't support pagination, do a client-side limit
if (!this.features.supportsPagination) {
if (!this.features.supportsPagination && clientSideLimiting) {
rows = luceneLimit(rows, limit)
}

View file

@ -49,7 +49,12 @@ describe.each([
let table: Table
let tableId: string
afterAll(setup.afterAll)
afterAll(async () => {
if (dsProvider) {
await dsProvider.stopContainer()
}
setup.afterAll()
})
beforeAll(async () => {
await config.init()
@ -521,20 +526,17 @@ describe.each([
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await config.api.row.patch(table._id!, {
const row = await config.api.row.patch(table._id!, {
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
name: "Updated Name",
})
expect((res as any).res.statusMessage).toEqual(
`${table.name} updated successfully.`
)
expect(res.body.name).toEqual("Updated Name")
expect(res.body.description).toEqual(existing.description)
expect(row.name).toEqual("Updated Name")
expect(row.description).toEqual(existing.description)
const savedRow = await loadRow(res.body._id, table._id!)
const savedRow = await loadRow(row._id!, table._id!)
expect(savedRow.body.description).toEqual(existing.description)
expect(savedRow.body.name).toEqual("Updated Name")

View file

@ -492,6 +492,67 @@ describe("/tables", () => {
}
})
it("should succeed when the row is created from the other side of the relationship", async () => {
// We found a bug just after releasing this feature where if the row was created from the
// users table, not the table linking to it, the migration would succeed but lose the data.
// This happened because the order of the documents in the link was reversed.
const table = await config.api.table.create({
name: "table",
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
"user relationship": {
type: FieldType.LINK,
fieldName: "test",
name: "user relationship",
constraints: {
type: "array",
presence: false,
},
relationshipType: RelationshipType.MANY_TO_ONE,
tableId: InternalTable.USER_METADATA,
},
},
})
let testRow = await config.api.row.save(table._id!, {})
await Promise.all(
users.map(u =>
config.api.row.patch(InternalTable.USER_METADATA, {
tableId: InternalTable.USER_METADATA,
_rev: u._rev!,
_id: u._id!,
test: [testRow],
})
)
)
await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"],
newColumn: {
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USERS,
},
})
const migratedTable = await config.api.table.get(table._id!)
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const resp = await config.api.row.get(table._id!, testRow._id!)
const migratedRow = resp.body as Row
expect(migratedRow["user column"]).toBeDefined()
expect(migratedRow["user relationship"]).not.toBeDefined()
expect(migratedRow["user column"]).toHaveLength(3)
expect(migratedRow["user column"].map((u: Row) => u._id)).toEqual(
expect.arrayContaining(users.map(u => u._id))
)
})
it("should successfully migrate a many-to-many user relationship to a users column", async () => {
const table = await config.api.table.create({
name: "table",

View file

@ -7,6 +7,7 @@ import {
isBBReferenceField,
isRelationshipField,
LinkDocument,
LinkInfo,
RelationshipFieldMetadata,
RelationshipType,
Row,
@ -125,7 +126,23 @@ abstract class UserColumnMigrator implements ColumnMigrator {
protected newColumn: BBReferenceFieldMetadata
) {}
abstract updateRow(row: Row, link: LinkDocument): void
abstract updateRow(row: Row, linkInfo: LinkInfo): void
pickUserTableLinkSide(link: LinkDocument): LinkInfo {
if (link.doc1.tableId === InternalTable.USER_METADATA) {
return link.doc1
} else {
return link.doc2
}
}
pickOtherTableLinkSide(link: LinkDocument): LinkInfo {
if (link.doc1.tableId === InternalTable.USER_METADATA) {
return link.doc2
} else {
return link.doc1
}
}
async doMigration(): Promise<MigrationResult> {
let oldTable = cloneDeep(this.table)
@ -137,15 +154,17 @@ abstract class UserColumnMigrator implements ColumnMigrator {
let links = await sdk.links.fetchWithDocument(this.table._id!)
for (let link of links) {
const userSide = this.pickUserTableLinkSide(link)
const otherSide = this.pickOtherTableLinkSide(link)
if (
link.doc1.tableId !== this.table._id ||
link.doc1.fieldName !== this.oldColumn.name ||
link.doc2.tableId !== InternalTable.USER_METADATA
otherSide.tableId !== this.table._id ||
otherSide.fieldName !== this.oldColumn.name ||
userSide.tableId !== InternalTable.USER_METADATA
) {
continue
}
let row = rowsById[link.doc1.rowId]
let row = rowsById[otherSide.rowId]
if (!row) {
// This can happen if the row has been deleted but the link hasn't,
// which was a state that was found during the initial testing of this
@ -153,7 +172,7 @@ abstract class UserColumnMigrator implements ColumnMigrator {
continue
}
this.updateRow(row, link)
this.updateRow(row, userSide)
}
let db = context.getAppDB()
@ -175,20 +194,20 @@ abstract class UserColumnMigrator implements ColumnMigrator {
}
class SingleUserColumnMigrator extends UserColumnMigrator {
updateRow(row: Row, link: LinkDocument): void {
updateRow(row: Row, linkInfo: LinkInfo): void {
row[this.newColumn.name] = dbCore.getGlobalIDFromUserMetadataID(
link.doc2.rowId
linkInfo.rowId
)
}
}
class MultiUserColumnMigrator extends UserColumnMigrator {
updateRow(row: Row, link: LinkDocument): void {
updateRow(row: Row, linkInfo: LinkInfo): void {
if (!row[this.newColumn.name]) {
row[this.newColumn.name] = []
}
row[this.newColumn.name].push(
dbCore.getGlobalIDFromUserMetadataID(link.doc2.rowId)
dbCore.getGlobalIDFromUserMetadataID(linkInfo.rowId)
)
}
}

View file

@ -55,7 +55,13 @@ export class RowAPI extends TestAPI {
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
if (resp.status !== expectStatus) {
throw new Error(
`Expected status ${expectStatus} but got ${
resp.status
}, body: ${JSON.stringify(resp.body)}`
)
}
return resp.body as Row
}
@ -77,13 +83,20 @@ export class RowAPI extends TestAPI {
sourceId: string,
row: PatchRowRequest,
{ expectStatus } = { expectStatus: 200 }
) => {
return this.request
): Promise<Row> => {
let resp = await this.request
.patch(`/api/${sourceId}/rows`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
if (resp.status !== expectStatus) {
throw new Error(
`Expected status ${expectStatus} but got ${
resp.status
}, body: ${JSON.stringify(resp.body)}`
)
}
return resp.body as Row
}
delete = async (

View file

@ -1,17 +1,15 @@
import { Document } from "../document"
export interface LinkInfo {
rowId: string
fieldName: string
tableId: string
}
export interface LinkDocument extends Document {
type: string
doc1: {
rowId: string
fieldName: string
tableId: string
}
doc2: {
rowId: string
fieldName: string
tableId: string
}
doc1: LinkInfo
doc2: LinkInfo
}
export interface LinkDocumentValue {

View file

@ -1,8 +1,7 @@
export enum FeatureFlag {
LICENSING = "LICENSING",
// Feature IDs in Posthog
PER_CREATOR_PER_USER_PRICE = "18873",
PER_CREATOR_PER_USER_PRICE_ALERT = "18530",
PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE",
PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT",
}
export interface TenantFeatureFlags {

View file

@ -1,16 +1,12 @@
## Description
_Describe the problem or feature in addition to a link to the relevant github issues._
Addresses:
### Addresses:
- `<Enter the Link to the issue(s) this PR addresses>`
- ...more if required
## App Export
- If possible, attach an app export file along with your request template to make QA testing easier, with minimal setup.
## Screenshots
_If a UI facing feature, a short video of the happy path, and some screenshots of the new functionality._

View file

@ -2,9 +2,9 @@
if [[ $TARGETARCH == arm* ]] ;
then
echo "INSTALLING ARM64 MINIO"
wget wget https://dl.min.io/server/minio/release/linux-arm64/archive/minio.deb -O minio.deb
wget https://dl.min.io/server/minio/release/linux-arm64/minio
else
echo "INSTALLING AMD64 MINIO"
wget wget https://dl.min.io/server/minio/release/linux-amd64/archive/minio.deb -O minio.deb
wget https://dl.min.io/server/minio/release/linux-amd64/minio
fi
dpkg -i minio.deb
chmod +x minio