Merge branch 'master' of github.com:budibase/budibase into budi-7710-user-groups-do-not-fully-support-custom-roles-5
|
@ -67,7 +67,7 @@
|
|||
"@types/lodash": "4.14.200",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/pouchdb": "6.4.0",
|
||||
"@types/redlock": "4.0.3",
|
||||
"@types/redlock": "4.0.7",
|
||||
"@types/semver": "7.3.7",
|
||||
"@types/tar-fs": "2.0.1",
|
||||
"@types/uuid": "8.3.4",
|
||||
|
@ -78,6 +78,7 @@
|
|||
"jest-serial-runner": "1.2.1",
|
||||
"pino-pretty": "10.0.0",
|
||||
"pouchdb-adapter-memory": "7.2.2",
|
||||
"testcontainers": "^10.7.2",
|
||||
"timekeeper": "2.2.0",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import env from "../environment"
|
||||
import Redis from "ioredis"
|
||||
import Redis, { Cluster } from "ioredis"
|
||||
// mock-redis doesn't have any typing
|
||||
let MockRedis: any | undefined
|
||||
if (env.MOCK_REDIS) {
|
||||
|
@ -28,7 +28,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
|
|||
|
||||
// for testing just generate the client once
|
||||
let CLOSED = false
|
||||
let CLIENTS: { [key: number]: any } = {}
|
||||
const CLIENTS: Record<number, Redis> = {}
|
||||
let CONNECTED = false
|
||||
|
||||
// mock redis always connected
|
||||
|
@ -36,7 +36,7 @@ if (env.MOCK_REDIS) {
|
|||
CONNECTED = true
|
||||
}
|
||||
|
||||
function pickClient(selectDb: number): any {
|
||||
function pickClient(selectDb: number) {
|
||||
return CLIENTS[selectDb]
|
||||
}
|
||||
|
||||
|
@ -201,12 +201,15 @@ class RedisWrapper {
|
|||
key = `${db}${SEPARATOR}${key}`
|
||||
let stream
|
||||
if (CLUSTERED) {
|
||||
let node = this.getClient().nodes("master")
|
||||
let node = (this.getClient() as never as Cluster).nodes("master")
|
||||
stream = node[0].scanStream({ match: key + "*", count: 100 })
|
||||
} else {
|
||||
stream = this.getClient().scanStream({ match: key + "*", count: 100 })
|
||||
stream = (this.getClient() as Redis).scanStream({
|
||||
match: key + "*",
|
||||
count: 100,
|
||||
})
|
||||
}
|
||||
return promisifyStream(stream, this.getClient())
|
||||
return promisifyStream(stream, this.getClient() as any)
|
||||
}
|
||||
|
||||
async keys(pattern: string) {
|
||||
|
@ -221,14 +224,16 @@ class RedisWrapper {
|
|||
|
||||
async get(key: string) {
|
||||
const db = this._db
|
||||
let response = await this.getClient().get(addDbPrefix(db, key))
|
||||
const response = await this.getClient().get(addDbPrefix(db, key))
|
||||
// overwrite the prefixed key
|
||||
// @ts-ignore
|
||||
if (response != null && response.key) {
|
||||
// @ts-ignore
|
||||
response.key = key
|
||||
}
|
||||
// if its not an object just return the response
|
||||
try {
|
||||
return JSON.parse(response)
|
||||
return JSON.parse(response!)
|
||||
} catch (err) {
|
||||
return response
|
||||
}
|
||||
|
@ -274,13 +279,37 @@ class RedisWrapper {
|
|||
}
|
||||
}
|
||||
|
||||
async bulkStore(
|
||||
data: Record<string, any>,
|
||||
expirySeconds: number | null = null
|
||||
) {
|
||||
const client = this.getClient()
|
||||
|
||||
const dataToStore = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
acc[addDbPrefix(this._db, key)] =
|
||||
typeof value === "object" ? JSON.stringify(value) : value
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.mset(dataToStore)
|
||||
|
||||
if (expirySeconds !== null) {
|
||||
for (const key of Object.keys(dataToStore)) {
|
||||
pipeline.expire(key, expirySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async getTTL(key: string) {
|
||||
const db = this._db
|
||||
const prefixedKey = addDbPrefix(db, key)
|
||||
return this.getClient().ttl(prefixedKey)
|
||||
}
|
||||
|
||||
async setExpiry(key: string, expirySeconds: number | null) {
|
||||
async setExpiry(key: string, expirySeconds: number) {
|
||||
const db = this._db
|
||||
const prefixedKey = addDbPrefix(db, key)
|
||||
await this.getClient().expire(prefixedKey, expirySeconds)
|
||||
|
@ -295,6 +324,26 @@ class RedisWrapper {
|
|||
let items = await this.scan()
|
||||
await Promise.all(items.map((obj: any) => this.delete(obj.key)))
|
||||
}
|
||||
|
||||
async increment(key: string) {
|
||||
const result = await this.getClient().incr(addDbPrefix(this._db, key))
|
||||
if (isNaN(result)) {
|
||||
throw new Error(`Redis ${key} does not contain a number`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteIfValue(key: string, value: any) {
|
||||
const client = this.getClient()
|
||||
|
||||
const luaScript = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
`
|
||||
|
||||
await client.eval(luaScript, 1, addDbPrefix(this._db, key), value)
|
||||
}
|
||||
}
|
||||
|
||||
export default RedisWrapper
|
||||
|
|
|
@ -72,7 +72,7 @@ const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
|
|||
export async function newRedlock(opts: Redlock.Options = {}) {
|
||||
const options = { ...OPTIONS.DEFAULT, ...opts }
|
||||
const redisWrapper = await getLockClient()
|
||||
const client = redisWrapper.getClient()
|
||||
const client = redisWrapper.getClient() as any
|
||||
return new Redlock([client], options)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,214 @@
|
|||
import { GenericContainer, StartedTestContainer } from "testcontainers"
|
||||
import { generator, structures } from "../../../tests"
|
||||
import RedisWrapper from "../redis"
|
||||
import { env } from "../.."
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("redis", () => {
|
||||
let redis: RedisWrapper
|
||||
let container: StartedTestContainer
|
||||
|
||||
beforeAll(async () => {
|
||||
const container = await new GenericContainer("redis")
|
||||
.withExposedPorts(6379)
|
||||
.start()
|
||||
|
||||
env._set(
|
||||
"REDIS_URL",
|
||||
`${container.getHost()}:${container.getMappedPort(6379)}`
|
||||
)
|
||||
env._set("MOCK_REDIS", 0)
|
||||
env._set("REDIS_PASSWORD", 0)
|
||||
})
|
||||
|
||||
afterAll(() => container?.stop())
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = new RedisWrapper(structures.db.id())
|
||||
await redis.init()
|
||||
})
|
||||
|
||||
describe("store", () => {
|
||||
it("a basic value can be persisted", async () => {
|
||||
const key = structures.uuid()
|
||||
const value = generator.word()
|
||||
|
||||
await redis.store(key, value)
|
||||
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
})
|
||||
|
||||
it("objects can be persisted", async () => {
|
||||
const key = structures.uuid()
|
||||
const value = { [generator.word()]: generator.word() }
|
||||
|
||||
await redis.store(key, value)
|
||||
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bulkStore", () => {
|
||||
function createRandomObject(
|
||||
keyLength: number,
|
||||
valueGenerator: () => any = () => generator.word()
|
||||
) {
|
||||
return generator
|
||||
.unique(() => generator.word(), keyLength)
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = valueGenerator()
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}
|
||||
|
||||
it("a basic object can be persisted", async () => {
|
||||
const data = createRandomObject(10)
|
||||
|
||||
await redis.bulkStore(data)
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
}
|
||||
|
||||
expect(await redis.keys("*")).toHaveLength(10)
|
||||
})
|
||||
|
||||
it("a complex object can be persisted", async () => {
|
||||
const data = {
|
||||
...createRandomObject(10, () => createRandomObject(5)),
|
||||
...createRandomObject(5),
|
||||
}
|
||||
|
||||
await redis.bulkStore(data)
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
}
|
||||
|
||||
expect(await redis.keys("*")).toHaveLength(15)
|
||||
})
|
||||
|
||||
it("no TTL is set by default", async () => {
|
||||
const data = createRandomObject(10)
|
||||
|
||||
await redis.bulkStore(data)
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
expect(await redis.getTTL(key)).toEqual(-1)
|
||||
}
|
||||
})
|
||||
|
||||
it("a bulk store can be persisted with TTL", async () => {
|
||||
const ttl = 500
|
||||
const data = createRandomObject(8)
|
||||
|
||||
await redis.bulkStore(data, ttl)
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
expect(await redis.getTTL(key)).toEqual(ttl)
|
||||
}
|
||||
|
||||
expect(await redis.keys("*")).toHaveLength(8)
|
||||
})
|
||||
|
||||
it("setting a TTL of -1 will not persist the key", async () => {
|
||||
const ttl = -1
|
||||
const data = createRandomObject(5)
|
||||
|
||||
await redis.bulkStore(data, ttl)
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
expect(await redis.get(key)).toBe(null)
|
||||
}
|
||||
|
||||
expect(await redis.keys("*")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("increment", () => {
|
||||
it("can increment on a new key", async () => {
|
||||
const key = structures.uuid()
|
||||
const result = await redis.increment(key)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it("can increment multiple times", async () => {
|
||||
const key = structures.uuid()
|
||||
const results = [
|
||||
await redis.increment(key),
|
||||
await redis.increment(key),
|
||||
await redis.increment(key),
|
||||
await redis.increment(key),
|
||||
await redis.increment(key),
|
||||
]
|
||||
expect(results).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it("can increment on a new key", async () => {
|
||||
const key1 = structures.uuid()
|
||||
const key2 = structures.uuid()
|
||||
|
||||
const result1 = await redis.increment(key1)
|
||||
expect(result1).toBe(1)
|
||||
|
||||
const result2 = await redis.increment(key2)
|
||||
expect(result2).toBe(1)
|
||||
})
|
||||
|
||||
it("can increment multiple times in parallel", async () => {
|
||||
const key = structures.uuid()
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 100 }).map(() => redis.increment(key))
|
||||
)
|
||||
expect(results).toHaveLength(100)
|
||||
expect(results).toEqual(Array.from({ length: 100 }).map((_, i) => i + 1))
|
||||
})
|
||||
|
||||
it("can increment existing set keys", async () => {
|
||||
const key = structures.uuid()
|
||||
await redis.store(key, 70)
|
||||
await redis.increment(key)
|
||||
|
||||
const result = await redis.increment(key)
|
||||
expect(result).toBe(72)
|
||||
})
|
||||
|
||||
it.each([
|
||||
generator.word(),
|
||||
generator.bool(),
|
||||
{ [generator.word()]: generator.word() },
|
||||
])("cannot increment if the store value is not a number", async value => {
|
||||
const key = structures.uuid()
|
||||
await redis.store(key, value)
|
||||
|
||||
await expect(redis.increment(key)).rejects.toThrowError(
|
||||
"ERR value is not an integer or out of range"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteIfValue", () => {
|
||||
it("can delete if the value matches", async () => {
|
||||
const key = structures.uuid()
|
||||
const value = generator.word()
|
||||
await redis.store(key, value)
|
||||
|
||||
await redis.deleteIfValue(key, value)
|
||||
|
||||
expect(await redis.get(key)).toBeNull()
|
||||
})
|
||||
|
||||
it("will not delete if the value does not matches", async () => {
|
||||
const key = structures.uuid()
|
||||
const value = generator.word()
|
||||
await redis.store(key, value)
|
||||
|
||||
await redis.deleteIfValue(key, generator.word())
|
||||
|
||||
expect(await redis.get(key)).toEqual(value)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -35,7 +35,10 @@ export default function positionDropdown(element, opts) {
|
|||
}
|
||||
|
||||
if (typeof customUpdate === "function") {
|
||||
styles = customUpdate(anchorBounds, elementBounds, styles)
|
||||
styles = customUpdate(anchorBounds, elementBounds, {
|
||||
...styles,
|
||||
offset: opts.offset,
|
||||
})
|
||||
} else {
|
||||
// Determine vertical styles
|
||||
if (align === "right-outside") {
|
||||
|
|
|
@ -9,13 +9,17 @@ const intercom = new IntercomClient(process.env.INTERCOM_TOKEN)
|
|||
class AnalyticsHub {
|
||||
constructor() {
|
||||
this.clients = [posthog, intercom]
|
||||
this.initialised = false
|
||||
}
|
||||
|
||||
async activate() {
|
||||
// Check analytics are enabled
|
||||
const analyticsStatus = await API.getAnalyticsStatus()
|
||||
if (analyticsStatus.enabled) {
|
||||
this.clients.forEach(client => client.init())
|
||||
if (analyticsStatus.enabled && !this.initialised) {
|
||||
this.clients.forEach(client => {
|
||||
client.init()
|
||||
})
|
||||
this.initialised = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -164,9 +164,10 @@
|
|||
</div>
|
||||
{/if}
|
||||
<TourWrap
|
||||
tourStepKey={$builderStore.onboarding
|
||||
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
|
||||
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
|
||||
stepKeys={[
|
||||
TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
|
||||
TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
|
||||
]}
|
||||
>
|
||||
<div class="app-action-button users">
|
||||
<div class="app-action" id="builder-app-users-button">
|
||||
|
@ -209,7 +210,7 @@
|
|||
<div bind:this={appActionPopoverAnchor}>
|
||||
<div class="app-action">
|
||||
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
|
||||
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
|
||||
<TourWrap stepKeys={[TOUR_STEP_KEYS.BUILDER_APP_PUBLISH]}>
|
||||
<span class="publish-open" id="builder-app-publish-button">
|
||||
Publish
|
||||
<Icon
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { runtimeToReadableBinding } from "dataBinding"
|
||||
import { isJSBinding } from "@budibase/string-templates"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import { componentStore } from "stores/builder"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import { customPositionHandler } from "."
|
||||
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
||||
|
||||
export let anchor
|
||||
|
@ -54,25 +55,6 @@
|
|||
|
||||
dispatch("change", nestedComponentInstance)
|
||||
}
|
||||
|
||||
const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
|
||||
let { left, top } = cfg
|
||||
let percentageOffset = 30
|
||||
// left-outside
|
||||
left = anchorBounds.left - eleBounds.width - 18
|
||||
|
||||
// shift up from the anchor, if space allows
|
||||
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
|
||||
let defaultTop = anchorBounds.top - offsetPos
|
||||
|
||||
if (window.innerHeight - defaultTop < eleBounds.height) {
|
||||
top = window.innerHeight - eleBounds.height - 5
|
||||
} else {
|
||||
top = anchorBounds.top - offsetPos
|
||||
}
|
||||
|
||||
return { ...cfg, left, top }
|
||||
}
|
||||
</script>
|
||||
|
||||
<Icon
|
||||
|
@ -104,6 +86,7 @@
|
|||
showPopover={drawers.length === 0}
|
||||
clickOutsideOverride={drawers.length > 0}
|
||||
maxHeight={600}
|
||||
offset={18}
|
||||
handlePostionUpdate={customPositionHandler}
|
||||
>
|
||||
<span class="popover-wrap">
|
|
@ -0,0 +1,18 @@
|
|||
export const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
|
||||
let { left, top, offset } = cfg
|
||||
let percentageOffset = 30
|
||||
// left-outside
|
||||
left = anchorBounds.left - eleBounds.width - (offset || 5)
|
||||
|
||||
// shift up from the anchor, if space allows
|
||||
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
|
||||
let defaultTop = anchorBounds.top - offsetPos
|
||||
|
||||
if (window.innerHeight - defaultTop < eleBounds.height) {
|
||||
top = window.innerHeight - eleBounds.height - 5
|
||||
} else {
|
||||
top = anchorBounds.top - offsetPos
|
||||
}
|
||||
|
||||
return { ...cfg, left, top }
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Toggle, Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
|
|
@ -52,8 +52,8 @@
|
|||
_id: Helpers.uuid(),
|
||||
_component: componentType,
|
||||
_instanceName: `Step ${currentStep + 1}`,
|
||||
title: stepSettings.title ?? defaults.title,
|
||||
buttons: stepSettings.buttons || defaults.buttons,
|
||||
title: stepSettings.title ?? defaults?.title,
|
||||
buttons: stepSettings.buttons || defaults?.buttons,
|
||||
fields: stepSettings.fields,
|
||||
desc: stepSettings.desc,
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Toggle, Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { setContext } from "svelte"
|
||||
import { writable } from "svelte/store"
|
||||
|
|
|
@ -139,10 +139,22 @@
|
|||
{/each}
|
||||
</div>
|
||||
<div class="search-input">
|
||||
<div class="input-wrapper">
|
||||
<Input bind:value={searchTerm} thin placeholder="Search Icon" />
|
||||
<div class="input-wrapper" style={`width: ${value ? "425" : "510"}px`}>
|
||||
<Input
|
||||
bind:value={searchTerm}
|
||||
on:keyup={event => {
|
||||
if (event.key === "Enter") {
|
||||
searchForIcon()
|
||||
}
|
||||
}}
|
||||
thin
|
||||
placeholder="Search Icon"
|
||||
/>
|
||||
</div>
|
||||
<Button secondary on:click={searchForIcon}>Search</Button>
|
||||
{#if value}
|
||||
<Button primary on:click={() => (value = null)}>Clear</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="page-area">
|
||||
<div class="pager">
|
||||
|
@ -239,6 +251,7 @@
|
|||
flex-flow: row nowrap;
|
||||
width: 100%;
|
||||
padding-right: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
.input-wrapper {
|
||||
width: 510px;
|
||||
|
|
|
@ -20,17 +20,23 @@
|
|||
export let bindings = []
|
||||
export let componentBindings = []
|
||||
export let nested = false
|
||||
export let highlighted = false
|
||||
export let propertyFocus = false
|
||||
export let info = null
|
||||
export let disableBindings = false
|
||||
export let wide
|
||||
|
||||
$: nullishValue = value == null || value === ""
|
||||
let highlightType
|
||||
|
||||
$: highlightedProp = $builderStore.highlightedSetting
|
||||
$: allBindings = getAllBindings(bindings, componentBindings, nested)
|
||||
$: safeValue = getSafeValue(value, defaultValue, allBindings)
|
||||
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
|
||||
|
||||
$: if (!Array.isArray(value)) {
|
||||
highlightType =
|
||||
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
|
||||
}
|
||||
|
||||
const getAllBindings = (bindings, componentBindings, nested) => {
|
||||
if (!nested) {
|
||||
return bindings
|
||||
|
@ -71,16 +77,17 @@
|
|||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (highlighted) {
|
||||
if (highlightedProp) {
|
||||
builderStore.highlightSetting(null)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="property-control"
|
||||
id={`${key}-prop-control-wrap`}
|
||||
class={`property-control ${highlightType}`}
|
||||
class:wide={!label || labelHidden || wide === true}
|
||||
class:highlighted={highlighted && nullishValue}
|
||||
class:highlighted={highlightType}
|
||||
class:property-focus={propertyFocus}
|
||||
>
|
||||
{#if label && !labelHidden}
|
||||
|
@ -115,6 +122,16 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.property-control.highlighted.highlighted-info {
|
||||
border-color: var(--spectrum-semantic-informative-color-background);
|
||||
}
|
||||
.property-control.highlighted.highlighted-error {
|
||||
border-color: var(--spectrum-global-color-static-red-600);
|
||||
}
|
||||
.property-control.highlighted.highlighted-warning {
|
||||
border-color: var(--spectrum-global-color-static-orange-700);
|
||||
}
|
||||
|
||||
.property-control {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
@ -132,6 +149,10 @@
|
|||
.property-control.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-color: var(--spectrum-global-color-static-red-600);
|
||||
margin-top: -3.5px;
|
||||
margin-bottom: -3.5px;
|
||||
padding-bottom: 3.5px;
|
||||
padding-top: 3.5px;
|
||||
}
|
||||
|
||||
.property-control.property-focus :global(input) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { Popover, Layout, Heading, Body, Button, Link } from "@budibase/bbui"
|
||||
import { TOURS } from "./tours.js"
|
||||
import { TOURS, getCurrentStepIdx } from "./tours.js"
|
||||
import { goto, layout, isActive } from "@roxi/routify"
|
||||
import { builderStore } from "stores/builder"
|
||||
|
||||
|
@ -20,6 +20,13 @@
|
|||
|
||||
const updateTourStep = (targetStepKey, tourKey) => {
|
||||
if (!tourKey) {
|
||||
tourSteps = null
|
||||
tourStepIdx = null
|
||||
lastStep = null
|
||||
tourStep = null
|
||||
popoverAnchor = null
|
||||
popover = null
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
if (!tourSteps?.length) {
|
||||
|
@ -78,16 +85,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentStepIdx = (steps, tourStepKey) => {
|
||||
if (!steps?.length) {
|
||||
return
|
||||
}
|
||||
if (steps?.length && !tourStepKey) {
|
||||
return 0
|
||||
}
|
||||
return steps.findIndex(step => step.id === tourStepKey)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tourKey}
|
||||
|
@ -98,7 +95,9 @@
|
|||
anchor={popoverAnchor}
|
||||
maxWidth={300}
|
||||
dismissible={false}
|
||||
offset={15}
|
||||
offset={12}
|
||||
handlePostionUpdate={tourStep?.positionHandler}
|
||||
customZindex={3}
|
||||
>
|
||||
<div class="tour-content">
|
||||
<Layout noPadding gap="M">
|
||||
|
@ -119,7 +118,7 @@
|
|||
</Body>
|
||||
<div class="tour-footer">
|
||||
<div class="tour-navigation">
|
||||
{#if typeof tourOnSkip === "function"}
|
||||
{#if typeof tourOnSkip === "function" && !lastStep}
|
||||
<Link
|
||||
secondary
|
||||
quiet
|
||||
|
|
|
@ -1,39 +1,63 @@
|
|||
<script>
|
||||
import { tourHandler } from "./tourHandler"
|
||||
import { TOURS } from "./tours"
|
||||
import { TOURSBYSTEP, TOURS, getCurrentStepIdx } from "./tours"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import { builderStore } from "stores/builder"
|
||||
|
||||
export let tourStepKey
|
||||
export let stepKeys = []
|
||||
|
||||
let currentTourStep
|
||||
let ready = false
|
||||
let registered = false
|
||||
let handler
|
||||
let registered = {}
|
||||
|
||||
const registerTourNode = (tourKey, stepKey) => {
|
||||
if (ready && !registered && tourKey) {
|
||||
currentTourStep = TOURS[tourKey].steps.find(step => step.id === stepKey)
|
||||
if (!currentTourStep) {
|
||||
return
|
||||
const step = TOURSBYSTEP[stepKey]
|
||||
if (ready && step && !registered[stepKey] && step?.tour === tourKey) {
|
||||
const elem = document.querySelector(step.query)
|
||||
registered[stepKey] = tourHandler(elem, stepKey)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToStep = () => {
|
||||
let tourStepIdx = getCurrentStepIdx(
|
||||
TOURS[tourKeyWatch]?.steps,
|
||||
tourStepKeyWatch
|
||||
)
|
||||
let currentStep = TOURS[tourKeyWatch]?.steps?.[tourStepIdx]
|
||||
if (currentStep?.scrollIntoView) {
|
||||
let currentNode = $builderStore.tourNodes?.[currentStep.id]
|
||||
if (currentNode) {
|
||||
currentNode.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
const elem = document.querySelector(currentTourStep.query)
|
||||
handler = tourHandler(elem, stepKey)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
$: tourKeyWatch = $builderStore.tourKey
|
||||
$: registerTourNode(tourKeyWatch, tourStepKey, ready)
|
||||
$: tourStepKeyWatch = $builderStore.tourStepKey
|
||||
$: if (tourKeyWatch || stepKeys || ready) {
|
||||
stepKeys.forEach(tourStepKey => {
|
||||
registerTourNode(tourKeyWatch, tourStepKey)
|
||||
})
|
||||
}
|
||||
$: scrollToStep(tourKeyWatch, tourStepKeyWatch)
|
||||
|
||||
onMount(() => {
|
||||
ready = true
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (handler) {
|
||||
Object.entries(registered).forEach(entry => {
|
||||
const handler = entry[1]
|
||||
const stepKey = entry[0]
|
||||
// Run step destroy, de-register nodes in the builderStore and local cache
|
||||
handler.destroy()
|
||||
}
|
||||
delete registered[stepKey]
|
||||
|
||||
// Check if the step is part of an active tour. End the tour if that is the case
|
||||
const step = TOURSBYSTEP[stepKey]
|
||||
if (step.tour === tourKeyWatch) {
|
||||
builderStore.setTour()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<div>
|
||||
When faced with a sizable form, consider implementing a multi-step approach to
|
||||
enhance user experience.
|
||||
|
||||
<p>
|
||||
Breaking the form into multiple steps can significantly improve usability by
|
||||
making the process more digestible for your users.
|
||||
</p>
|
||||
</div>
|
|
@ -0,0 +1,17 @@
|
|||
<div>
|
||||
You can use bindings to set the Row ID on your form.
|
||||
<p>
|
||||
This will allow you to pull the correct information into your form and allow
|
||||
you to update!
|
||||
</p>
|
||||
<a href="https://docs.budibase.com/docs/form-block" target="_blank">
|
||||
How to pass a row ID using bindings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
|
@ -1,3 +1,5 @@
|
|||
export { default as OnboardingData } from "./OnboardingData.svelte"
|
||||
export { default as OnboardingDesign } from "./OnboardingDesign.svelte"
|
||||
export { default as OnboardingPublish } from "./OnboardingPublish.svelte"
|
||||
export { default as NewViewUpdateFormRowId } from "./NewViewUpdateFormRowId.svelte"
|
||||
export { default as NewFormSteps } from "./NewFormSteps.svelte"
|
||||
|
|
|
@ -2,8 +2,15 @@ import { get } from "svelte/store"
|
|||
import { builderStore } from "stores/builder"
|
||||
import { auth } from "stores/portal"
|
||||
import analytics from "analytics"
|
||||
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps"
|
||||
import {
|
||||
OnboardingData,
|
||||
OnboardingDesign,
|
||||
OnboardingPublish,
|
||||
NewViewUpdateFormRowId,
|
||||
NewFormSteps,
|
||||
} from "./steps"
|
||||
import { API } from "api"
|
||||
import { customPositionHandler } from "components/design/settings/controls/EditComponentPopover"
|
||||
|
||||
const ONBOARDING_EVENT_PREFIX = "onboarding"
|
||||
|
||||
|
@ -14,11 +21,26 @@ export const TOUR_STEP_KEYS = {
|
|||
BUILDER_USER_MANAGEMENT: "builder-user-management",
|
||||
BUILDER_AUTOMATION_SECTION: "builder-automation-section",
|
||||
FEATURE_USER_MANAGEMENT: "feature-user-management",
|
||||
BUILDER_FORM_CREATE_STEPS: "builder-form-create-steps",
|
||||
BUILDER_FORM_VIEW_UPDATE_STEPS: "builder-form-view-update-steps",
|
||||
BUILDER_FORM_ROW_ID: "builder-form-row-id",
|
||||
}
|
||||
|
||||
export const TOUR_KEYS = {
|
||||
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
|
||||
FEATURE_ONBOARDING: "feature-onboarding",
|
||||
BUILDER_FORM_CREATE: "builder-form-create",
|
||||
BUILDER_FORM_VIEW_UPDATE: "builder-form-view-update",
|
||||
}
|
||||
|
||||
export const getCurrentStepIdx = (steps, tourStepKey) => {
|
||||
if (!steps?.length) {
|
||||
return
|
||||
}
|
||||
if (steps?.length && !tourStepKey) {
|
||||
return 0
|
||||
}
|
||||
return steps.findIndex(step => step.id === tourStepKey)
|
||||
}
|
||||
|
||||
const endUserOnboarding = async ({ skipped = false } = {}) => {
|
||||
|
@ -37,13 +59,8 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
|
|||
// Update the cached user
|
||||
await auth.getSelf()
|
||||
|
||||
builderStore.update(state => ({
|
||||
...state,
|
||||
tourNodes: null,
|
||||
tourKey: null,
|
||||
tourStepKey: null,
|
||||
onboarding: false,
|
||||
}))
|
||||
builderStore.endBuilderOnboarding()
|
||||
builderStore.setTour()
|
||||
} catch (e) {
|
||||
console.error("Onboarding failed", e)
|
||||
return false
|
||||
|
@ -52,9 +69,29 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
|
|||
}
|
||||
}
|
||||
|
||||
const tourEvent = eventKey => {
|
||||
const endTour = async ({ key, skipped = false } = {}) => {
|
||||
const { tours = {} } = get(auth).user
|
||||
tours[key] = new Date().toISOString()
|
||||
|
||||
await API.updateSelf({
|
||||
tours,
|
||||
})
|
||||
|
||||
if (skipped) {
|
||||
tourEvent(key, skipped)
|
||||
}
|
||||
|
||||
// Update the cached user
|
||||
await auth.getSelf()
|
||||
|
||||
// Reset tour state
|
||||
builderStore.setTour()
|
||||
}
|
||||
|
||||
const tourEvent = (eventKey, skipped) => {
|
||||
analytics.captureEvent(`${ONBOARDING_EVENT_PREFIX}:${eventKey}`, {
|
||||
eventSource: EventSource.PORTAL,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -135,7 +172,71 @@ const getTours = () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
[TOUR_KEYS.BUILDER_FORM_CREATE]: {
|
||||
steps: [
|
||||
{
|
||||
id: TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS,
|
||||
title: "Add multiple steps",
|
||||
layout: NewFormSteps,
|
||||
query: "#steps-prop-control-wrap",
|
||||
onComplete: () => {
|
||||
builderStore.highlightSetting()
|
||||
endTour({ key: TOUR_KEYS.BUILDER_FORM_CREATE })
|
||||
},
|
||||
onLoad: () => {
|
||||
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS)
|
||||
builderStore.highlightSetting("steps", "info")
|
||||
},
|
||||
positionHandler: customPositionHandler,
|
||||
align: "left-outside",
|
||||
},
|
||||
],
|
||||
},
|
||||
[TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE]: {
|
||||
steps: [
|
||||
{
|
||||
id: TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID,
|
||||
title: "Add row ID to update a row",
|
||||
layout: NewViewUpdateFormRowId,
|
||||
query: "#rowId-prop-control-wrap",
|
||||
onLoad: () => {
|
||||
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID)
|
||||
builderStore.highlightSetting("rowId", "info")
|
||||
},
|
||||
positionHandler: customPositionHandler,
|
||||
align: "left-outside",
|
||||
},
|
||||
{
|
||||
id: TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS,
|
||||
title: "Add multiple steps",
|
||||
layout: NewFormSteps,
|
||||
query: "#steps-prop-control-wrap",
|
||||
onComplete: () => {
|
||||
builderStore.highlightSetting()
|
||||
endTour({ key: TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE })
|
||||
},
|
||||
onLoad: () => {
|
||||
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS)
|
||||
builderStore.highlightSetting("steps", "info")
|
||||
},
|
||||
positionHandler: customPositionHandler,
|
||||
align: "left-outside",
|
||||
scrollIntoView: true,
|
||||
},
|
||||
],
|
||||
onSkip: async () => {
|
||||
builderStore.highlightSetting()
|
||||
endTour({ key: TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE, skipped: true })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const TOURS = getTours()
|
||||
export const TOURSBYSTEP = Object.keys(TOURS).reduce((acc, tour) => {
|
||||
TOURS[tour].steps.forEach(element => {
|
||||
acc[element.id] = element
|
||||
acc[element.id]["tour"] = tour
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
|
|
@ -1146,7 +1146,7 @@ export const getAllStateVariables = () => {
|
|||
"@budibase/standard-components/multistepformblockstep"
|
||||
)
|
||||
|
||||
steps.forEach(step => {
|
||||
steps?.forEach(step => {
|
||||
parseComponentSettings(stepDefinition, step)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -96,7 +96,7 @@
|
|||
const release_date = new Date("2023-03-01T00:00:00.000Z")
|
||||
const onboarded = new Date($auth.user?.onboardedAt)
|
||||
if (onboarded < release_date) {
|
||||
builderStore.startTour(TOUR_KEYS.FEATURE_ONBOARDING)
|
||||
builderStore.setTour(TOUR_KEYS.FEATURE_ONBOARDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -144,7 +144,7 @@
|
|||
</span>
|
||||
<Tabs {selected} size="M">
|
||||
{#each $layout.children as { path, title }}
|
||||
<TourWrap tourStepKey={`builder-${title}-section`}>
|
||||
<TourWrap stepKeys={[`builder-${title}-section`]}>
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
|
|
|
@ -16,6 +16,14 @@
|
|||
} from "dataBinding"
|
||||
import { ActionButton, notifications } from "@budibase/bbui"
|
||||
import { capitalise } from "helpers"
|
||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
|
||||
|
||||
const {
|
||||
BUILDER_FORM_CREATE_STEPS,
|
||||
BUILDER_FORM_VIEW_UPDATE_STEPS,
|
||||
BUILDER_FORM_ROW_ID,
|
||||
} = TOUR_STEP_KEYS
|
||||
|
||||
const onUpdateName = async value => {
|
||||
try {
|
||||
|
@ -46,7 +54,6 @@
|
|||
|
||||
$: id = $selectedComponent?._id
|
||||
$: id, (section = tabs[0])
|
||||
|
||||
$: componentName = getComponentName(componentInstance)
|
||||
</script>
|
||||
|
||||
|
@ -92,13 +99,21 @@
|
|||
</div>
|
||||
</span>
|
||||
{#if section == "settings"}
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
<TourWrap
|
||||
stepKeys={[
|
||||
BUILDER_FORM_CREATE_STEPS,
|
||||
BUILDER_FORM_VIEW_UPDATE_STEPS,
|
||||
BUILDER_FORM_ROW_ID,
|
||||
]}
|
||||
>
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
</TourWrap>
|
||||
{/if}
|
||||
{#if section == "styles"}
|
||||
<DesignSection
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { DetailSummary, notifications } from "@budibase/bbui"
|
||||
import { componentStore } from "stores/builder"
|
||||
import { componentStore, builderStore } from "stores/builder"
|
||||
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
|
||||
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
||||
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
|
||||
|
@ -177,9 +177,7 @@
|
|||
defaultValue={setting.defaultValue}
|
||||
nested={setting.nested}
|
||||
onChange={val => updateSetting(setting, val)}
|
||||
highlighted={$componentStore.highlightedSettingKey ===
|
||||
setting.key}
|
||||
propertyFocus={$componentStore.propertyFocus === setting.key}
|
||||
propertyFocus={$builderStore.propertyFocus === setting.key}
|
||||
info={setting.info}
|
||||
disableBindings={setting.disableBindings}
|
||||
props={{
|
||||
|
|
|
@ -174,7 +174,7 @@
|
|||
} else if (type === "request-add-component") {
|
||||
toggleAddComponent()
|
||||
} else if (type === "highlight-setting") {
|
||||
builderStore.highlightSetting(data.setting)
|
||||
builderStore.highlightSetting(data.setting, "error")
|
||||
|
||||
// Also scroll setting into view
|
||||
const selector = `#${data.setting}-prop-control`
|
||||
|
|
|
@ -3,13 +3,23 @@
|
|||
import DatasourceModal from "./DatasourceModal.svelte"
|
||||
import ScreenRoleModal from "./ScreenRoleModal.svelte"
|
||||
import sanitizeUrl from "helpers/sanitizeUrl"
|
||||
import FormTypeModal from "./FormTypeModal.svelte"
|
||||
import { Modal, notifications } from "@budibase/bbui"
|
||||
import { screenStore, navigationStore, tables } from "stores/builder"
|
||||
import {
|
||||
screenStore,
|
||||
navigationStore,
|
||||
tables,
|
||||
builderStore,
|
||||
} from "stores/builder"
|
||||
import { auth } from "stores/portal"
|
||||
import { get } from "svelte/store"
|
||||
import getTemplates from "templates"
|
||||
import { Roles } from "constants/backend"
|
||||
import { capitalise } from "helpers"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
|
||||
import formScreen from "templates/formScreen"
|
||||
import rowListScreen from "templates/rowListScreen"
|
||||
|
||||
let mode
|
||||
let pendingScreen
|
||||
|
@ -18,12 +28,18 @@
|
|||
let screenDetailsModal
|
||||
let datasourceModal
|
||||
let screenAccessRoleModal
|
||||
let formTypeModal
|
||||
|
||||
// Cache variables for workflow
|
||||
let screenAccessRole = Roles.BASIC
|
||||
let selectedTemplates = null
|
||||
|
||||
let templates = null
|
||||
let screens = null
|
||||
|
||||
let selectedDatasources = null
|
||||
let blankScreenUrl = null
|
||||
let screenMode = null
|
||||
let formType = null
|
||||
|
||||
// Creates an array of screens, checking and sanitising their URLs
|
||||
const createScreens = async ({ screens, screenAccessRole }) => {
|
||||
|
@ -32,7 +48,7 @@
|
|||
}
|
||||
|
||||
try {
|
||||
let screenId
|
||||
let createdScreens = []
|
||||
|
||||
for (let screen of screens) {
|
||||
// Check we aren't clashing with an existing URL
|
||||
|
@ -56,7 +72,7 @@
|
|||
|
||||
// Create the screen
|
||||
const response = await screenStore.save(screen)
|
||||
screenId = response._id
|
||||
createdScreens.push(response)
|
||||
|
||||
// Add link in layout. We only ever actually create 1 screen now, even
|
||||
// for autoscreens, so it's always safe to do this.
|
||||
|
@ -66,9 +82,7 @@
|
|||
)
|
||||
}
|
||||
|
||||
// Go to new screen
|
||||
$goto(`./${screenId}`)
|
||||
screenStore.select(screenId)
|
||||
return createdScreens
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error creating screens")
|
||||
|
@ -104,13 +118,16 @@
|
|||
// Handler for NewScreenModal
|
||||
export const show = newMode => {
|
||||
mode = newMode
|
||||
selectedTemplates = null
|
||||
templates = null
|
||||
screens = null
|
||||
selectedDatasources = null
|
||||
blankScreenUrl = null
|
||||
screenMode = mode
|
||||
pendingScreen = null
|
||||
screenAccessRole = Roles.BASIC
|
||||
formType = null
|
||||
|
||||
if (mode === "table" || mode === "grid") {
|
||||
if (mode === "table" || mode === "grid" || mode === "form") {
|
||||
datasourceModal.show()
|
||||
} else if (mode === "blank") {
|
||||
let templates = getTemplates($tables.list)
|
||||
|
@ -125,19 +142,26 @@
|
|||
}
|
||||
|
||||
// Handler for DatasourceModal confirmation, move to screen access select
|
||||
const confirmScreenDatasources = async ({ templates }) => {
|
||||
selectedTemplates = templates
|
||||
screenAccessRoleModal.show()
|
||||
const confirmScreenDatasources = async ({ datasources }) => {
|
||||
selectedDatasources = datasources
|
||||
if (screenMode === "form") {
|
||||
formTypeModal.show()
|
||||
} else {
|
||||
screenAccessRoleModal.show()
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for Datasource Screen Creation
|
||||
const completeDatasourceScreenCreation = async () => {
|
||||
const screens = selectedTemplates.map(template => {
|
||||
templates = rowListScreen(selectedDatasources, mode)
|
||||
|
||||
const screens = templates.map(template => {
|
||||
let screenTemplate = template.create()
|
||||
screenTemplate.autoTableId = template.resourceId
|
||||
return screenTemplate
|
||||
})
|
||||
await createScreens({ screens, screenAccessRole })
|
||||
const createdScreens = await createScreens({ screens, screenAccessRole })
|
||||
loadNewScreen(createdScreens)
|
||||
}
|
||||
|
||||
const confirmScreenBlank = async ({ screenUrl }) => {
|
||||
|
@ -154,7 +178,54 @@
|
|||
return
|
||||
}
|
||||
pendingScreen.routing.route = screenUrl
|
||||
await createScreens({ screens: [pendingScreen], screenAccessRole })
|
||||
const createdScreens = await createScreens({
|
||||
screens: [pendingScreen],
|
||||
screenAccessRole,
|
||||
})
|
||||
loadNewScreen(createdScreens)
|
||||
}
|
||||
|
||||
const onConfirmFormType = () => {
|
||||
screenAccessRoleModal.show()
|
||||
}
|
||||
|
||||
const loadNewScreen = createdScreens => {
|
||||
const lastScreen = createdScreens.slice(-1)[0]
|
||||
|
||||
// Go to new screen
|
||||
if (lastScreen?.props?._children.length) {
|
||||
// Focus on the main component for the streen type
|
||||
const mainComponent = lastScreen?.props?._children?.[0]._id
|
||||
$goto(`./${lastScreen._id}/${mainComponent}`)
|
||||
} else {
|
||||
$goto(`./${lastScreen._id}`)
|
||||
}
|
||||
|
||||
screenStore.select(lastScreen._id)
|
||||
}
|
||||
|
||||
const confirmFormScreenCreation = async () => {
|
||||
templates = formScreen(selectedDatasources, { actionType: formType })
|
||||
screens = templates.map(template => {
|
||||
let screenTemplate = template.create()
|
||||
return screenTemplate
|
||||
})
|
||||
const createdScreens = await createScreens({ screens, screenAccessRole })
|
||||
|
||||
if (formType === "Update" || formType === "Create") {
|
||||
const associatedTour =
|
||||
formType === "Update"
|
||||
? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE
|
||||
: TOUR_KEYS.BUILDER_FORM_CREATE
|
||||
|
||||
const tourRequired = !$auth?.user?.tours?.[associatedTour]
|
||||
if (tourRequired) {
|
||||
builderStore.setTour(associatedTour)
|
||||
}
|
||||
}
|
||||
|
||||
// Go to new screen
|
||||
loadNewScreen(createdScreens)
|
||||
}
|
||||
|
||||
// Submit screen config for creation.
|
||||
|
@ -164,6 +235,8 @@
|
|||
screenUrl: blankScreenUrl,
|
||||
screenAccessRole,
|
||||
})
|
||||
} else if (screenMode === "form") {
|
||||
confirmFormScreenCreation()
|
||||
} else {
|
||||
completeDatasourceScreenCreation()
|
||||
}
|
||||
|
@ -179,19 +252,18 @@
|
|||
</script>
|
||||
|
||||
<Modal bind:this={datasourceModal} autoFocus={false}>
|
||||
<DatasourceModal
|
||||
{mode}
|
||||
onConfirm={confirmScreenDatasources}
|
||||
initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
|
||||
/>
|
||||
<DatasourceModal {mode} onConfirm={confirmScreenDatasources} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={screenAccessRoleModal}>
|
||||
<ScreenRoleModal
|
||||
onConfirm={confirmScreenCreation}
|
||||
onCancel={roleSelectBack}
|
||||
onConfirm={() => {
|
||||
confirmScreenCreation()
|
||||
}}
|
||||
bind:screenAccessRole
|
||||
onCancel={roleSelectBack}
|
||||
screenUrl={blankScreenUrl}
|
||||
confirmText={screenMode === "form" ? "Confirm" : "Done"}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
@ -201,3 +273,17 @@
|
|||
initialUrl={blankScreenUrl}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={formTypeModal}>
|
||||
<FormTypeModal
|
||||
onConfirm={onConfirmFormType}
|
||||
onCancel={() => {
|
||||
formTypeModal.hide()
|
||||
datasourceModal.show()
|
||||
}}
|
||||
on:select={e => {
|
||||
formType = e.detail
|
||||
}}
|
||||
type={formType}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -4,37 +4,33 @@
|
|||
import ICONS from "components/backend/DatasourceNavigator/icons"
|
||||
import { IntegrationNames } from "constants"
|
||||
import { onMount } from "svelte"
|
||||
import rowListScreen from "templates/rowListScreen"
|
||||
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
|
||||
|
||||
export let mode
|
||||
export let onCancel
|
||||
export let onConfirm
|
||||
export let initialScreens = []
|
||||
|
||||
let selectedScreens = [...initialScreens]
|
||||
let selectedSources = []
|
||||
|
||||
$: filteredSources = $datasources.list?.filter(datasource => {
|
||||
return datasource.source !== IntegrationNames.REST && datasource["entities"]
|
||||
})
|
||||
|
||||
const toggleSelection = datasource => {
|
||||
const { resourceId } = datasource
|
||||
if (selectedScreens.find(s => s.resourceId === resourceId)) {
|
||||
selectedScreens = selectedScreens.filter(
|
||||
screen => screen.resourceId !== resourceId
|
||||
const exists = selectedSources.find(
|
||||
d => d.resourceId === datasource.resourceId
|
||||
)
|
||||
if (exists) {
|
||||
selectedSources = selectedSources.filter(
|
||||
d => d.resourceId === datasource.resourceId
|
||||
)
|
||||
} else {
|
||||
selectedScreens = [
|
||||
...selectedScreens,
|
||||
rowListScreen([datasource], mode)[0],
|
||||
]
|
||||
selectedSources = [...selectedSources, datasource]
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDatasourceSelection = async () => {
|
||||
await onConfirm({
|
||||
templates: selectedScreens,
|
||||
datasources: selectedSources,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -54,7 +50,7 @@
|
|||
cancelText="Back"
|
||||
onConfirm={confirmDatasourceSelection}
|
||||
{onCancel}
|
||||
disabled={!selectedScreens.length}
|
||||
disabled={!selectedSources.length}
|
||||
size="L"
|
||||
>
|
||||
<Body size="S">
|
||||
|
@ -85,8 +81,8 @@
|
|||
resourceId: table._id,
|
||||
type: "table",
|
||||
}}
|
||||
{@const selected = selectedScreens.find(
|
||||
screen => screen.resourceId === tableDS.resourceId
|
||||
{@const selected = selectedSources.find(
|
||||
datasource => datasource.resourceId === tableDS.resourceId
|
||||
)}
|
||||
<DatasourceTemplateRow
|
||||
on:click={() => toggleSelection(tableDS)}
|
||||
|
@ -103,7 +99,7 @@
|
|||
tableId: view.tableId,
|
||||
type: "viewV2",
|
||||
}}
|
||||
{@const selected = selectedScreens.find(
|
||||
{@const selected = selectedSources.find(
|
||||
x => x.resourceId === viewDS.resourceId
|
||||
)}
|
||||
<DatasourceTemplateRow
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
<script>
|
||||
import { ModalContent, Layout, Body, Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let onCancel = () => {}
|
||||
export let onConfirm = () => {}
|
||||
export let type
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<span>
|
||||
<ModalContent
|
||||
title="Select form type"
|
||||
confirmText="Done"
|
||||
cancelText="Back"
|
||||
{onConfirm}
|
||||
{onCancel}
|
||||
disabled={!type}
|
||||
size="L"
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<Layout noPadding gap="S">
|
||||
<div
|
||||
class="form-type"
|
||||
class:selected={type === "Create"}
|
||||
on:click={() => {
|
||||
dispatch("select", "Create")
|
||||
}}
|
||||
>
|
||||
<div class="form-type-wrap">
|
||||
<div class="form-type-content">
|
||||
<Body noPadding>Create a new row</Body>
|
||||
<Body size="S">
|
||||
For capturing and storing new data from your users
|
||||
</Body>
|
||||
</div>
|
||||
{#if type === "Create"}
|
||||
<span class="form-type-check">
|
||||
<Icon size="S" name="CheckmarkCircle" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="form-type"
|
||||
class:selected={type === "Update"}
|
||||
on:click={() => {
|
||||
dispatch("select", "Update")
|
||||
}}
|
||||
>
|
||||
<div class="form-type-wrap">
|
||||
<div class="form-type-content">
|
||||
<Body noPadding>Update an existing row</Body>
|
||||
<Body size="S">For viewing and updating existing data</Body>
|
||||
</div>
|
||||
{#if type === "Update"}
|
||||
<span class="form-type-check">
|
||||
<Icon size="S" name="CheckmarkCircle" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="form-type"
|
||||
class:selected={type === "View"}
|
||||
on:click={() => {
|
||||
dispatch("select", "View")
|
||||
}}
|
||||
>
|
||||
<div class="form-type-wrap">
|
||||
<div class="form-type-content">
|
||||
<Body noPadding>View an existing row</Body>
|
||||
<Body size="S">For a read only view of your data</Body>
|
||||
</div>
|
||||
{#if type === "View"}
|
||||
<span class="form-type-check">
|
||||
<Icon size="S" name="CheckmarkCircle" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</ModalContent>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.form-type {
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m) var(--spacing-xl);
|
||||
background: var(--spectrum-alias-background-color-secondary);
|
||||
transition: 0.3s all;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.selected,
|
||||
.form-type:hover {
|
||||
background: var(--spectrum-alias-background-color-tertiary);
|
||||
}
|
||||
.form-type-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-type :global(p:nth-child(2)) {
|
||||
color: var(--grey-6);
|
||||
}
|
||||
.form-type-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
.form-type-check :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-green-600);
|
||||
}
|
||||
.form-type-content {
|
||||
gap: var(--spacing-s);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
|
@ -9,6 +9,7 @@
|
|||
export let onCancel
|
||||
export let screenUrl
|
||||
export let screenAccessRole
|
||||
export let confirmText = "Done"
|
||||
|
||||
let error
|
||||
|
||||
|
@ -40,7 +41,7 @@
|
|||
|
||||
<ModalContent
|
||||
title="Access"
|
||||
confirmText="Done"
|
||||
{confirmText}
|
||||
cancelText="Back"
|
||||
{onConfirm}
|
||||
{onCancel}
|
||||
|
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
@ -1,9 +1,10 @@
|
|||
<script>
|
||||
import { Body } from "@budibase/bbui"
|
||||
import CreationPage from "components/common/CreationPage.svelte"
|
||||
import blankImage from "./blank.png"
|
||||
import tableImage from "./table.png"
|
||||
import gridImage from "./grid.png"
|
||||
import blankImage from "./images/blank.png"
|
||||
import tableImage from "./images/table.png"
|
||||
import gridImage from "./images/grid.png"
|
||||
import formImage from "./images/form.png"
|
||||
import CreateScreenModal from "./CreateScreenModal.svelte"
|
||||
import { screenStore } from "stores/builder"
|
||||
|
||||
|
@ -56,6 +57,16 @@
|
|||
<Body size="XS">View and manipulate rows on a grid</Body>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" on:click={() => createScreenModal.show("form")}>
|
||||
<div class="image">
|
||||
<img alt="" src={formImage} />
|
||||
</div>
|
||||
<div class="text">
|
||||
<Body size="S">Form</Body>
|
||||
<Body size="XS">Capture data from your users</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreationPage>
|
||||
</div>
|
||||
|
|
|
@ -29,6 +29,7 @@ export const INITIAL_APP_META_STATE = {
|
|||
initialised: false,
|
||||
hasAppPackage: false,
|
||||
usedPlugins: null,
|
||||
automations: {},
|
||||
routes: {},
|
||||
}
|
||||
|
||||
|
@ -63,6 +64,7 @@ export class AppMetaStore extends BudiStore {
|
|||
...app.features,
|
||||
},
|
||||
initialised: true,
|
||||
automations: app.automations || {},
|
||||
hasAppPackage: true,
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -2,12 +2,11 @@ import { get } from "svelte/store"
|
|||
import { createBuilderWebsocket } from "./websocket.js"
|
||||
import { BuilderSocketEvent } from "@budibase/shared-core"
|
||||
import BudiStore from "./BudiStore"
|
||||
import { previewStore } from "./preview.js"
|
||||
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
|
||||
|
||||
export const INITIAL_BUILDER_STATE = {
|
||||
previousTopNavPath: {},
|
||||
highlightedSettingKey: null,
|
||||
highlightedSetting: null,
|
||||
propertyFocus: null,
|
||||
builderSidePanel: false,
|
||||
onboarding: false,
|
||||
|
@ -26,7 +25,6 @@ export class BuilderStore extends BudiStore {
|
|||
this.reset = this.reset.bind(this)
|
||||
this.highlightSetting = this.highlightSetting.bind(this)
|
||||
this.propertyFocus = this.propertyFocus.bind(this)
|
||||
this.hover = this.hover.bind(this)
|
||||
this.hideBuilderSidePanel = this.hideBuilderSidePanel.bind(this)
|
||||
this.showBuilderSidePanel = this.showBuilderSidePanel.bind(this)
|
||||
this.setPreviousTopNavPath = this.setPreviousTopNavPath.bind(this)
|
||||
|
@ -58,10 +56,10 @@ export class BuilderStore extends BudiStore {
|
|||
this.websocket = null
|
||||
}
|
||||
|
||||
highlightSetting(key) {
|
||||
highlightSetting(key, type) {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
highlightedSettingKey: key,
|
||||
highlightedSetting: key ? { key, type: type || "info" } : null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -135,25 +133,20 @@ export class BuilderStore extends BudiStore {
|
|||
}))
|
||||
}
|
||||
|
||||
startTour(tourKey) {
|
||||
endBuilderOnboarding() {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
tourKey: tourKey,
|
||||
onboarding: false,
|
||||
}))
|
||||
}
|
||||
|
||||
hover(componentId, notifyClient = true) {
|
||||
const store = get(this.store)
|
||||
if (componentId === store.hoveredComponentId) {
|
||||
return
|
||||
}
|
||||
this.update(state => {
|
||||
state.hoveredComponentId = componentId
|
||||
return state
|
||||
})
|
||||
if (notifyClient) {
|
||||
previewStore.sendEvent("hover-component", componentId)
|
||||
}
|
||||
setTour(tourKey) {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
tourStepKey: null,
|
||||
tourNodes: null,
|
||||
tourKey: tourKey,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -88,14 +88,42 @@ describe("Builder store", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("Sync a highlighted setting key to state", ctx => {
|
||||
expect(ctx.test.store.highlightedSettingKey).toBeNull()
|
||||
it("Sync a highlighted setting key to state. Default to info type", ctx => {
|
||||
expect(ctx.test.store.highlightedSetting).toBeNull()
|
||||
|
||||
ctx.test.builderStore.highlightSetting("testing")
|
||||
|
||||
expect(ctx.test.store).toStrictEqual({
|
||||
...INITIAL_BUILDER_STATE,
|
||||
highlightedSettingKey: "testing",
|
||||
highlightedSetting: {
|
||||
key: "testing",
|
||||
type: "info",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("Sync a highlighted setting key to state. Use provided type", ctx => {
|
||||
expect(ctx.test.store.highlightedSetting).toBeNull()
|
||||
|
||||
ctx.test.builderStore.highlightSetting("testing", "error")
|
||||
|
||||
expect(ctx.test.store).toStrictEqual({
|
||||
...INITIAL_BUILDER_STATE,
|
||||
highlightedSetting: {
|
||||
key: "testing",
|
||||
type: "error",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("Sync a highlighted setting key to state. Unset when no value is passed", ctx => {
|
||||
expect(ctx.test.store.highlightedSetting).toBeNull()
|
||||
|
||||
ctx.test.builderStore.highlightSetting("testing", "error")
|
||||
ctx.test.builderStore.highlightSetting()
|
||||
|
||||
expect(ctx.test.store).toStrictEqual({
|
||||
...INITIAL_BUILDER_STATE,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { Screen } from "./Screen"
|
||||
import { Component } from "./Component"
|
||||
import sanitizeUrl from "helpers/sanitizeUrl"
|
||||
|
||||
export const FORM_TEMPLATE = "FORM_TEMPLATE"
|
||||
export const formUrl = datasource => sanitizeUrl(`/${datasource.label}-form`)
|
||||
|
||||
// Mode not really necessary
|
||||
export default function (datasources, config) {
|
||||
if (!Array.isArray(datasources)) {
|
||||
return []
|
||||
}
|
||||
return datasources.map(datasource => {
|
||||
return {
|
||||
name: `${datasource.label} - Form`,
|
||||
create: () => createScreen(datasource, config),
|
||||
id: FORM_TEMPLATE,
|
||||
resourceId: datasource.resourceId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const generateMultistepFormBlock = (dataSource, { actionType } = {}) => {
|
||||
const multistepFormBlock = new Component(
|
||||
"@budibase/standard-components/multistepformblock"
|
||||
)
|
||||
multistepFormBlock
|
||||
.customProps({
|
||||
actionType,
|
||||
dataSource,
|
||||
steps: [{}],
|
||||
})
|
||||
.instanceName(`${dataSource.label} - Multistep Form block`)
|
||||
return multistepFormBlock
|
||||
}
|
||||
|
||||
const createScreen = (datasource, config) => {
|
||||
return new Screen()
|
||||
.route(formUrl(datasource))
|
||||
.instanceName(`${datasource.label} - Form`)
|
||||
.addChild(generateMultistepFormBlock(datasource, config))
|
||||
.json()
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import rowListScreen from "./rowListScreen"
|
||||
import createFromScratchScreen from "./createFromScratchScreen"
|
||||
import formScreen from "./formScreen"
|
||||
|
||||
const allTemplates = datasources => [...rowListScreen(datasources)]
|
||||
const allTemplates = datasources => [
|
||||
...rowListScreen(datasources),
|
||||
...formScreen(datasources),
|
||||
]
|
||||
|
||||
// Allows us to apply common behaviour to all create() functions
|
||||
const createTemplateOverride = template => () => {
|
||||
|
@ -19,6 +23,7 @@ export default datasources => {
|
|||
})
|
||||
const fromScratch = enrichTemplate(createFromScratchScreen)
|
||||
const tableTemplates = allTemplates(datasources).map(enrichTemplate)
|
||||
|
||||
return [
|
||||
fromScratch,
|
||||
...tableTemplates.sort((templateA, templateB) => {
|
||||
|
|
|
@ -525,6 +525,38 @@
|
|||
"barTitle": "Disable button",
|
||||
"key": "disabled"
|
||||
},
|
||||
{
|
||||
"type": "icon",
|
||||
"label": "Icon",
|
||||
"key": "icon"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Gap",
|
||||
"key": "gap",
|
||||
"showInBar": true,
|
||||
"barStyle": "picker",
|
||||
"dependsOn": "icon",
|
||||
"options": [
|
||||
{
|
||||
"label": "None",
|
||||
"value": "N"
|
||||
},
|
||||
{
|
||||
"label": "Small",
|
||||
"value": "S"
|
||||
},
|
||||
{
|
||||
"label": "Medium",
|
||||
"value": "M"
|
||||
},
|
||||
{
|
||||
"label": "Large",
|
||||
"value": "L"
|
||||
}
|
||||
],
|
||||
"defaultValue": "M"
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"label": "On click",
|
||||
|
|
|
@ -13,9 +13,10 @@
|
|||
export let size = "M"
|
||||
export let type = "cta"
|
||||
export let quiet = false
|
||||
export let icon = null
|
||||
export let gap = "M"
|
||||
|
||||
// For internal use only for now - not defined in the manifest
|
||||
export let icon = null
|
||||
export let active = false
|
||||
|
||||
const handleOnClick = async () => {
|
||||
|
@ -47,7 +48,7 @@
|
|||
|
||||
{#key $component.editing}
|
||||
<button
|
||||
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`}
|
||||
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type} gap-${gap}`}
|
||||
class:spectrum-Button--quiet={quiet}
|
||||
disabled={disabled || handlingOnClick}
|
||||
use:styleable={$component.styles}
|
||||
|
@ -58,15 +59,7 @@
|
|||
class:active
|
||||
>
|
||||
{#if icon}
|
||||
<svg
|
||||
class:hasText={componentText?.length > 0}
|
||||
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
aria-label={icon}
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
<i class="{icon} {size}" />
|
||||
{/if}
|
||||
{componentText}
|
||||
</button>
|
||||
|
@ -92,4 +85,13 @@
|
|||
.active {
|
||||
color: var(--spectrum-global-color-blue-600);
|
||||
}
|
||||
.gap-S {
|
||||
gap: 8px;
|
||||
}
|
||||
.gap-M {
|
||||
gap: 16px;
|
||||
}
|
||||
.gap-L {
|
||||
gap: 32px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
wrap: true,
|
||||
}}
|
||||
>
|
||||
{#each buttons as { text, type, quiet, disabled, onClick, size }}
|
||||
{#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
|
||||
<BlockComponent
|
||||
type="button"
|
||||
props={{
|
||||
|
@ -29,6 +29,8 @@
|
|||
type,
|
||||
quiet,
|
||||
disabled,
|
||||
icon,
|
||||
gap,
|
||||
size: size || "M",
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -92,9 +92,9 @@
|
|||
{#if schemaLoaded}
|
||||
<Button
|
||||
onClick={openEditor}
|
||||
icon="Properties"
|
||||
icon="ri-filter-3-line"
|
||||
text="Filter"
|
||||
{size}
|
||||
size="XL"
|
||||
type="secondary"
|
||||
quiet
|
||||
active={filters?.length > 0}
|
||||
|
|
|
@ -149,7 +149,7 @@
|
|||
"rimraf": "3.0.2",
|
||||
"supertest": "6.3.3",
|
||||
"swagger-jsdoc": "6.1.0",
|
||||
"testcontainers": "10.6.0",
|
||||
"testcontainers": "10.7.2",
|
||||
"timekeeper": "2.2.0",
|
||||
"ts-node": "10.8.1",
|
||||
"tsconfig-paths": "4.0.0",
|
||||
|
|
|
@ -390,24 +390,41 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
|
|||
}
|
||||
)
|
||||
|
||||
// Match a document against all criteria
|
||||
const docMatch = (doc: any) => {
|
||||
return (
|
||||
stringMatch(doc) &&
|
||||
fuzzyMatch(doc) &&
|
||||
rangeMatch(doc) &&
|
||||
equalMatch(doc) &&
|
||||
notEqualMatch(doc) &&
|
||||
emptyMatch(doc) &&
|
||||
notEmptyMatch(doc) &&
|
||||
oneOf(doc) &&
|
||||
contains(doc) &&
|
||||
containsAny(doc) &&
|
||||
notContains(doc)
|
||||
)
|
||||
}
|
||||
const filterFunctions: Record<SearchQueryOperators, (doc: any) => boolean> =
|
||||
{
|
||||
string: stringMatch,
|
||||
fuzzy: fuzzyMatch,
|
||||
range: rangeMatch,
|
||||
equal: equalMatch,
|
||||
notEqual: notEqualMatch,
|
||||
empty: emptyMatch,
|
||||
notEmpty: notEmptyMatch,
|
||||
oneOf: oneOf,
|
||||
contains: contains,
|
||||
containsAny: containsAny,
|
||||
notContains: notContains,
|
||||
}
|
||||
|
||||
// Process all docs
|
||||
const activeFilterKeys: SearchQueryOperators[] = Object.entries(query || {})
|
||||
.filter(
|
||||
([key, value]: [string, any]) =>
|
||||
!["allOr", "onEmptyFilter"].includes(key) &&
|
||||
value &&
|
||||
Object.keys(value as Record<string, any>).length > 0
|
||||
)
|
||||
.map(([key]) => key as any)
|
||||
|
||||
const results: boolean[] = activeFilterKeys.map(filterKey => {
|
||||
return filterFunctions[filterKey]?.(doc) ?? false
|
||||
})
|
||||
|
||||
if (query!.allOr) {
|
||||
return results.some(result => result === true)
|
||||
} else {
|
||||
return results.every(result => result === true)
|
||||
}
|
||||
}
|
||||
return docs.filter(docMatch)
|
||||
}
|
||||
|
||||
|
|
|
@ -47,10 +47,7 @@ describe("runLuceneQuery", () => {
|
|||
},
|
||||
]
|
||||
|
||||
function buildQuery(
|
||||
filterKey: string,
|
||||
value: { [key: string]: any }
|
||||
): SearchQuery {
|
||||
function buildQuery(filters: { [filterKey: string]: any }): SearchQuery {
|
||||
const query: SearchQuery = {
|
||||
string: {},
|
||||
fuzzy: {},
|
||||
|
@ -63,8 +60,13 @@ describe("runLuceneQuery", () => {
|
|||
notContains: {},
|
||||
oneOf: {},
|
||||
containsAny: {},
|
||||
allOr: false,
|
||||
}
|
||||
query[filterKey as SearchQueryOperators] = value
|
||||
|
||||
for (const filterKey in filters) {
|
||||
query[filterKey as SearchQueryOperators] = filters[filterKey]
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
|
@ -73,16 +75,17 @@ describe("runLuceneQuery", () => {
|
|||
})
|
||||
|
||||
it("should return matching rows for equal filter", () => {
|
||||
const query = buildQuery("equal", {
|
||||
order_status: 4,
|
||||
const query = buildQuery({
|
||||
equal: { order_status: 4 },
|
||||
})
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it("should return matching row for notEqual filter", () => {
|
||||
const query = buildQuery("notEqual", {
|
||||
order_status: 4,
|
||||
const query = buildQuery({
|
||||
notEqual: { order_status: 4 },
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
|
||||
})
|
||||
|
||||
|
@ -90,48 +93,56 @@ describe("runLuceneQuery", () => {
|
|||
expect(
|
||||
runLuceneQuery(
|
||||
docs,
|
||||
buildQuery("fuzzy", {
|
||||
description: "sm",
|
||||
buildQuery({
|
||||
fuzzy: { description: "sm" },
|
||||
})
|
||||
).map(row => row.description)
|
||||
).toEqual(["Small box"])
|
||||
expect(
|
||||
runLuceneQuery(
|
||||
docs,
|
||||
buildQuery("string", {
|
||||
description: "SM",
|
||||
buildQuery({
|
||||
string: { description: "SM" },
|
||||
})
|
||||
).map(row => row.description)
|
||||
).toEqual(["Small box"])
|
||||
})
|
||||
|
||||
it("should return rows within a range filter", () => {
|
||||
const query = buildQuery("range", {
|
||||
customer_id: {
|
||||
low: 500,
|
||||
high: 1000,
|
||||
const query = buildQuery({
|
||||
range: {
|
||||
customer_id: {
|
||||
low: 500,
|
||||
high: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
|
||||
})
|
||||
|
||||
it("should return rows with numeric strings within a range filter", () => {
|
||||
const query = buildQuery("range", {
|
||||
customer_id: {
|
||||
low: "500",
|
||||
high: "1000",
|
||||
const query = buildQuery({
|
||||
range: {
|
||||
customer_id: {
|
||||
low: "500",
|
||||
high: "1000",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
|
||||
})
|
||||
|
||||
it("should return rows with ISO date strings within a range filter", () => {
|
||||
const query = buildQuery("range", {
|
||||
order_date: {
|
||||
low: "2016-01-04T00:00:00.000Z",
|
||||
high: "2016-01-11T00:00:00.000Z",
|
||||
const query = buildQuery({
|
||||
range: {
|
||||
order_date: {
|
||||
low: "2016-01-04T00:00:00.000Z",
|
||||
high: "2016-01-11T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
|
||||
})
|
||||
|
||||
|
@ -150,40 +161,88 @@ describe("runLuceneQuery", () => {
|
|||
label: "",
|
||||
},
|
||||
]
|
||||
const query = buildQuery("range", {
|
||||
order_date: {
|
||||
low: "2016-01-04T00:00:00.000Z",
|
||||
high: "2016-01-11T00:00:00.000Z",
|
||||
|
||||
const query = buildQuery({
|
||||
range: {
|
||||
order_date: {
|
||||
low: "2016-01-04T00:00:00.000Z",
|
||||
high: "2016-01-11T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query)).toEqual(docs)
|
||||
})
|
||||
|
||||
it("should return rows with matches on empty filter", () => {
|
||||
const query = buildQuery("empty", {
|
||||
label: null,
|
||||
const query = buildQuery({
|
||||
empty: {
|
||||
label: null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1])
|
||||
})
|
||||
|
||||
it("should return rows with matches on notEmpty filter", () => {
|
||||
const query = buildQuery("notEmpty", {
|
||||
label: null,
|
||||
const query = buildQuery({
|
||||
notEmpty: {
|
||||
label: null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
test.each([[523, 259], "523,259"])(
|
||||
"should return rows with matches on numeric oneOf filter",
|
||||
input => {
|
||||
let query = buildQuery("oneOf", {
|
||||
customer_id: input,
|
||||
const query = buildQuery({
|
||||
oneOf: {
|
||||
customer_id: input,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.customer_id)).toEqual([
|
||||
259, 523,
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
[false, []],
|
||||
[true, [1, 2, 3]],
|
||||
])("should return %s if allOr is %s ", (allOr, expectedResult) => {
|
||||
const query = buildQuery({
|
||||
allOr,
|
||||
oneOf: { staff_id: [10] },
|
||||
contains: { description: ["box"] },
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual(
|
||||
expectedResult
|
||||
)
|
||||
})
|
||||
|
||||
it("should return matching results if allOr is true and only one filter matches with different operands", () => {
|
||||
const query = buildQuery({
|
||||
allOr: true,
|
||||
equal: { order_status: 4 },
|
||||
oneOf: { label: ["FRAGILE"] },
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it("should handle when a value is null or undefined", () => {
|
||||
const query = buildQuery({
|
||||
allOr: true,
|
||||
equal: { order_status: null },
|
||||
oneOf: { label: ["FRAGILE"] },
|
||||
})
|
||||
|
||||
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildLuceneQuery", () => {
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
"@budibase/nano": "10.1.5",
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/pouchdb": "6.4.0",
|
||||
"@types/redlock": "4.0.3",
|
||||
"@types/redlock": "4.0.7",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface UpdateSelfRequest {
|
|||
password?: string
|
||||
forceResetPassword?: boolean
|
||||
onboardedAt?: string
|
||||
tours?: Record<string, Date>
|
||||
}
|
||||
|
||||
export interface UpdateSelfResponse {
|
||||
|
|
|
@ -55,6 +55,7 @@ export interface User extends Document {
|
|||
dayPassRecordedAt?: string
|
||||
userGroups?: string[]
|
||||
onboardedAt?: string
|
||||
tours?: Record<string, Date>
|
||||
scimInfo?: { isSync: true } & Record<string, any>
|
||||
ssoId?: string
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ export const buildSelfSaveValidation = () => {
|
|||
firstName: OPTIONAL_STRING,
|
||||
lastName: OPTIONAL_STRING,
|
||||
onboardedAt: Joi.string().optional(),
|
||||
tours: Joi.object().optional(),
|
||||
}
|
||||
return auth.joiValidator.body(Joi.object(schema).required().unknown(false))
|
||||
}
|
||||
|
|
94
yarn.lock
|
@ -5303,10 +5303,10 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/dockerode@^3.3.21":
|
||||
version "3.3.23"
|
||||
resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.23.tgz#07b2084013d01e14d5d97856446f4d9c9f27c223"
|
||||
integrity sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==
|
||||
"@types/dockerode@^3.3.24":
|
||||
version "3.3.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.24.tgz#bea354a4fcd0824a80fd5ea5ede3e8cda71137a7"
|
||||
integrity sha512-679y69OYusf7Fr2HtdjXPUF6hnHxSA9K4EsuagsMuPno/XpJHjXxCOy2I5YL8POnWbzjsQAi0pyKIYM9HSpQog==
|
||||
dependencies:
|
||||
"@types/docker-modem" "*"
|
||||
"@types/node" "*"
|
||||
|
@ -5408,7 +5408,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.1.tgz#20172f9578b225f6c7da63446f56d4ce108d5a65"
|
||||
integrity sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==
|
||||
|
||||
"@types/ioredis@4.28.10":
|
||||
"@types/ioredis@4.28.10", "@types/ioredis@^4.28.10":
|
||||
version "4.28.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.28.10.tgz#40ceb157a4141088d1394bb87c98ed09a75a06ff"
|
||||
integrity sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==
|
||||
|
@ -5896,12 +5896,13 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/redlock@4.0.3":
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/redlock/-/redlock-4.0.3.tgz#aeab5fe5f0d433a125f6dcf9a884372ac0cddd4b"
|
||||
integrity sha512-mcvvrquwREbAqyZALNBIlf49AL9Aa324BG+J/Dv4TAP8g+nxQMBI4/APNqqS99QEY7VTNT9XvsaczCVGK8uNnQ==
|
||||
"@types/redlock@4.0.7":
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/redlock/-/redlock-4.0.7.tgz#33ed56f22a38d6b2f2e6ae5ed1b3fc1875a08e6b"
|
||||
integrity sha512-5D6egBv0fCfdbmnCETjEynVuiwFMEFFc3YFjh9EwhaaVTAi0YmB6UI1swq1S1rjIu+n27ppmlTFDK3D3cadJqg==
|
||||
dependencies:
|
||||
"@types/bluebird" "*"
|
||||
"@types/ioredis" "^4.28.10"
|
||||
"@types/redis" "^2.8.0"
|
||||
|
||||
"@types/request@^2.48.7":
|
||||
|
@ -7026,7 +7027,7 @@ ast-types@0.9.6:
|
|||
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
|
||||
integrity sha512-qEdtR2UH78yyHX/AUNfXmJTlM48XoFZKBdwi1nzkI1mJL21cmbu0cvjxjpkXJ5NENMq42H+hNs8VLJcqXLerBQ==
|
||||
|
||||
async-lock@^1.4.0:
|
||||
async-lock@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
|
||||
integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
|
||||
|
@ -7262,6 +7263,33 @@ balanced-match@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
bare-events@^2.0.0, bare-events@^2.2.0:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.1.tgz#7b6d421f26a7a755e20bf580b727c84b807964c1"
|
||||
integrity sha512-9GYPpsPFvrWBkelIhOhTWtkeZxVxZOdb3VnFTCzlOo3OjvmTvzLoZFUT8kNFACx0vJej6QPney1Cf9BvzCNE/A==
|
||||
|
||||
bare-fs@^2.1.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-2.2.1.tgz#c1985d8d3e07a178956b072d3af67cb8c1fa9391"
|
||||
integrity sha512-+CjmZANQDFZWy4PGbVdmALIwmt33aJg8qTkVjClU6X4WmZkTPBDxRHiBn7fpqEWEfF3AC2io++erpViAIQbSjg==
|
||||
dependencies:
|
||||
bare-events "^2.0.0"
|
||||
bare-os "^2.0.0"
|
||||
bare-path "^2.0.0"
|
||||
streamx "^2.13.0"
|
||||
|
||||
bare-os@^2.0.0, bare-os@^2.1.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-2.2.0.tgz#24364692984d0bd507621754781b31d7872736b2"
|
||||
integrity sha512-hD0rOPfYWOMpVirTACt4/nK8mC55La12K5fY1ij8HAdfQakD62M+H4o4tpfKzVGLgRDTuk3vjA4GqGXXCeFbag==
|
||||
|
||||
bare-path@^2.0.0, bare-path@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-2.1.0.tgz#830f17fd39842813ca77d211ebbabe238a88cb4c"
|
||||
integrity sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==
|
||||
dependencies:
|
||||
bare-os "^2.1.0"
|
||||
|
||||
base62@^1.1.0:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/base62/-/base62-1.2.8.tgz#1264cb0fb848d875792877479dbe8bae6bae3428"
|
||||
|
@ -9614,10 +9642,10 @@ docker-compose@^0.23.5, docker-compose@^0.23.6:
|
|||
dependencies:
|
||||
yaml "^1.10.2"
|
||||
|
||||
docker-compose@^0.24.2:
|
||||
version "0.24.3"
|
||||
resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-0.24.3.tgz#298d7bb4aaf37b3b45d0e4ef55c7f58ccc39cca9"
|
||||
integrity sha512-x3/QN3AIOMe7j2c8f/jcycizMft7dl8MluoB9OGPAYCyKHHiPUFqI9GjCcsU0kYy24vYKMCcfR6+5ZaEyQlrxg==
|
||||
docker-compose@^0.24.6:
|
||||
version "0.24.6"
|
||||
resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-0.24.6.tgz#d1f490a641bdb7ccc07c4d446b264f026f9a1f15"
|
||||
integrity sha512-VidlUyNzXMaVsuM79sjSvwC4nfojkP2VneL+Zfs538M2XFnffZDhx6veqnz/evCNIYGyz5O+1fgL6+g0NLWTBA==
|
||||
dependencies:
|
||||
yaml "^2.2.2"
|
||||
|
||||
|
@ -20178,6 +20206,16 @@ stream-to-array@^2.3.0:
|
|||
dependencies:
|
||||
any-promise "^1.1.0"
|
||||
|
||||
streamx@^2.13.0:
|
||||
version "2.16.1"
|
||||
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.16.1.tgz#2b311bd34832f08aa6bb4d6a80297c9caef89614"
|
||||
integrity sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==
|
||||
dependencies:
|
||||
fast-fifo "^1.1.0"
|
||||
queue-tick "^1.0.1"
|
||||
optionalDependencies:
|
||||
bare-events "^2.2.0"
|
||||
|
||||
streamx@^2.15.0:
|
||||
version "2.15.6"
|
||||
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.6.tgz#28bf36997ebc7bf6c08f9eba958735231b833887"
|
||||
|
@ -20729,14 +20767,16 @@ tar-fs@2.1.1, tar-fs@^2.0.0, tar-fs@^2.1.0:
|
|||
pump "^3.0.0"
|
||||
tar-stream "^2.1.4"
|
||||
|
||||
tar-fs@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf"
|
||||
integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==
|
||||
tar-fs@^3.0.5:
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.5.tgz#f954d77767e4e6edf973384e1eb95f8f81d64ed9"
|
||||
integrity sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==
|
||||
dependencies:
|
||||
mkdirp-classic "^0.5.2"
|
||||
pump "^3.0.0"
|
||||
tar-stream "^3.1.5"
|
||||
optionalDependencies:
|
||||
bare-fs "^2.1.1"
|
||||
bare-path "^2.1.0"
|
||||
|
||||
tar-fs@~2.0.1:
|
||||
version "2.0.1"
|
||||
|
@ -20874,25 +20914,25 @@ test-exclude@^6.0.0:
|
|||
glob "^7.1.4"
|
||||
minimatch "^3.0.4"
|
||||
|
||||
testcontainers@10.6.0:
|
||||
version "10.6.0"
|
||||
resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.6.0.tgz#205ad9148e68ff5c43a6209a30b12965acbe89d8"
|
||||
integrity sha512-FDJ3o3J8IMu1V7Uc6lNZ2MAD8+BV4HdpR/Vf5mHtgYHKdn6k1EbGFwtnvVNOxanJ99FCjf/EU8eA5ZQ4yjlsGA==
|
||||
testcontainers@10.7.2, testcontainers@^10.7.2:
|
||||
version "10.7.2"
|
||||
resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.7.2.tgz#619e93200dd47f174b307b40fa830cf023b74c25"
|
||||
integrity sha512-7d+LVd/4YKp/cutiVMLL5cnj/8p8oYELAVRRyNUM4FyUDz1OLQuwW868nDl7Vd1ZAQxzGeCR+F86FlR9Yw9fMA==
|
||||
dependencies:
|
||||
"@balena/dockerignore" "^1.0.2"
|
||||
"@types/dockerode" "^3.3.21"
|
||||
"@types/dockerode" "^3.3.24"
|
||||
archiver "^5.3.2"
|
||||
async-lock "^1.4.0"
|
||||
async-lock "^1.4.1"
|
||||
byline "^5.0.0"
|
||||
debug "^4.3.4"
|
||||
docker-compose "^0.24.2"
|
||||
docker-compose "^0.24.6"
|
||||
dockerode "^3.3.5"
|
||||
get-port "^5.1.1"
|
||||
node-fetch "^2.7.0"
|
||||
proper-lockfile "^4.1.2"
|
||||
properties-reader "^2.3.0"
|
||||
ssh-remote-port-forward "^1.0.4"
|
||||
tar-fs "^3.0.4"
|
||||
tar-fs "^3.0.5"
|
||||
tmp "^0.2.1"
|
||||
|
||||
testcontainers@4.7.0:
|
||||
|
|