Merge branch 'master' into ts/form

This commit is contained in:
Adria Navarro 2025-03-17 16:07:03 +01:00
commit 3e9357b2e8
339 changed files with 6975 additions and 4183 deletions

View File

@ -165,6 +165,7 @@ jobs:
oracle,
sqs,
elasticsearch,
dynamodb,
none,
]
steps:
@ -205,6 +206,8 @@ jobs:
docker pull postgres:9.5.25
elif [ "${{ matrix.datasource }}" == "elasticsearch" ]; then
docker pull elasticsearch@${{ steps.dotenv.outputs.ELASTICSEARCH_SHA }}
elif [ "${{ matrix.datasource }}" == "dynamodb" ]; then
docker pull amazon/dynamodb-local@${{ steps.dotenv.outputs.DYNAMODB_SHA }}
fi
docker pull minio/minio &
docker pull redis &

View File

@ -88,6 +88,16 @@ export default async function setup() {
content: `
[log]
level = warn
[httpd]
socket_options = [{nodelay, true}]
[couchdb]
single_node = true
[cluster]
n = 1
q = 1
`,
target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
},

View File

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

View File

@ -18,7 +18,6 @@
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-local-rules": "3.0.2",
"eslint-plugin-svelte": "2.46.1",
"svelte-preprocess": "^6.0.3",
"husky": "^8.0.3",
"kill-port": "^1.6.1",
"lerna": "7.4.2",
@ -29,7 +28,9 @@
"prettier-plugin-svelte": "^2.3.0",
"proper-lockfile": "^4.1.2",
"svelte": "4.2.19",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "0.43.0",
"svelte-preprocess": "^6.0.3",
"typescript": "5.7.2",
"typescript-eslint": "8.17.0",
"yargs": "^17.7.2"

View File

@ -222,9 +222,12 @@ export class DatabaseImpl implements Database {
}
async getMultiple<T extends Document>(
ids: string[],
ids?: string[],
opts?: { allowMissing?: boolean; excludeDocs?: boolean }
): Promise<T[]> {
if (!ids || ids.length === 0) {
return []
}
// get unique
ids = [...new Set(ids)]
const includeDocs = !opts?.excludeDocs
@ -249,7 +252,7 @@ export class DatabaseImpl implements Database {
if (!opts?.allowMissing && someMissing) {
const missing = response.rows.filter(row => rowUnavailable(row))
const missingIds = missing.map(row => row.key).join(", ")
throw new Error(`Unable to get documents: ${missingIds}`)
throw new Error(`Unable to get bulk documents: ${missingIds}`)
}
return rows.map(row => (includeDocs ? row.doc! : row.value))
}

View File

@ -52,13 +52,13 @@ export class DDInstrumentedDatabase implements Database {
}
getMultiple<T extends Document>(
ids: string[],
ids?: string[],
opts?: { allowMissing?: boolean | undefined } | undefined
): Promise<T[]> {
return tracer.trace("db.getMultiple", async span => {
span.addTags({
db_name: this.name,
num_docs: ids.length,
num_docs: ids?.length || 0,
allow_missing: opts?.allowMissing,
})
const docs = await this.db.getMultiple<T>(ids, opts)

View File

@ -147,9 +147,7 @@ export class FlagSet<T extends { [name: string]: boolean }> {
for (const [name, value] of Object.entries(posthogFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
console.warn(`Unexpected posthog flag "${name}": ${value}`)
// We don't want an unexpected PostHog flag to break the app
continue
}

View File

@ -478,7 +478,7 @@ export async function deleteFolder(
if (existingObjectsResponse.Contents?.length === 0) {
return
}
const deleteParams: any = {
const deleteParams: { Bucket: string; Delete: { Objects: any[] } } = {
Bucket: bucketName,
Delete: {
Objects: [],
@ -489,10 +489,12 @@ export async function deleteFolder(
deleteParams.Delete.Objects.push({ Key: content.Key })
})
const deleteResponse = await client.deleteObjects(deleteParams)
// can only empty 1000 items at once
if (deleteResponse.Deleted?.length === 1000) {
return deleteFolder(bucketName, folder)
if (deleteParams.Delete.Objects.length) {
const deleteResponse = await client.deleteObjects(deleteParams)
// can only empty 1000 items at once
if (deleteResponse.Deleted?.length === 1000) {
return deleteFolder(bucketName, folder)
}
}
}

View File

@ -3,7 +3,6 @@ import { newid } from "../utils"
import { Queue, QueueOptions, JobOptions } from "./queue"
import { helpers } from "@budibase/shared-core"
import { Job, JobId, JobInformation } from "bull"
import { cloneDeep } from "lodash"
function jobToJobInformation(job: Job): JobInformation {
let cron = ""
@ -88,9 +87,7 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
*/
async process(concurrencyOrFunc: number | any, func?: any) {
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
this._emitter.on("message", async msg => {
const message = cloneDeep(msg)
this._emitter.on("message", async message => {
// For the purpose of testing, don't trigger cron jobs immediately.
// Require the test to trigger them manually with timestamps.
if (!message.manualTrigger && message.opts?.repeat != null) {
@ -165,6 +162,9 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
opts,
}
this._messages.push(message)
if (this._messages.length > 1000) {
this._messages.shift()
}
this._addCount++
this._emitter.emit("message", message)
}

View File

@ -26,8 +26,9 @@ import {
import {
getAccountHolderFromUsers,
isAdmin,
isCreator,
creatorsInList,
validateUniqueUser,
isCreatorAsync,
} from "./utils"
import {
getFirstPlatformUser,
@ -261,8 +262,16 @@ export class UserDB {
}
const change = dbUser ? 0 : 1 // no change if there is existing user
const creatorsChange =
(await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0
let creatorsChange = 0
if (dbUser) {
const [isDbUserCreator, isUserCreator] = await creatorsInList([
dbUser,
user,
])
creatorsChange = isDbUserCreator !== isUserCreator ? 1 : 0
}
return UserDB.quotas.addUsers(change, creatorsChange, async () => {
if (!opts.isAccountHolder) {
await validateUniqueUser(email, tenantId)
@ -353,7 +362,7 @@ export class UserDB {
}
newUser.userGroups = groups || []
newUsers.push(newUser)
if (await isCreator(newUser)) {
if (await isCreatorAsync(newUser)) {
newCreators.push(newUser)
}
}
@ -453,10 +462,8 @@ export class UserDB {
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsEval = await Promise.all(usersToDelete.map(isCreator))
const creatorsToDeleteCount = creatorsEval.filter(
creator => !!creator
).length
const creatorsEval = await creatorsInList(usersToDelete)
const creatorsToDeleteCount = creatorsEval.filter(creator => creator).length
const ssoUsersToDelete: AnyDocument[] = []
for (let user of usersToDelete) {
@ -533,7 +540,7 @@ export class UserDB {
await db.remove(userId, dbUser._rev!)
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
const creatorsToDelete = (await isCreatorAsync(dbUser)) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)

View File

@ -2,39 +2,39 @@ import { User, UserGroup } from "@budibase/types"
import { generator, structures } from "../../../tests"
import { DBTestConfiguration } from "../../../tests/extra"
import { getGlobalDB } from "../../context"
import { isCreator } from "../utils"
import { isCreatorSync, creatorsInList } from "../utils"
const config = new DBTestConfiguration()
describe("Users", () => {
it("User is a creator if it is configured as a global builder", async () => {
it("User is a creator if it is configured as a global builder", () => {
const user: User = structures.users.user({ builder: { global: true } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it is configured as a global admin", async () => {
it("User is a creator if it is configured as a global admin", () => {
const user: User = structures.users.user({ admin: { global: true } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it is configured with creator permission", async () => {
it("User is a creator if it is configured with creator permission", () => {
const user: User = structures.users.user({ builder: { creator: true } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it is a builder in some application", async () => {
it("User is a creator if it is a builder in some application", () => {
const user: User = structures.users.user({ builder: { apps: ["app1"] } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it has CREATOR permission in some application", async () => {
it("User is a creator if it has CREATOR permission in some application", () => {
const user: User = structures.users.user({ roles: { app1: "CREATOR" } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it has ADMIN permission in some application", async () => {
it("User is a creator if it has ADMIN permission in some application", () => {
const user: User = structures.users.user({ roles: { app1: "ADMIN" } })
expect(await isCreator(user)).toBe(true)
expect(isCreatorSync(user, [])).toBe(true)
})
it("User is a creator if it remains to a group with ADMIN permissions", async () => {
@ -59,7 +59,7 @@ describe("Users", () => {
await db.put(group)
for (let user of users) {
await db.put(user)
const creator = await isCreator(user)
const creator = (await creatorsInList([user]))[0]
expect(creator).toBe(true)
}
})

View File

@ -22,7 +22,7 @@ import {
} from "@budibase/types"
import * as context from "../context"
import { getGlobalDB } from "../context"
import { isCreator } from "./utils"
import { creatorsInList } from "./utils"
import { UserDB } from "./db"
import { dataFilters } from "@budibase/shared-core"
@ -305,8 +305,8 @@ export async function getCreatorCount() {
let creators = 0
async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage })
const creatorsEval = await Promise.all(page.data.map(isCreator))
creators += creatorsEval.filter(creator => !!creator).length
const creatorsEval = await creatorsInList(page.data)
creators += creatorsEval.filter(creator => creator).length
if (page.hasNextPage) {
await iterate(page.nextPage)
}

View File

@ -16,30 +16,47 @@ export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
export async function isCreator(user?: User | ContextUser) {
export async function creatorsInList(
users: (User | ContextUser)[],
groups?: UserGroup[]
) {
const groupIds = [
...new Set(
users.filter(user => user.userGroups).flatMap(user => user.userGroups!)
),
]
const db = context.getGlobalDB()
groups = await db.getMultiple<UserGroup>(groupIds, { allowMissing: true })
return users.map(user => isCreatorSync(user, groups))
}
// fetches groups if no provided, but is async and shouldn't be looped with
export async function isCreatorAsync(user: User | ContextUser) {
let groups: UserGroup[] = []
if (user.userGroups) {
const db = context.getGlobalDB()
groups = await db.getMultiple<UserGroup>(user.userGroups)
}
return isCreatorSync(user, groups)
}
export function isCreatorSync(user: User | ContextUser, groups?: UserGroup[]) {
const isCreatorByUserDefinition = sdk.users.isCreator(user)
if (!isCreatorByUserDefinition && user) {
return await isCreatorByGroupMembership(user)
return isCreatorByGroupMembership(user, groups)
}
return isCreatorByUserDefinition
}
async function isCreatorByGroupMembership(user?: User | ContextUser) {
const userGroups = user?.userGroups || []
if (userGroups.length > 0) {
const db = context.getGlobalDB()
const groups: UserGroup[] = []
for (let groupId of userGroups) {
try {
const group = await db.get<UserGroup>(groupId)
groups.push(group)
} catch (e: any) {
if (e.error !== "not_found") {
throw e
}
}
}
return groups.some(group =>
function isCreatorByGroupMembership(
user: User | ContextUser,
groups?: UserGroup[]
) {
const userGroups = groups?.filter(
group => user.userGroups?.indexOf(group._id!) !== -1
)
if (userGroups && userGroups.length > 0) {
return userGroups.some(group =>
Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN)
)
}

View File

@ -1,52 +0,0 @@
import { range } from "lodash/fp"
import { structures } from "../.."
jest.mock("../../../src/context")
jest.mock("../../../src/db")
import * as context from "../../../src/context"
import * as db from "../../../src/db"
import { getCreatorCount } from "../../../src/users/users"
describe("Users", () => {
let getGlobalDBMock: jest.SpyInstance
let paginationMock: jest.SpyInstance
beforeEach(() => {
jest.resetAllMocks()
getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
paginationMock = jest.spyOn(db, "pagination")
jest.spyOn(db, "getGlobalUserParams")
})
it("retrieves the number of creators", async () => {
const getUsers = (offset: number, limit: number, creators = false) => {
const opts = creators ? { builder: { global: true } } : undefined
return range(offset, limit).map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)
getGlobalDBMock.mockImplementation(() => ({
name: "fake-db",
allDocs: () => ({
rows: [...page1Data, ...page2Data],
}),
}))
paginationMock.mockImplementationOnce(() => ({
data: page1Data,
hasNextPage: true,
nextPage: "1",
}))
paginationMock.mockImplementation(() => ({
data: page2Data,
hasNextPage: false,
nextPage: undefined,
}))
const creatorsCount = await getCreatorCount()
expect(creatorsCount).toBe(4)
expect(paginationMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -5,10 +5,10 @@
export let disabled = false
export let align = "left"
export let portalTarget
export let portalTarget = undefined
export let openOnHover = false
export let animate
export let offset
export let animate = true
export let offset = undefined
const actionMenuContext = getContext("actionMenu")

View File

@ -14,7 +14,7 @@
export let url = ""
export let disabled = false
export let initials = "JD"
export let color = null
export let color = ""
const DefaultColor = "#3aab87"

View File

@ -28,23 +28,7 @@
const dispatch = createEventDispatcher()
const categories = [
{
label: "Theme",
colors: [
"gray-50",
"gray-75",
"gray-100",
"gray-200",
"gray-300",
"gray-400",
"gray-500",
"gray-600",
"gray-700",
"gray-800",
"gray-900",
],
},
{
label: "Colors",
label: "Theme colors",
colors: [
"red-100",
"orange-100",
@ -91,6 +75,49 @@
"indigo-700",
"magenta-700",
"gray-50",
"gray-75",
"gray-100",
"gray-200",
"gray-300",
"gray-400",
"gray-500",
"gray-600",
"gray-700",
"gray-800",
"gray-900",
],
},
{
label: "Static colors",
colors: [
"static-red-400",
"static-orange-400",
"static-yellow-400",
"static-green-400",
"static-seafoam-400",
"static-blue-400",
"static-indigo-400",
"static-magenta-400",
"static-red-800",
"static-orange-800",
"static-yellow-800",
"static-green-800",
"static-seafoam-800",
"static-blue-800",
"static-indigo-800",
"static-magenta-800",
"static-red-1200",
"static-orange-1200",
"static-yellow-1200",
"static-green-1200",
"static-seafoam-1200",
"static-blue-1200",
"static-indigo-1200",
"static-magenta-1200",
"static-white",
"static-black",
],
@ -137,10 +164,13 @@
: "var(--spectrum-global-color-gray-50)"
}
// Use contrasating check for the dim colours
// Use contrasting check for the dim colours
if (value?.includes("-100")) {
return "var(--spectrum-global-color-gray-900)"
}
if (value?.includes("-1200") || value?.includes("-800")) {
return "var(--spectrum-global-color-static-gray-50)"
}
// Use black check for static white
if (value?.includes("static-black")) {
@ -169,7 +199,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover bind:this={dropdown} anchor={preview} maxHeight={320} {offset} {align}>
<Popover bind:this={dropdown} anchor={preview} maxHeight={350} {offset} {align}>
<Layout paddingX="XL" paddingY="L">
<div class="container">
{#each categories as category}

View File

@ -64,7 +64,7 @@
import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid"
export let title
export let title = ""
export let forceModal = false
const dispatch = createEventDispatcher()

View File

@ -1,22 +1,26 @@
<script>
<script lang="ts" context="module">
type Option = any
</script>
<script lang="ts">
import Picker from "./Picker.svelte"
import { createEventDispatcher } from "svelte"
export let value = []
export let id = null
export let placeholder = null
export let disabled = false
export let options = []
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let readonly = false
export let autocomplete = false
export let sort = false
export let autoWidth = false
export let searchTerm = null
export let customPopoverHeight = undefined
export let open = false
export let loading
export let value: string[] = []
export let id: string | undefined = undefined
export let placeholder: string | null = null
export let disabled: boolean = false
export let options: Option[] = []
export let getOptionLabel = (option: Option, _index?: number) => option
export let getOptionValue = (option: Option, _index?: number) => option
export let readonly: boolean = false
export let autocomplete: boolean = false
export let sort: boolean = false
export let autoWidth: boolean = false
export let searchTerm: string | null = null
export let customPopoverHeight: string | undefined = undefined
export let open: boolean = false
export let loading: boolean
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
@ -27,10 +31,15 @@
$: optionLookupMap = getOptionLookupMap(options)
$: fieldText = getFieldText(arrayValue, optionLookupMap, placeholder)
$: isOptionSelected = optionValue => selectedLookupMap[optionValue] === true
$: isOptionSelected = (optionValue: string) =>
selectedLookupMap[optionValue] === true
$: toggleOption = makeToggleOption(selectedLookupMap, arrayValue)
const getFieldText = (value, map, placeholder) => {
const getFieldText = (
value: string[],
map: Record<string, any> | null,
placeholder: string | null
) => {
if (Array.isArray(value) && value.length > 0) {
if (!map) {
return ""
@ -42,8 +51,8 @@
}
}
const getSelectedLookupMap = value => {
let map = {}
const getSelectedLookupMap = (value: string[]) => {
const map: Record<string, boolean> = {}
if (Array.isArray(value) && value.length > 0) {
value.forEach(option => {
if (option) {
@ -54,22 +63,23 @@
return map
}
const getOptionLookupMap = options => {
let map = null
if (options?.length) {
map = {}
options.forEach((option, idx) => {
const optionValue = getOptionValue(option, idx)
if (optionValue != null) {
map[optionValue] = getOptionLabel(option, idx) || ""
}
})
const getOptionLookupMap = (options: Option[]) => {
if (!options?.length) {
return null
}
const map: Record<string, any> = {}
options.forEach((option, idx) => {
const optionValue = getOptionValue(option, idx)
if (optionValue != null) {
map[optionValue] = getOptionLabel(option, idx) || ""
}
})
return map
}
const makeToggleOption = (map, value) => {
return optionValue => {
const makeToggleOption = (map: Record<string, boolean>, value: string[]) => {
return (optionValue: string) => {
if (map[optionValue]) {
const filtered = value.filter(option => option !== optionValue)
dispatch("change", filtered)

View File

@ -1,47 +1,79 @@
<script>
<script lang="ts">
import "@spectrum-css/textfield/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
export let value = ""
export let placeholder = null
export let disabled = false
export let readonly = false
export let id = null
export let height = null
export let minHeight = null
export let value: string | undefined = ""
export let placeholder: string | undefined = undefined
export let disabled: boolean = false
export let readonly: boolean = false
export let id: string | undefined = undefined
export let height: string | number | undefined = undefined
export let minHeight: string | number | undefined = undefined
export let align = null
export let updateOnChange: boolean = false
export const getCaretPosition = () => ({
start: textarea.selectionStart,
end: textarea.selectionEnd,
})
export let align = null
let focus = false
let textarea
const dispatch = createEventDispatcher()
const onChange = event => {
dispatch("change", event.target.value)
focus = false
let isFocused = false
let textarea: HTMLTextAreaElement
let scrollable = false
$: heightString = getStyleString("height", height)
$: minHeightString = getStyleString("min-height", minHeight)
$: dispatch("scrollable", scrollable)
export function focus() {
textarea.focus()
}
const getStyleString = (attribute, value) => {
if (!attribute || value == null) {
export function contents() {
return textarea.value
}
const onBlur = () => {
isFocused = false
updateValue()
}
const onChange = () => {
scrollable = textarea.clientHeight < textarea.scrollHeight
if (!updateOnChange) {
return
}
updateValue()
}
const updateValue = () => {
if (readonly || disabled) {
return
}
dispatch("change", textarea.value)
}
const getStyleString = (
attribute: string,
value: string | number | undefined
) => {
if (value == null) {
return ""
}
if (isNaN(value)) {
if (typeof value !== "number" || isNaN(value)) {
return `${attribute}:${value};`
}
return `${attribute}:${value}px;`
}
$: heightString = getStyleString("height", height)
$: minHeightString = getStyleString("min-height", minHeight)
</script>
<div
style={`${heightString}${minHeightString}`}
class="spectrum-Textfield spectrum-Textfield--multiline"
class:is-disabled={disabled}
class:is-focused={focus}
class:is-focused={isFocused}
>
<!-- prettier-ignore -->
<textarea
@ -52,8 +84,11 @@
{disabled}
{readonly}
{id}
on:focus={() => (focus = true)}
on:blur={onChange}
on:input={onChange}
on:focus={() => (isFocused = true)}
on:blur={onBlur}
on:blur
on:keypress
>{value || ""}</textarea>
</div>

View File

@ -1,132 +0,0 @@
<script>
import Field from "./Field.svelte"
import PickerDropdown from "./Core/PickerDropdown.svelte"
import { createEventDispatcher } from "svelte"
export let primaryValue = null
export let secondaryValue = null
export let inputType = "text"
export let label = null
export let labelPosition = "above"
export let secondaryPlaceholder = null
export let autocomplete
export let placeholder = null
export let disabled = false
export let readonly = false
export let error = null
export let getSecondaryOptionLabel = option =>
extractProperty(option, "label")
export let getSecondaryOptionValue = option =>
extractProperty(option, "value")
export let getSecondaryOptionColour = () => {}
export let getSecondaryOptionIcon = () => {}
export let quiet = false
export let autofocus
export let primaryOptions = []
export let secondaryOptions = []
export let searchTerm
export let showClearIcon = true
export let helpText = null
let primaryLabel
let secondaryLabel
const dispatch = createEventDispatcher()
$: secondaryFieldText = getSecondaryFieldText(
secondaryValue,
secondaryOptions,
secondaryPlaceholder
)
$: secondaryFieldIcon = getSecondaryFieldAttribute(
getSecondaryOptionIcon,
secondaryValue,
secondaryOptions
)
$: secondaryFieldColour = getSecondaryFieldAttribute(
getSecondaryOptionColour,
secondaryValue,
secondaryOptions
)
const getSecondaryFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getSecondaryOptionValue(option, idx) === value
)
return index !== -1 ? getAttribute(options[index], index) : null
}
const getSecondaryFieldText = (value, options, placeholder) => {
// Always use placeholder if no value
if (value == null || value === "") {
return placeholder || "Choose an option"
}
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options)
}
const onPickPrimary = e => {
primaryLabel = e?.detail?.label || null
primaryValue = e?.detail?.value || null
dispatch("pickprimary", e?.detail?.value || {})
}
const onPickSecondary = e => {
secondaryValue = e.detail
dispatch("picksecondary", e.detail)
}
const extractProperty = (value, property) => {
if (value && typeof value === "object") {
return value[property]
}
return value
}
const updateSearchTerm = e => {
searchTerm = e.detail
}
</script>
<Field {helpText} {label} {labelPosition} {error}>
<PickerDropdown
{searchTerm}
{autocomplete}
{error}
{disabled}
{readonly}
{placeholder}
{inputType}
{quiet}
{autofocus}
{primaryOptions}
{secondaryOptions}
{getSecondaryOptionLabel}
{getSecondaryOptionValue}
{getSecondaryOptionIcon}
{getSecondaryOptionColour}
{secondaryFieldText}
{secondaryFieldIcon}
{secondaryFieldColour}
{primaryValue}
{secondaryValue}
{primaryLabel}
{secondaryLabel}
{showClearIcon}
on:pickprimary={onPickPrimary}
on:picksecondary={onPickSecondary}
on:search={updateSearchTerm}
on:click
on:input
on:blur
on:focus
on:keyup
on:closed
/>
</Field>

View File

@ -1,21 +1,29 @@
<script>
<script lang="ts">
import Field from "./Field.svelte"
import TextArea from "./Core/TextArea.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let value: string | undefined = undefined
export let label: string | undefined = undefined
export let labelPosition = "above"
export let placeholder = null
export let placeholder: string | undefined = undefined
export let disabled = false
export let error = null
export let getCaretPosition = null
export let height = null
export let minHeight = null
export let helpText = null
export let error: string | undefined = undefined
export let height: number | undefined = undefined
export let minHeight: number | undefined = undefined
export let helpText: string | undefined = undefined
let textarea: TextArea
export function focus() {
textarea.focus()
}
export function contents() {
return textarea.contents()
}
const dispatch = createEventDispatcher()
const onChange = e => {
const onChange = (e: CustomEvent<string>) => {
value = e.detail
dispatch("change", e.detail)
}
@ -23,13 +31,13 @@
<Field {helpText} {label} {labelPosition} {error}>
<TextArea
bind:getCaretPosition
{error}
bind:this={textarea}
{disabled}
{value}
{placeholder}
{height}
{minHeight}
on:change={onChange}
on:keypress
/>
</Field>

View File

@ -25,12 +25,12 @@
noWrap={tooltipWrap}
>
<div class="icon" class:newStyles>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<svg
on:contextmenu
on:click
on:mouseover
on:mouseleave
on:focus
class:hoverable
class:disabled
class="spectrum-Icon spectrum-Icon--size{size}"

View File

@ -1,16 +1,15 @@
<script>
<script lang="ts">
import Icon from "./Icon.svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
export let icon
export let background
export let color
export let size = "M"
export let tooltip
export let icon: string | undefined = undefined
export let background: string | undefined = undefined
export let color: string | undefined = undefined
export let size: "XS" | "S" | "M" | "L" = "M"
export let tooltip: string | undefined = undefined
let showTooltip = false
let showTooltip: boolean = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@ -1,19 +1,24 @@
<script>
<script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte"
export let value
export let size = "M"
export let alignRight = false
export let value: string | undefined
export let size: "S" | "M" | "L" = "M"
export let alignRight: boolean = false
let open = false
let open: boolean = false
const dispatch = createEventDispatcher()
const iconList = [
interface IconCategory {
label: string
icons: string[]
}
const iconList: IconCategory[] = [
{
label: "Icons",
icons: [
@ -45,12 +50,12 @@
},
]
const onChange = value => {
const onChange = (value: string) => {
dispatch("change", value)
open = false
}
const handleOutsideClick = event => {
const handleOutsideClick = (event: MouseEvent) => {
if (open) {
event.stopPropagation()
open = false
@ -77,11 +82,11 @@
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
class:spectrum-Popover--align-right={alignRight}
>
{#each iconList as icon}
{#each iconList as iconList}
<div class="category">
<div class="heading">{icon.label}</div>
<div class="heading">{iconList.label}</div>
<div class="icons">
{#each icon.icons as icon}
{#each iconList.icons as icon}
<div
on:click={() => {
onChange(icon)

View File

@ -1,14 +0,0 @@
<div class="icon-side-nav">
<slot />
</div>
<style>
.icon-side-nav {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: var(--spacing-s);
gap: var(--spacing-xs);
}
</style>

View File

@ -1,58 +0,0 @@
<script>
import Icon from "../Icon/Icon.svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
export let icon
export let active = false
export let tooltip
let showTooltip = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="icon-side-nav-item"
class:active
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:click
>
<Icon name={icon} hoverable />
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="right" text={tooltip} />
</div>
{/if}
</div>
<style>
.icon-side-nav-item {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 4px;
position: relative;
cursor: pointer;
transition: background 130ms ease-out;
}
.icon-side-nav-item:hover :global(svg),
.active :global(svg) {
color: var(--spectrum-global-color-gray-900);
}
.active {
background: var(--spectrum-global-color-gray-300);
}
.tooltip {
position: absolute;
pointer-events: none;
left: calc(100% - 4px);
top: 50%;
white-space: nowrap;
transform: translateY(-50%);
z-index: 1;
}
</style>

View File

@ -1,19 +1,22 @@
<script>
<script lang="ts">
import "@spectrum-css/inlinealert/dist/index-vars.css"
import Button from "../Button/Button.svelte"
import Icon from "../Icon/Icon.svelte"
export let type = "info"
export let header = ""
export let message = ""
export let onConfirm = undefined
export let buttonText = ""
export let cta = false
export let type: "info" | "error" | "success" | "help" | "negative" = "info"
export let header: string = ""
export let message: string = ""
export let onConfirm: (() => void) | undefined = undefined
export let buttonText: string = ""
export let cta: boolean = false
export let link: string = ""
export let linkText: string = ""
$: icon = selectIcon(type)
// if newlines used, convert them to different elements
$: split = message.split("\n")
function selectIcon(alertType) {
function selectIcon(alertType: string): string {
switch (alertType) {
case "error":
case "negative":
@ -49,6 +52,19 @@
>
</div>
{/if}
{#if link && linkText}
<div id="docs-link">
<a
href={link}
target="_blank"
rel="noopener noreferrer"
class="docs-link"
>
{linkText}
<Icon name="LinkOut" size="XS" />
</a>
</div>
{/if}
</div>
<style>
@ -64,4 +80,21 @@
margin: 0;
border-width: 1px;
}
a {
color: white;
}
#docs-link {
padding-top: 10px;
display: flex;
align-items: center;
gap: 5px;
}
#docs-link > * {
display: flex;
align-items: center;
gap: 5px;
}
</style>

View File

@ -1,19 +1,21 @@
<script>
<script lang="ts">
import Input from "../Form/Input.svelte"
import Icon from "../Icon/Icon.svelte"
import { notifications } from "../Stores/notifications"
export let label = null
export let value
export let label: string | undefined = undefined
export let value: string | undefined = undefined
const copyToClipboard = val => {
const dummy = document.createElement("textarea")
document.body.appendChild(dummy)
dummy.value = val
dummy.select()
document.execCommand("copy")
document.body.removeChild(dummy)
notifications.success(`Copied to clipboard`)
const copyToClipboard = (val: string | undefined) => {
if (val) {
const dummy = document.createElement("textarea")
document.body.appendChild(dummy)
dummy.value = val
dummy.select()
document.execCommand("copy")
document.body.removeChild(dummy)
notifications.success(`Copied to clipboard`)
}
}
</script>

View File

@ -1,10 +1,10 @@
<script>
<script lang="ts">
import "@spectrum-css/fieldlabel/dist/index-vars.css"
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
export let size = "M"
export let tooltip = ""
export let muted = undefined
export let size: "S" | "M" | "L" = "M"
export let tooltip: string = ""
export let muted: boolean | undefined = undefined
</script>
<TooltipWrapper {tooltip} {size}>

View File

@ -1,12 +1,17 @@
<script>
export let horizontal = false
export let paddingX = "M"
export let paddingY = "M"
export let noPadding = false
export let gap = "M"
export let noGap = false
export let alignContent = "normal"
export let justifyItems = "stretch"
<script lang="ts">
export let horizontal: boolean = false
export let paddingX: "S" | "M" | "L" | "XL" | "XXL" = "M"
export let paddingY: "S" | "M" | "L" | "XL" | "XXL" = "M"
export let noPadding: boolean = false
export let gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M"
export let noGap: boolean = false
export let alignContent:
| "start"
| "center"
| "space-between"
| "space-around"
| "normal" = "normal"
export let justifyItems: "stretch" | "start" | "center" | "end" = "stretch"
</script>
<div

View File

@ -1,13 +1,13 @@
<script>
<script lang="ts">
import { setContext } from "svelte"
import clickOutside from "../Actions/click_outside"
export let wide = false
export let narrow = false
export let narrower = false
export let noPadding = false
export let wide: boolean = false
export let narrow: boolean = false
export let narrower: boolean = false
export let noPadding: boolean = false
let sidePanelVisible = false
let sidePanelVisible: boolean = false
setContext("side-panel", {
open: () => (sidePanelVisible = true),

View File

@ -1,7 +1,7 @@
<script>
<script lang="ts">
import Detail from "../Typography/Detail.svelte"
export let title = null
export let title: string | null = null
</script>
<div>

View File

@ -1,20 +1,20 @@
<script>
<script lang="ts">
import SpectrumMDE from "./SpectrumMDE.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let height = null
export let placeholder = null
export let id = null
export let fullScreenOffset = 0
export let disabled = false
export let readonly = false
export let easyMDEOptions
export let value: string | null = null
export let height: string | null = null
export let placeholder: string | null = null
export let id: string | null = null
export let fullScreenOffset: { x: string; y: string } | null = null
export let disabled: boolean = false
export let readonly: boolean = false
export let easyMDEOptions: Record<string, any> = {}
const dispatch = createEventDispatcher()
let latestValue
let mde
let latestValue: string | null
let mde: any
// Ensure the value is updated if the value prop changes outside the editor's
// control
@ -24,7 +24,7 @@
mde?.togglePreview()
}
const checkValue = val => {
const checkValue = (val: string | null) => {
if (mde && val !== latestValue) {
mde.value(val)
}

View File

@ -1,10 +1,10 @@
<script>
<script lang="ts">
import SpectrumMDE from "./SpectrumMDE.svelte"
export let value
export let height
export let value: string | undefined = undefined
export let height: string | undefined = undefined
let mde
let mde: any
// Keep the value up to date
$: mde && mde.value(value || "")
@ -40,6 +40,7 @@
border: none;
background: transparent;
padding: 0;
color: inherit;
}
.markdown-viewer :global(.EasyMDEContainer) {
background: transparent;

View File

@ -1,17 +1,17 @@
<script>
<script lang="ts">
import EasyMDE from "easymde"
import "easymde/dist/easymde.min.css"
import { onMount } from "svelte"
export let height = null
export let scroll = true
export let easyMDEOptions = null
export let mde = null
export let id = null
export let fullScreenOffset = null
export let disabled = false
export let height: string | null = null
export let scroll: boolean = true
export let easyMDEOptions: Record<string, any> | null = null
export let mde: EasyMDE | null = null
export let id: string | null = null
export let fullScreenOffset: { x: string; y: string } | null = null
export let disabled: boolean = false
let element
let element: HTMLTextAreaElement | undefined = undefined
onMount(() => {
height = height || "200px"
@ -27,13 +27,13 @@
// Revert the editor when we unmount
return () => {
mde.toTextArea()
mde?.toTextArea()
}
})
$: styleString = getStyleString(fullScreenOffset)
const getStyleString = offset => {
const getStyleString = (offset: { x?: string; y?: string } | null) => {
let string = ""
string += `--fullscreen-offset-x:${offset?.x || "0px"};`
string += `--fullscreen-offset-y:${offset?.y || "0px"};`

View File

@ -1,18 +1,18 @@
<script>
<script lang="ts">
import { createEventDispatcher, getContext } from "svelte"
import Icon from "../Icon/Icon.svelte"
const dispatch = createEventDispatcher()
const actionMenu = getContext("actionMenu")
const actionMenu = getContext("actionMenu") as { hideAll: () => void }
export let icon = undefined
export let disabled = undefined
export let noClose = false
export let keyBind = undefined
export let icon: string | undefined = undefined
export let disabled: boolean | undefined = undefined
export let noClose: boolean = false
export let keyBind: string | undefined = undefined
$: keys = getKeys(keyBind)
const getKeys = keyBind => {
const getKeys = (keyBind: string | undefined): string[] => {
let keys = keyBind?.split("+") || []
for (let i = 0; i < keys.length; i++) {
if (

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import "@spectrum-css/menu/dist/index-vars.css"
</script>

View File

@ -1,19 +0,0 @@
<script>
import Menu from './Menu.svelte'
import Separator from './Separator.svelte'
import Section from './Section.svelte'
import Item from './Item.svelte'
</script>
<Menu>
<Section heading="Section heading">
<Item>Some Item 1</Item>
<Item>Some Item 2</Item>
<Item>Some Item 3</Item>
</Section>
<Separator />
<Section heading="Section heading">
<Item icon="SaveFloppy">Save</Item>
<Item disabled icon="DataDownload">Download</Item>
</Section>
</Menu>

View File

@ -1,5 +1,5 @@
<script>
export let heading
<script lang="ts">
export let heading: string
</script>
<li role="presentation">

View File

@ -1,7 +1,7 @@
<script>
<script lang="ts">
import Input from "../Form/Input.svelte"
let value = ""
let value: string = ""
</script>
<Input label="Your Name" bind:value />

View File

@ -1,11 +1,11 @@
<script>
<script lang="ts">
import { getContext } from "svelte"
import Context from "../context"
const { hide } = getContext(Context.Modal)
const { hide } = getContext(Context.Modal) as { hide: () => void }
let count = 0
const clicks = 5
let count: number = 0
const clicks: number = 5
$: if (count === clicks) hide()
$: remaining = clicks - count

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import "@spectrum-css/modal/dist/index-vars.css"
import "@spectrum-css/underlay/dist/index-vars.css"
import { createEventDispatcher, setContext, tick, onMount } from "svelte"
@ -6,33 +6,37 @@
import Portal from "svelte-portal"
import Context from "../context"
export let fixed = false
export let inline = false
export let disableCancel = false
export let autoFocus = true
export let zIndex = 1001
export let fixed: boolean = false
export let inline: boolean = false
export let disableCancel: boolean = false
export let autoFocus: boolean = true
export let zIndex: number = 1001
const dispatch = createEventDispatcher()
let visible = fixed || inline
let modal
const dispatch = createEventDispatcher<{
show: void
hide: void
cancel: void
}>()
let visible: boolean = fixed || inline
let modal: HTMLElement | undefined
$: dispatch(visible ? "show" : "hide")
export function show() {
export function show(): void {
if (visible) {
return
}
visible = true
}
export function hide() {
export function hide(): void {
if (!visible || fixed || inline) {
return
}
visible = false
}
export function toggle() {
export function toggle(): void {
if (visible) {
hide()
} else {
@ -40,7 +44,7 @@
}
}
export function cancel() {
export function cancel(): void {
if (!visible || disableCancel) {
return
}
@ -48,34 +52,33 @@
hide()
}
function handleKey(e) {
function handleKey(e: KeyboardEvent): void {
if (visible && e.key === "Escape") {
cancel()
}
}
async function focusModal(node) {
if (!autoFocus) {
return
}
await tick()
// Try to focus first input
const inputs = node.querySelectorAll("input")
if (inputs?.length) {
inputs[0].focus()
}
// Otherwise try to focus confirmation button
else if (modal) {
const confirm = modal.querySelector(".confirm-wrap .spectrum-Button")
if (confirm) {
confirm.focus()
function focusModal(node: HTMLElement): void {
if (!autoFocus) return
tick().then(() => {
const inputs = node.querySelectorAll("input")
if (inputs?.length) {
inputs[0].focus()
} else if (modal) {
const confirm = modal.querySelector(".confirm-wrap .spectrum-Button")
if (confirm) {
;(confirm as HTMLElement).focus()
}
}
}
})
}
setContext(Context.Modal, { show, hide, toggle, cancel })
setContext(Context.Modal, {
show,
hide,
toggle,
cancel,
} as { show: () => void; hide: () => void; toggle: () => void; cancel: () => void })
onMount(() => {
document.addEventListener("keydown", handleKey)

View File

@ -1,116 +0,0 @@
<script>
import { View } from "svench";
import Modal from "./Modal.svelte";
import ModalContent from "./ModalContent.svelte";
import Button from "../Button/Button.svelte";
import Content from "./Content.svelte";
import QuizModal from "./QuizModal.svelte";
import CustomContent from "./CustomContent.svelte";
let modal1
let modal2
let modal3
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
async function longTask() {
await sleep(3000)
}
</script>
<style>
p, span {
font-size: var(--font-size-s);
}
code {
display: inline-block;
box-sizing: border-box;
padding: var(--spacing-xs) var(--spacing-s);
background-color: var(--grey-2);
color: var(--red-dark);
border-radius: var(--spacing-xs);
}
</style>
<h3>Modals</h3>
<p>
Modals provide a means to render content in front of everything else on a page.
</p>
<p>
The modal module in BBUI exposes two
separate components to provide this functionality; a <code>Modal</code> component to control visibility of content,
and a <code>ModalContent</code> component to quickly construct the typical content - although this is optional.
</p>
<p>
One of the common problems with modals and popups is stale state reappearing after hiding and showing the content
again, since the state hasn't been garbage collected if a component controls its own visibility. This is handled for
you when using the <code>Modal</code> component as it will fully unmount child components, properly resetting state
every time it appears.
</p>
<br/>
<p>Use ModalContent to render typical modal content.</p>
<View name="Simple Confirmation Modal">
<Button primary on:click={modal1.show}>Delete Record</Button>
<Modal bind:this={modal1}>
<ModalContent title="Confirm Deletion" confirmText="Delete">
<span>Are you sure you want to delete this record?</span>
</ModalContent>
</Modal>
</View>
<br/>
<p>
Width can be specified as a prop to a <code>Modal</code>. Any additional <code>ModalContent</code> props provided
will be passed to the confirmation button.
</p>
<View name="Different Buttons and Width">
<Button primary on:click={modal3.show}>Open Modal</Button>
<Modal bind:this={modal3} width="250px">
<ModalContent
title="Confirmation Required"
showCancelButton={false}
showCloseIcon={false}
confirmText="I'm sure!"
green
large
wide
>
<span>Are you sure you want to do that?</span>
</ModalContent>
</Modal>
</View>
<br/>
<p>Any content can be rendered inside a <code>Modal</code>. Use context to close the modal from your own components.</p>
<View name="Custom Content">
<Button primary on:click={modal1.show}>Open Modal</Button>
<Modal bind:this={modal1} padding={false} border={false}>
<CustomContent/>
</Modal>
</View>
<br/>
<p>Async functions passed in as the onConfirm prop will make the modal wait until the callback is completed.</p>
<View name="Async Callbacks">
<Button primary on:click={modal2.show}>Long Task</Button>
<Modal bind:this={modal2}>
<ModalContent
title="Perform Long Task"
confirmText="Submit"
onConfirm={longTask}
>
<span>Pressing submit will wait 3 seconds before finishing and disable the confirm button until it's done.</span>
</ModalContent>
</Modal>
</View>
<br/>
<p>Returning false from a onConfirm callback will prevent the modal being closed.</p>
<View name="Callback Failure Handling">
<Button primary on:click={modal3.show}>Open Quiz</Button>
<Modal bind:this={modal3}>
<QuizModal />
</Modal>
</View>

View File

@ -1,8 +1,8 @@
<script context="module">
<script context="module" lang="ts">
export const keepOpen = Symbol("keepOpen")
</script>
<script>
<script lang="ts">
import "@spectrum-css/dialog/dist/index-vars.css"
import { getContext } from "svelte"
import Button from "../Button/Button.svelte"
@ -11,31 +11,36 @@
import Context from "../context"
import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte"
export let title = undefined
export let size = "S"
export let cancelText = "Cancel"
export let confirmText = "Confirm"
export let showCancelButton = true
export let showConfirmButton = true
export let showCloseIcon = true
export let onConfirm = undefined
export let onCancel = undefined
export let disabled = false
export let showDivider = true
export let title: string | undefined = undefined
export let size: "S" | "M" | "L" | "XL" = "S"
export let cancelText: string = "Cancel"
export let confirmText: string = "Confirm"
export let showCancelButton: boolean = true
export let showConfirmButton: boolean = true
export let showCloseIcon: boolean = true
export let onConfirm: (() => Promise<any> | any) | undefined = undefined
export let onCancel: (() => Promise<any> | any) | undefined = undefined
export let disabled: boolean = false
export let showDivider: boolean = true
export let showSecondaryButton = false
export let secondaryButtonText = undefined
export let secondaryAction = undefined
export let secondaryButtonWarning = false
export let custom = false
export let showSecondaryButton: boolean = false
export let secondaryButtonText: string | undefined = undefined
export let secondaryAction: ((_e: Event) => Promise<any> | any) | undefined =
undefined
export let secondaryButtonWarning: boolean = false
export let custom: boolean = false
const { hide, cancel } = getContext(Context.Modal)
const { hide, cancel } = getContext(Context.Modal) as {
hide: () => void
cancel: () => void
}
let loading = false
let loading: boolean = false
let confirmDisabled: boolean
$: confirmDisabled = disabled || loading
async function secondary(e) {
async function secondary(e: Event): Promise<void> {
loading = true
if (!secondaryAction || (await secondaryAction(e)) !== keepOpen) {
hide()
@ -43,7 +48,7 @@
loading = false
}
export async function confirm() {
export async function confirm(): Promise<void> {
loading = true
if (!onConfirm || (await onConfirm()) !== keepOpen) {
hide()
@ -51,7 +56,7 @@
loading = false
}
async function close() {
async function close(): Promise<void> {
loading = true
if (!onCancel || (await onCancel()) !== keepOpen) {
cancel()
@ -90,7 +95,6 @@
{/if}
{/if}
<!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid">
<slot {loading} />
</section>
@ -102,7 +106,6 @@
{#if showSecondaryButton && secondaryButtonText && secondaryAction}
<div class="secondary-action">
<Button
group
secondary
warning={secondaryButtonWarning}
on:click={secondary}>{secondaryButtonText}</Button
@ -111,14 +114,13 @@
{/if}
{#if showCancelButton}
<Button group secondary on:click={close}>
<Button secondary on:click={close}>
{cancelText}
</Button>
{/if}
{#if showConfirmButton}
<span class="confirm-wrap">
<Button
group
cta
{...$$restProps}
disabled={confirmDisabled}

View File

@ -1,53 +0,0 @@
<script>
import ModalContent from "./ModalContent.svelte"
import Input from "../Form/Input.svelte"
let modal
let answer
let error
export function show() {
modal.show()
}
export function hide() {
modal.hide
}
function resetState() {
answer = undefined
error = undefined
}
async function answerQuiz() {
const correct = answer === "8"
error = !correct
return correct
}
</script>
<ModalContent
title="Quick Maths"
bind:this={modal}
confirmText="Submit"
onConfirm={answerQuiz}
on:show={resetState}
>
{#if error}
<p class="error">Wrong answer! Try again.</p>
{/if}
<p>What is 4 + 4?</p>
<Input label="Answer" bind:value={answer} />
</ModalContent>
<style>
p {
margin: 0;
font-size: var(--font-size-s);
}
p.error {
color: #e26d69;
background-color: #ffe6e6;
padding: 8px;
border-radius: 4px;
}
</style>

View File

@ -1,17 +1,17 @@
<script>
<script lang="ts">
import { ActionButton } from "../"
import { createEventDispatcher } from "svelte"
export let type = "info"
export let icon = "Info"
export let message = ""
export let dismissable = false
export let actionMessage = null
export let action = null
export let wide = false
export let type: string = "info"
export let icon: string = "Info"
export let message: string = ""
export let dismissable: boolean = false
export let actionMessage: string | null = null
export let action: ((_dismiss: () => void) => void) | null = null
export let wide: boolean = false
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{ dismiss: void }>()
</script>
<div class="spectrum-Toast spectrum-Toast--{type}" class:wide>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import "@spectrum-css/toast/dist/index-vars.css"
import Portal from "svelte-portal"
import { notifications } from "../Stores/notifications"

View File

@ -1,20 +1,19 @@
<script>
<script lang="ts">
import "@spectrum-css/pagination/dist/index-vars.css"
import "@spectrum-css/actionbutton/dist/index-vars.css"
import "@spectrum-css/typography/dist/index-vars.css"
export let page
export let goToPrevPage
export let goToNextPage
export let hasPrevPage = true
export let hasNextPage = true
export let page: number
export let goToPrevPage: () => void
export let goToNextPage: () => void
export let hasPrevPage: boolean = true
export let hasNextPage: boolean = true
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<nav class="spectrum-Pagination spectrum-Pagination--explicit">
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-prevButton"
on:click={hasPrevPage ? goToPrevPage : null}
class:is-disabled={!hasPrevPage}
@ -32,7 +31,6 @@
Page {page}
</span>
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-nextButton"
on:click={hasNextPage ? goToNextPage : null}
class:is-disabled={!hasNextPage}

View File

@ -18,7 +18,7 @@
import type { KeyboardEventHandler } from "svelte/elements"
import { PopoverAlignment } from "../constants"
export let anchor: HTMLElement
export let anchor: HTMLElement | undefined
export let align: PopoverAlignment | `${PopoverAlignment}` =
PopoverAlignment.Right
export let portalTarget: string | undefined = undefined

View File

@ -1,25 +1,24 @@
<script>
<script lang="ts">
import "@spectrum-css/progressbar/dist/index-vars.css"
export let value = false
export let duration = 1000
export let width = false
export let sideLabel = false
export let hidePercentage = true
export let color // red, green, default = blue
export let size = "M"
export let value: number | boolean = false
export let duration: number = 1000
export let width: string | boolean = false
export let sideLabel: boolean = false
export let hidePercentage: boolean = true
export let color: "red" | "green" | undefined = undefined // red, green, default = blue
export let size: string = "M"
</script>
<div
class:spectrum-ProgressBar--indeterminate={!value && value !== 0}
class:spectrum-ProgressBar--sideLabel={sideLabel}
class="spectrum-ProgressBar spectrum-ProgressBar--size{size}"
{value}
role="progressbar"
aria-valuenow={value}
aria-valuenow={typeof value === "number" ? value : undefined}
aria-valuemin="0"
aria-valuemax="100"
style={width ? `width: ${width};` : ""}
style={width ? `width: ${typeof width === "string" ? width : ""};` : ""}
>
{#if $$slots}
<div
@ -32,7 +31,7 @@
<div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
>
{Math.round(value)}%
{Math.round(Number(value))}%
</div>
{/if}
<div class="spectrum-ProgressBar-track">
@ -40,10 +39,12 @@
class="spectrum-ProgressBar-fill"
class:color-green={color === "green"}
class:color-red={color === "red"}
style="width: {value}%; --duration: {duration}ms;"
style="width: {typeof value === 'number'
? value
: 0}%; --duration: {duration}ms;"
/>
</div>
<div class="spectrum-ProgressBar-label" hidden="" />
<div class="spectrum-ProgressBar-label" hidden={false} />
</div>
<style>

View File

@ -1,8 +1,8 @@
<script>
<script lang="ts">
import "@spectrum-css/progresscircle/dist/index-vars.css"
export let size = "M"
function convertSize(size) {
export let size: "S" | "M" | "L" = "M"
function convertSize(size: "S" | "M" | "L"): string | undefined {
switch (size) {
case "S":
return "small"
@ -13,18 +13,18 @@
}
}
export let value = null
export let minValue = 0
export let maxValue = 100
export let value: number | null = null
export let minValue: number = 0
export let maxValue: number = 100
let subMask1Style
let subMask2Style
let subMask1Style: string | undefined
let subMask2Style: string | undefined
$: calculateSubMasks(value)
function calculateSubMasks(value) {
function calculateSubMasks(value: number | null): void {
if (value) {
let percentage = ((value - minValue) / (maxValue - minValue)) * 100
let angle
let angle: number
if (percentage > 0 && percentage <= 50) {
angle = -180 + (percentage / 50) * 180
subMask1Style = `transform: rotate(${angle}deg);`
@ -37,7 +37,7 @@
}
}
export let overBackground = false
export let overBackground: boolean = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@ -1,78 +0,0 @@
<script>
import { flip } from 'svelte/animate';
import { fly } from "svelte/transition"
import { View } from "svench";
import { notifications } from "./notifications";
export let themes = {
danger: "#E26D69",
success: "#84C991",
warning: "#f0ad4e",
info: "#5bc0de",
default: "#aaaaaa",
}
</script>
## Notification Store
This custom can be used to display toast messages. It has 5 different methods: `send`, `danger`, `warning`, `success`, `info`.
<View name="danger">
<button on:click={() => notifications.error('This is a danger!')}>Danger</button>
</View>
<View name="warning">
<button on:click={() => notifications.warning('This is a warning!')}>Warning</button>
</View>
<View name="success">
<button on:click={() => notifications.success('This is a success!')}>Success</button>
</View>
<View name="info">
<button on:click={() => notifications.info('This is an info toast!')}>Info</button>
</View>
<div class="notifications">
{#each $notifications as notification (notification.id)}
<div
animate:flip
class="toast"
style="background: {themes[notification.type]};"
transition:fly={{ y: -30 }}>
<div class="content">{notification.message}</div>
{#if notification.icon}<i class={notification.icon} />{/if}
</div>
{/each}
</div>
<style>
.notifications {
position: fixed;
top: 10px;
left: 0;
right: 0;
margin: 0 auto;
padding: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
pointer-events: none;
}
.toast {
flex: 0 0 auto;
margin-bottom: 10px;
border-radius: var(--border-radius-s);
/* The toasts now support being auto sized, so this static width could be removed */
width: 40vw;
}
.content {
padding: 10px;
display: block;
color: white;
font-weight: 500;
}
</style>

View File

@ -4,7 +4,7 @@
export let title
export let icon = ""
export let id
export let id = undefined
export let href = "#"
export let link = false

View File

@ -8,11 +8,13 @@
export let invalid: boolean = false
export let disabled: boolean = false
export let closable: boolean = false
export let emphasized: boolean = false
</script>
<div
class:is-invalid={invalid}
class:is-disabled={disabled}
class:is-emphasized={emphasized}
class="spectrum-Tags-item"
role="listitem"
>
@ -40,4 +42,9 @@
margin-bottom: 0;
margin-top: 0;
}
.is-emphasized {
border-color: var(--spectrum-global-color-blue-700);
color: var(--spectrum-global-color-blue-700);
}
</style>

View File

@ -3,7 +3,7 @@
import AbsTooltip from "./AbsTooltip.svelte"
export let tooltip: string = ""
export let size: "S" | "M" = "M"
export let size: "S" | "M" | "L" = "M"
export let disabled: boolean = true
</script>

View File

@ -11,6 +11,16 @@
--bb-forest-green: #053835;
--bb-beige: #f6efea;
/* Custom spectrum additions */
--spectrum-global-color-static-red-1200: #740000;
--spectrum-global-color-static-orange-1200: #612300;
--spectrum-global-color-static-yellow-1200: #483300;
--spectrum-global-color-static-green-1200: #053f27;
--spectrum-global-color-static-seafoam-1200: #123c3a;
--spectrum-global-color-static-blue-1200: #003571;
--spectrum-global-color-static-indigo-1200: #262986;
--spectrum-global-color-static-magenta-1200: #700037;
--grey-1: #fafafa;
--grey-2: #f5f5f5;
--grey-3: #eeeeee;

View File

@ -17,7 +17,6 @@ export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Search } from "./Form/Search.svelte"
@ -87,8 +86,6 @@ export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
export { default as List } from "./List/List.svelte"
export { default as ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"

View File

@ -100,7 +100,6 @@
"jest": "29.7.0",
"jsdom": "^21.1.1",
"resize-observer-polyfill": "^1.5.1",
"svelte-check": "^4.1.0",
"svelte-jester": "^1.3.2",
"vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0",

View File

@ -1,9 +1,12 @@
import posthog from "posthog-js"
import { Events } from "./constants"
export default class PosthogClient {
constructor(token) {
token: string
initialised: boolean
constructor(token: string) {
this.token = token
this.initialised = false
}
init() {
@ -12,6 +15,8 @@ export default class PosthogClient {
posthog.init(this.token, {
autocapture: false,
capture_pageview: false,
// disable by default
disable_session_recording: true,
})
posthog.set_config({ persistence: "cookie" })
@ -22,7 +27,7 @@ export default class PosthogClient {
* Set the posthog context to the current user
* @param {String} id - unique user id
*/
identify(id) {
identify(id: string) {
if (!this.initialised) return
posthog.identify(id)
@ -32,7 +37,7 @@ export default class PosthogClient {
* Update user metadata associated with current user in posthog
* @param {Object} meta - user fields
*/
updateUser(meta) {
updateUser(meta: Record<string, any>) {
if (!this.initialised) return
posthog.people.set(meta)
@ -43,28 +48,22 @@ export default class PosthogClient {
* @param {String} event - event identifier
* @param {Object} props - properties for the event
*/
captureEvent(eventName, props) {
if (!this.initialised) return
props.sourceApp = "builder"
posthog.capture(eventName, props)
}
/**
* Submit NPS feedback to posthog.
* @param {Object} values - NPS Values
*/
npsFeedback(values) {
if (!this.initialised) return
localStorage.setItem(Events.NPS.SUBMITTED, Date.now())
const prefixedFeedback = {}
for (let key in values) {
prefixedFeedback[`feedback_${key}`] = values[key]
captureEvent(event: string, props: Record<string, any>) {
if (!this.initialised) {
return
}
posthog.capture(Events.NPS.SUBMITTED, prefixedFeedback)
props.sourceApp = "builder"
posthog.capture(event, props)
}
enableSessionRecording() {
if (!this.initialised) {
return
}
posthog.set_config({
disable_session_recording: false,
})
}
/**

View File

@ -31,6 +31,10 @@ class AnalyticsHub {
posthog.captureEvent(eventName, props)
}
enableSessionRecording() {
posthog.enableSessionRecording()
}
async logout() {
posthog.logout()
}

View File

@ -23,9 +23,8 @@
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter(
entry => {
const [key] = entry
return key !== AutomationActionStepId.BRANCH
([key, action]) => {
return key !== AutomationActionStepId.BRANCH && action.deprecated !== true
}
)
let lockedFeatures = [
@ -186,6 +185,10 @@
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{:else if action.new}
<Tags>
<Tag emphasized>New</Tag>
</Tags>
{/if}
</div>
</div>
@ -227,6 +230,10 @@
grid-gap: var(--spectrum-alias-grid-baseline);
}
.item :global(.spectrum-Tags-itemLabel) {
cursor: pointer;
}
.item {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
@ -237,6 +244,8 @@
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
min-height: 3.5rem;
display: flex;
}
.item:not(.disabled):hover,
.selected {

View File

@ -18,8 +18,12 @@
import AutomationBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import FlowItemActions from "./FlowItemActions.svelte"
import { automationStore, selectedAutomation } from "@/stores/builder"
import { QueryUtils, Utils } from "@budibase/frontend-core"
import {
automationStore,
selectedAutomation,
evaluationContext,
} from "@/stores/builder"
import { QueryUtils, Utils, memo } from "@budibase/frontend-core"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import DragZone from "./DragZone.svelte"
@ -34,11 +38,14 @@
export let automation
const view = getContext("draggableView")
const memoContext = memo({})
let drawer
let open = true
let confirmDeleteModal
$: memoContext.set($evaluationContext)
$: branch = step.inputs?.branches?.[branchIdx]
$: editableConditionUI = branch.conditionUI || {}
@ -100,6 +107,7 @@
allowOnEmpty={false}
builderType={"condition"}
docsURL={null}
evaluationContext={$memoContext}
/>
</DrawerContent>
</Drawer>

View File

@ -1,6 +1,13 @@
<script>
import { automationStore, selectedAutomation } from "@/stores/builder"
import { Icon, Body, AbsTooltip, StatusLight } from "@budibase/bbui"
import {
Icon,
Body,
AbsTooltip,
StatusLight,
Tags,
Tag,
} from "@budibase/bbui"
import { externalActions } from "./ExternalActions"
import { createEventDispatcher } from "svelte"
import { Features } from "@/constants/backend/automations"
@ -24,6 +31,7 @@
$: blockRefs = $selectedAutomation?.blockRefs || {}
$: stepNames = automation?.definition.stepNames || {}
$: allSteps = automation?.definition.steps || []
$: blockDefinition = $automationStore.blockDefinitions.ACTION[block.stepId]
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult)
@ -135,7 +143,16 @@
{#if isHeaderTrigger}
<Body size="XS"><b>Trigger</b></Body>
{:else}
<Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
<Body size="XS">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b>{isBranch ? "Branch" : "Step"}</b>
{#if blockDefinition.deprecated}
<Tags>
<Tag invalid>Deprecated</Tag>
</Tags>
{/if}
</div>
</Body>
{/if}
{#if enableNaming}

View File

@ -102,6 +102,10 @@
if (rowTriggers.includes(trigger?.event)) {
const tableId = trigger?.inputs?.tableId
if (!jsonUpdate.row) {
jsonUpdate.row = {}
}
// Reset the tableId as it must match the trigger
if (jsonUpdate?.row?.tableId !== tableId) {
jsonUpdate.row.tableId = tableId
@ -161,7 +165,7 @@
block={trigger}
on:update={e => {
const { testData: updatedTestData } = e.detail
testData = updatedTestData
testData = parseTestData(updatedTestData)
}}
/>
</div>

View File

@ -32,7 +32,6 @@
})
$: groupedAutomations = groupAutomations(filteredAutomations)
$: showNoResults = searchString && !filteredAutomations.length
const groupAutomations = automations => {
@ -41,7 +40,6 @@
for (let auto of automations) {
let category = null
let dataTrigger = false
// Group by datasource if possible
if (dsTriggers.includes(auto.definition?.trigger?.stepId)) {
if (auto.definition.trigger.inputs?.tableId) {
@ -97,7 +95,10 @@
{triggerGroup?.name}
</div>
{#each triggerGroup.entries as automation}
<AutomationNavItem {automation} icon={triggerGroup.icon} />
<AutomationNavItem
{automation}
icon={automation?.definition?.trigger?.icon}
/>
{/each}
</div>
{/each}

View File

@ -18,10 +18,11 @@
Toggle,
Divider,
Icon,
CoreSelect,
} from "@budibase/bbui"
import CreateWebhookModal from "@/components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, tables } from "@/stores/builder"
import { automationStore, tables, evaluationContext } from "@/stores/builder"
import { environment } from "@/stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import {
@ -48,7 +49,13 @@
EditorModes,
} from "@/components/common/CodeEditor"
import FilterBuilder from "@/components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core"
import {
QueryUtils,
Utils,
search,
memo,
fetchData,
} from "@budibase/frontend-core"
import { getSchemaForDatasourcePlus } from "@/dataBinding"
import { TriggerStepID, ActionStepID } from "@/constants/backend/automations"
import { onMount, createEventDispatcher } from "svelte"
@ -59,9 +66,13 @@
AutomationStepType,
AutomationActionStepId,
AutomationCustomIOType,
SortOrder,
} from "@budibase/types"
import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
import DrawerBindableCodeEditorField from "@/components/common/bindings/DrawerBindableCodeEditorField.svelte"
import { API } from "@/api"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
export let automation
export let block
@ -74,6 +85,7 @@
// Stop unnecessary rendering
const memoBlock = memo(block)
const memoContext = memo({})
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
@ -95,8 +107,11 @@
let inputData
let insertAtPos, getCaretPosition
let stepLayouts = {}
let rowSearchTerm = ""
let selectedRow
$: memoBlock.set(block)
$: memoContext.set($evaluationContext)
$: filters = lookForFilters(schemaProperties)
$: filterCount =
@ -109,9 +124,13 @@
$: stepId = $memoBlock.stepId
$: getInputData(testData, $memoBlock.inputs)
$: tableId = inputData ? inputData.tableId : null
$: tableId =
inputData?.row?.tableId ||
testData?.row?.tableId ||
inputData?.tableId ||
null
$: table = tableId
? $tables.list.find(table => table._id === inputData.tableId)
? $tables.list.find(table => table._id === tableId)
: { schema: {} }
$: schema = getSchemaForDatasourcePlus(tableId, {
searchableSchema: true,
@ -140,6 +159,40 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: []
$: fetch = createFetch({ type: "table", tableId })
$: fetchedRows = $fetch?.rows
$: fetch?.update({
query: {
fuzzy: {
[primaryDisplay]: rowSearchTerm || "",
},
},
})
$: fetchLoading = $fetch?.loading
$: primaryDisplay = table?.primaryDisplay
const createFetch = datasource => {
if (!datasource) {
return
}
return fetchData({
API,
datasource,
options: {
sortColumn: primaryDisplay,
sortOrder: SortOrder.ASCENDING,
query: {
fuzzy: {
[primaryDisplay]: rowSearchTerm || "",
},
},
limit: 20,
},
})
}
const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
@ -167,7 +220,18 @@
const stepStore = writable({})
$: stepState = $stepStore?.[block.id]
$: customStepLayouts($memoBlock, schemaProperties, stepState)
const updateSelectedRow = testData => {
selectedRow = testData?.row
}
$: updateSelectedRow(testData)
$: customStepLayouts(
$memoBlock,
schemaProperties,
stepState,
fetchedRows,
selectedRow
)
const customStepLayouts = block => {
if (
@ -200,7 +264,6 @@
onChange({ ["revision"]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -228,7 +291,6 @@
onChange({ [rowIdentifier]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -336,6 +398,57 @@
]
}
const getTestDataSelector = () => {
if (!isTestModal) {
return []
}
return [
{
type: CoreSelect,
title: "Row",
props: {
disabled: !table,
placeholder: "Select a row",
options: fetchedRows,
loading: fetchLoading,
value: selectedRow,
autocomplete: true,
filter: false,
getOptionLabel: row => row?.[primaryDisplay] || "",
compare: (a, b) => a?.[primaryDisplay] === b?.[primaryDisplay],
onChange: e => {
if (isTestModal) {
onChange({
id: e.detail?._id,
revision: e.detail?._rev,
row: e.detail,
oldRow: e.detail,
meta: {
fields: inputData["meta"]?.fields || {},
oldFields: e.detail?.meta?.fields || {},
},
})
}
},
},
},
{
type: InfoDisplay,
props: {
warning: true,
icon: "AlertCircleFilled",
body: `Be careful when testing this automation because your data may be modified or deleted.`,
},
},
{
type: Divider,
props: {
noMargin: true,
},
},
]
}
stepLayouts[block.stepId] = {
row: {
schema: schema["row"],
@ -362,6 +475,7 @@
disabled: isTestModal,
},
},
...getTestDataSelector(),
...getIdConfig(),
...getRevConfig(),
...getRowTypeConfig(),
@ -476,6 +590,10 @@
...update,
})
if (!updatedAutomation) {
return
}
// Exclude default or invalid data from the test data
let updatedFields = {}
for (const key of Object.keys(block?.inputs?.fields || {})) {
@ -547,7 +665,7 @@
...newTestData,
body: {
...update,
...automation.testData?.body,
...(automation?.testData?.body || {}),
},
}
}
@ -556,6 +674,15 @@
...request,
}
if (
newTestData?.row == null ||
Object.keys(newTestData?.row).length === 0
) {
selectedRow = null
} else {
selectedRow = newTestData.row
}
const updatedAuto =
automationStore.actions.addTestDataToAutomation(newTestData)
@ -668,6 +795,8 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
bind:searchTerm={rowSearchTerm}
/>
</PropField>
{:else}
@ -676,6 +805,7 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
/>
{/if}
{/each}
@ -800,6 +930,7 @@
: "Add signature"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
context={$memoContext}
/>
{:else if isTestModal}
<ModalBindableInput
@ -824,6 +955,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
{/if}
</div>
@ -853,6 +985,7 @@
panel={AutomationBindingPanel}
showFilterEmptyDropdown={!rowTriggers.includes(stepId)}
on:change={e => (tempFilters = e.detail)}
evaluationContext={$memoContext}
/>
</DrawerContent>
</Drawer>
@ -895,7 +1028,19 @@
on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]}
/>
{:else if value.customType === "code"}
{:else if value.customType === "code" && stepId === ActionStepID.EXECUTE_SCRIPT_V2}
<div class="scriptv2-wrapper">
<DrawerBindableCodeEditorField
{bindings}
{schema}
panel={AutomationBindingPanel}
on:change={e => onChange({ [key]: e.detail })}
context={$memoContext}
value={inputData[key]}
/>
</div>
{:else if value.customType === "code" && stepId === ActionStepID.EXECUTE_SCRIPT}
<!-- DEPRECATED -->
<CodeEditorModal
on:hide={() => {
// Push any pending changes when the window closes
@ -914,6 +1059,7 @@
inputData[key] = e.detail
}}
completions={stepCompletions}
{bindings}
mode={codeMode}
autocompleteEnabled={codeMode !== EditorModes.JS}
bind:getCaretPosition
@ -977,6 +1123,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
</div>
{/if}

View File

@ -25,6 +25,7 @@
export let meta
export let bindings
export let isTestModal
export let context = {}
const typeToField = Object.values(FIELDS).reduce((acc, field) => {
acc[field.type] = field
@ -58,7 +59,7 @@
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
clone.icon = clone.icon ?? "ShareAndroid"
return clone
})
@ -258,6 +259,7 @@
fields: editableFields,
}}
{onChange}
{context}
/>
{:else}
<DrawerBindableSlot
@ -276,6 +278,7 @@
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
{context}
>
<RowSelectorTypes
{isTestModal}
@ -286,6 +289,7 @@
meta={{
fields: editableFields,
}}
{context}
onChange={change => onChange(change)}
/>
</DrawerBindableSlot>
@ -303,13 +307,22 @@
>
<ActionButton
icon="Add"
fullWidth
on:click={() => {
customPopover.show()
}}
disabled={!schemaFields}
>Add fields
</ActionButton>
<ActionButton
icon="Remove"
on:click={() => {
dispatch("change", {
meta: { fields: {} },
row: {},
})
}}
>Clear
</ActionButton>
</div>
{/if}
@ -375,4 +388,11 @@
.prop-control-wrap :global(.icon.json-slot-icon) {
right: 1px !important;
}
.add-fields-btn {
display: flex;
flex-direction: row;
justify-content: center;
gap: var(--spacing-s);
}
</style>

View File

@ -25,12 +25,13 @@
export let meta
export let bindings
export let isTestModal
export let context
$: fieldData = value[field]
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
clone.icon = clone.icon ?? "ShareAndroid"
return clone
})
@ -151,6 +152,7 @@
<div class="field-wrap json-field">
<CodeEditor
value={readableValue}
{bindings}
on:blur={e => {
onChange({
row: {
@ -232,6 +234,7 @@
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE) &&
fieldData}
{context}
/>
</div>
{:else}

View File

@ -1,18 +1,11 @@
<script>
import { Input, Select, Button } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
import { memo } from "@budibase/frontend-core"
import { generate } from "shortid"
export let value = {}
$: fieldsArray = value
? Object.entries(value).map(([name, type]) => ({
name,
type,
}))
: []
const typeOptions = [
{
label: "Text",
@ -36,16 +29,42 @@
},
]
const dispatch = createEventDispatcher()
const memoValue = memo({ data: {} })
$: memoValue.set({ data: value })
$: fieldsArray = $memoValue.data
? Object.entries($memoValue.data).map(([name, type]) => ({
name,
type,
id: generate(),
}))
: []
function addField() {
const newValue = { ...value }
const newValue = { ...$memoValue.data }
newValue[""] = "string"
dispatch("change", newValue)
fieldsArray = [...fieldsArray, { name: "", type: "string", id: generate() }]
}
function removeField(name) {
const newValues = { ...value }
delete newValues[name]
dispatch("change", newValues)
function removeField(idx) {
const entries = [...fieldsArray]
// Remove empty field
if (!entries[idx]?.name) {
fieldsArray.splice(idx, 1)
fieldsArray = [...fieldsArray]
return
}
entries.splice(idx, 1)
const update = entries.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
dispatch("change", update)
}
const fieldNameChanged = originalName => e => {
@ -57,11 +76,16 @@
} else {
entries = entries.filter(f => f.name !== originalName)
}
value = entries.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
dispatch("change", value)
const update = entries
.filter(entry => entry.name)
.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
if (Object.keys(update).length) {
dispatch("change", update)
}
}
</script>
@ -69,7 +93,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="root">
<div class="spacer" />
{#each fieldsArray as field}
{#each fieldsArray as field, idx (field.id)}
<div class="field">
<Input
value={field.name}
@ -88,7 +112,9 @@
/>
<i
class="remove-field ri-delete-bin-line"
on:click={() => removeField(field.name)}
on:click={() => {
removeField(idx)
}}
/>
</div>
{/each}
@ -115,4 +141,12 @@
align-items: center;
gap: var(--spacing-m);
}
.remove-field {
cursor: pointer;
}
.remove-field:hover {
color: var(--spectrum-global-color-gray-900);
}
</style>

View File

@ -1,132 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import {
keepOpen,
ModalContent,
notifications,
Body,
Layout,
Tabs,
Tab,
Heading,
TextArea,
Dropzone,
} from "@budibase/bbui"
import { datasources, queries } from "@/stores/builder"
import { writable } from "svelte/store"
export let navigateDatasource = false
export let datasourceId
export let createDatasource = false
export let onCancel
const data = writable({
url: "",
raw: "",
file: undefined,
})
let lastTouched = "url"
const getData = async () => {
let dataString
// parse the file into memory and send as string
if (lastTouched === "file") {
dataString = await $data.file.text()
} else if (lastTouched === "url") {
const response = await fetch($data.url)
dataString = await response.text()
} else if (lastTouched === "raw") {
dataString = $data.raw
}
return dataString
}
async function importQueries() {
try {
const dataString = await getData()
if (!datasourceId && !createDatasource) {
throw new Error("No datasource id")
}
const body = {
data: dataString,
datasourceId,
}
const importResult = await queries.import(body)
if (!datasourceId) {
datasourceId = importResult.datasourceId
}
// reload
await datasources.fetch()
await queries.fetch()
if (navigateDatasource) {
$goto(`./datasource/${datasourceId}`)
}
notifications.success(`Imported successfully.`)
} catch (error) {
notifications.error("Error importing queries")
return keepOpen
}
}
</script>
<ModalContent
onConfirm={() => importQueries()}
{onCancel}
confirmText={"Import"}
cancelText="Back"
size="L"
>
<Layout noPadding>
<Heading size="S">Import</Heading>
<Body size="XS"
>Import your rest collection using one of the options below</Body
>
<Tabs selected="File">
<!-- Commenting until nginx csp issue resolved -->
<!-- <Tab title="Link">
<Input
bind:value={$data.url}
on:change={() => (lastTouched = "url")}
label="Enter a URL"
placeholder="e.g. https://petstore.swagger.io/v2/swagger.json"
/>
</Tab> -->
<Tab title="File">
<Dropzone
gallery={false}
value={$data.file ? [$data.file] : []}
on:change={e => {
$data.file = e.detail?.[0]
lastTouched = "file"
}}
fileTags={[
"OpenAPI 3.0",
"OpenAPI 2.0",
"Swagger 2.0",
"cURL",
"YAML",
"JSON",
]}
maximum={1}
/>
</Tab>
<Tab title="Raw Text">
<TextArea
bind:value={$data.raw}
on:change={() => (lastTouched = "raw")}
label={"Paste raw text"}
placeholder={'e.g. curl --location --request GET "https://example.com"'}
/>
</Tab>
</Tabs>
</Layout>
</ModalContent>

View File

@ -43,7 +43,7 @@
const validateDescription = description => {
if (!description?.length) {
return "Please enter a name"
return "Please enter a description"
}
return null
}

View File

@ -1,5 +1,18 @@
<script context="module" lang="ts">
export const DropdownPosition = {
Relative: "top",
Absolute: "right",
}
</script>
<script lang="ts">
import { Label } from "@budibase/bbui"
import {
Button,
Label,
notifications,
Popover,
TextArea,
} from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
@ -46,10 +59,18 @@
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types"
import {
type EnrichedBinding,
FeatureFlag,
type EditorMode,
} from "@budibase/types"
import { tooltips } from "@codemirror/view"
import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js"
import { featureFlag } from "@/helpers"
import { API } from "@/api"
import Spinner from "../Spinner.svelte"
export let label: string | undefined = undefined
export let completions: BindingCompletion[] = []
@ -62,11 +83,14 @@
export let jsBindingWrapping = true
export let readonly = false
export let readonlyLineNumbers = false
export let dropdown = DropdownPosition.Relative
export let bindings: EnrichedBinding[] = []
const dispatch = createEventDispatcher()
let textarea: HTMLDivElement
let editor: EditorView
let editorEle: HTMLDivElement
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
@ -76,6 +100,14 @@
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
let popoverAnchor: HTMLElement
let popover: Popover
let promptInput: TextArea
$: aiGenEnabled =
featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) &&
mode.name === "javascript" &&
!readonly
$: {
if (autofocus && isEditorInitialised) {
editor.focus()
@ -117,7 +149,6 @@
queuedRefresh = true
return
}
if (
editor &&
value &&
@ -130,6 +161,68 @@
}
}
$: promptLoading = false
let popoverWidth = 300
let suggestedCode: string | null = null
let previousContents: string | null = null
const generateJs = async (prompt: string) => {
previousContents = editor.state.doc.toString()
promptLoading = true
popoverWidth = 30
let code = ""
try {
const resp = await API.generateJs({ prompt, bindings })
code = resp.code
if (code === "") {
throw new Error(
"we didn't understand your prompt, please phrase your request in another way"
)
}
} catch (e) {
console.error(e)
if (e instanceof Error) {
notifications.error(`Unable to generate code: ${e.message}`)
} else {
notifications.error("Unable to generate code, please try again later.")
}
code = previousContents
promptLoading = false
resetPopover()
return
}
value = code
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code },
})
suggestedCode = code
popoverWidth = 100
promptLoading = false
}
const acceptSuggestion = () => {
suggestedCode = null
previousContents = null
resetPopover()
dispatch("change", editor.state.doc.toString())
dispatch("blur", editor.state.doc.toString())
}
const rejectSuggestion = () => {
suggestedCode = null
value = previousContents || ""
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
})
previousContents = null
resetPopover()
}
const resetPopover = () => {
popover.hide()
popoverWidth = 300
}
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
@ -271,16 +364,15 @@
EditorView.inputHandler.of((view, from, to, insert) => {
if (jsBindingWrapping && insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const wrap =
(!left.includes('$("') || !right.includes('")')) &&
!(left.includes("`") && right.includes("`"))
const anchor = from + (wrap ? 3 : 1)
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
@ -288,6 +380,19 @@
}
)
view.dispatch(tr)
// the selection needs to fired after the dispatch - this seems
// to fix an issue with the cursor not moving when the editor is
// first loaded, the first usage of the editor is not ready
// for the anchor to move as well as perform a change
setTimeout(() => {
view.dispatch(
view.state.update({
selection: {
anchor,
},
})
)
}, 1)
return true
}
return false
@ -369,14 +474,25 @@
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value?.toString(),
extensions: buildExtensions(baseExtensions),
doc: String(value),
extensions: buildExtensions([
...baseExtensions,
dropdown == DropdownPosition.Absolute
? tooltips({
position: "absolute",
})
: [],
]),
parent: textarea,
})
}
onMount(async () => {
mounted = true
// Capture scrolling
editorEle.addEventListener("wheel", e => {
e.stopPropagation()
})
})
onDestroy(() => {
@ -391,15 +507,66 @@
<Label size="S">{label}</Label>
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div class={`code-editor ${mode?.name || ""}`} bind:this={editorEle}>
<div tabindex="-1" bind:this={textarea} />
</div>
{#if aiGenEnabled}
<button
bind:this={popoverAnchor}
class="ai-gen"
on:click={() => {
popover.show()
setTimeout(() => {
promptInput.focus()
}, 100)
}}
>
Generate with AI ✨
</button>
<Popover
bind:this={popover}
minWidth={popoverWidth}
anchor={popoverAnchor}
on:close={() => {
if (suggestedCode) {
acceptSuggestion()
}
}}
align="left-outside"
>
{#if promptLoading}
<div class="prompt-spinner">
<Spinner size="20" color="white" />
</div>
{:else if suggestedCode !== null}
<Button on:click={acceptSuggestion}>Accept</Button>
<Button on:click={rejectSuggestion}>Reject</Button>
{:else}
<TextArea
bind:this={promptInput}
placeholder="Type your prompt then press enter..."
on:keypress={event => {
if (event.getModifierState("Shift")) {
return
}
if (event.key === "Enter") {
generateJs(promptInput.contents())
}
}}
/>
{/if}
</Popover>
{/if}
<style>
/* Editor */
.code-editor {
font-size: 12px;
height: 100%;
cursor: text;
}
.code-editor :global(.cm-editor) {
height: 100%;
@ -559,12 +726,11 @@
/* Live binding value / helper container */
.code-editor :global(.cm-completionInfo) {
margin-left: var(--spacing-s);
margin: 0px var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--border-radius-s);
background-color: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m);
margin-top: -2px;
}
/* Wrapper around helpers */
@ -589,6 +755,7 @@
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
overflow-y: auto;
max-height: 480px;
}
.code-editor :global(.binding__example.helper) {
@ -599,4 +766,34 @@
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.ai-gen {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
padding: var(--spacing-s);
border-left: 1px solid var(--spectrum-alias-border-color);
border-top: 1px solid var(--spectrum-alias-border-color);
border-top-left-radius: var(--spectrum-alias-border-radius-regular);
color: var(--spectrum-global-color-blue-700);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.ai-gen:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.prompt-spinner {
padding: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import { DrawerBindableInput } from "@/components/common/bindings"
</script>
<DrawerBindableInput
on:change
on:blur
on:drawerHide
on:drawerShow
{...$$props}
multiline
/>

View File

@ -2,10 +2,11 @@
import { Circle } from "svelte-loading-spinners"
export let size = "60"
export let color = "var(--ink)"
</script>
<div class="spinner-container">
<Circle {size} color="var(--ink)" unit="px" />
<Circle {size} {color} unit="px" />
</div>
<style>

View File

@ -5,8 +5,8 @@
import { appsStore } from "@/stores/portal"
import { API } from "@/api"
import { writable } from "svelte/store"
import { createValidationStore } from "@/helpers/validation/yup"
import * as appValidation from "@/helpers/validation/yup/app"
import { createValidationStore } from "@budibase/frontend-core/src/utils/validation/yup"
import * as appValidation from "@budibase/frontend-core/src/utils/validation/yup/app"
import EditableIcon from "@/components/common/EditableIcon.svelte"
import { isEqual } from "lodash"
import { createEventDispatcher } from "svelte"

View File

@ -27,12 +27,11 @@
} from "../CodeEditor"
import BindingSidePanel from "./BindingSidePanel.svelte"
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils"
import { capitalise } from "@/helpers"
import { Utils, JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import { BindingMode, SidePanel } from "@budibase/types"
import { BindingMode } from "@budibase/types"
import type {
EnrichedBinding,
Snippet,
@ -44,6 +43,8 @@
import type { Log } from "@budibase/string-templates"
import type { CodeValidator } from "@/types"
type SidePanel = "Bindings" | "Evaluation"
const dispatch = createEventDispatcher()
export let bindings: EnrichedBinding[] = []
@ -55,7 +56,7 @@
export let context = null
export let snippets: Snippet[] | null = null
export let autofocusEditor = false
export let placeholder = null
export let placeholder: string | null = null
export let showTabBar = true
let mode: BindingMode
@ -71,14 +72,13 @@
let expressionError: string | undefined
let evaluating = false
$: useSnippets = allowSnippets && !$licensing.isFreePlan
const SidePanelIcons: Record<SidePanel, string> = {
Bindings: "FlashOn",
Evaluation: "Play",
}
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sidePanelOptions = getSidePanelOptions(
bindings,
context,
allowSnippets,
mode
)
$: sidePanelOptions = getSidePanelOptions(bindings, context)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === BindingMode.JavaScript
$: editorMode =
@ -93,7 +93,9 @@
$: bindingOptions = bindingsToCompletions(enrichedBindings, editorMode)
$: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : []
$: snippetsOptions =
usingJS && useSnippets && snippets?.length ? snippets : []
usingJS && allowSnippets && !$licensing.isFreePlan && snippets?.length
? snippets
: []
$: completions = !usingJS
? [hbAutocomplete([...bindingOptions, ...helperOptions])]
@ -137,21 +139,13 @@
return options
}
const getSidePanelOptions = (
bindings: EnrichedBinding[],
context: any,
useSnippets: boolean,
mode: BindingMode | null
) => {
let options = []
const getSidePanelOptions = (bindings: EnrichedBinding[], context: any) => {
let options: SidePanel[] = []
if (bindings?.length) {
options.push(SidePanel.Bindings)
options.push("Bindings")
}
if (context && Object.keys(context).length > 0) {
options.push(SidePanel.Evaluation)
}
if (useSnippets && mode === BindingMode.JavaScript) {
options.push(SidePanel.Snippets)
options.push("Evaluation")
}
return options
}
@ -342,14 +336,15 @@
{/each}
</div>
<div class="side-tabs">
{#each sidePanelOptions as panel}
{#each sidePanelOptions as panelOption}
<ActionButton
size="M"
quiet
selected={sidePanel === panel}
on:click={() => changeSidePanel(panel)}
selected={sidePanel === panelOption}
on:click={() => changeSidePanel(panelOption)}
tooltip={panelOption}
>
<Icon name={panel} size="S" />
<Icon name={SidePanelIcons[panelOption]} size="S" />
</ActionButton>
{/each}
</div>
@ -359,11 +354,12 @@
{#if mode === BindingMode.Text}
{#key completions}
<CodeEditor
value={hbsValue}
value={hbsValue || ""}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
{completions}
{bindings}
{validations}
autofocus={autofocusEditor}
placeholder={placeholder ||
@ -374,9 +370,10 @@
{:else if mode === BindingMode.JavaScript}
{#key completions}
<CodeEditor
value={jsValue ? decodeJSBinding(jsValue) : jsValue}
value={jsValue ? decodeJSBinding(jsValue) : ""}
on:change={onChangeJSValue}
{completions}
{bindings}
{validations}
mode={EditorModes.JS}
bind:getCaretPosition
@ -415,16 +412,19 @@
</div>
</div>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanel.Bindings}
{#if sidePanel === "Bindings"}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
{allowSnippets}
{context}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
{addSnippet}
{mode}
{snippets}
/>
{:else if sidePanel === SidePanel.Evaluation}
{:else if sidePanel === "Evaluation"}
<EvaluationSidePanel
{expressionResult}
{expressionError}
@ -432,8 +432,6 @@
{evaluating}
expression={editorValue ? editorValue : ""}
/>
{:else if sidePanel === SidePanel.Snippets}
<SnippetSidePanel {addSnippet} {snippets} />
{/if}
</div>
</div>

View File

@ -1,31 +1,52 @@
<script lang="ts">
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, Icon, Popover } from "@budibase/bbui"
import { licensing } from "@/stores/portal"
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import { handlebarsCompletions } from "@/constants/completions"
import type { EnrichedBinding, Helper } from "@budibase/types"
import type { EnrichedBinding, Helper, Snippet } from "@budibase/types"
import { BindingMode } from "@budibase/types"
import { EditorModes } from "../CodeEditor"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import SnippetDrawer from "./SnippetDrawer.svelte"
import UpgradeButton from "@/pages/builder/portal/_components/UpgradeButton.svelte"
export let addHelper: (_helper: Helper, _js?: boolean) => void
export let addBinding: (_binding: EnrichedBinding) => void
export let addSnippet: (_snippet: Snippet) => void
export let bindings: EnrichedBinding[]
export let snippets: Snippet[] | null
export let mode: BindingMode
export let allowHelpers: boolean
export let allowSnippets: boolean
export let context = null
let search = ""
let searching = false
let popover: Popover
let popoverAnchor: HTMLElement | null
let popoverAnchor: HTMLElement | undefined
let hoverTarget: {
helper: boolean
type: "binding" | "helper" | "snippet"
code: string
description?: string
} | null
let helpers = handlebarsCompletions()
let selectedCategory: string | null
let hideTimeout: ReturnType<typeof setTimeout> | null
let snippetDrawer: SnippetDrawer
let editableSnippet: Snippet | null
$: enableSnippets = !$licensing.isFreePlan
$: bindingIcons = bindings?.reduce<Record<string, string>>((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
@ -35,9 +56,14 @@
$: categoryIcons = {
...bindingIcons,
Helpers: "MagicWand",
Snippets: "Code",
} as Record<string, string>
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: categoryNames = getCategoryNames(
categories,
allowSnippets && mode === BindingMode.JavaScript
)
$: searchRgx = new RegExp(search, "ig")
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
@ -61,6 +87,17 @@
)
})
$: filteredSnippets = getFilteredSnippets(
enableSnippets,
snippets || [],
search
)
function onModeChange(_mode: BindingMode) {
selectedCategory = null
}
$: onModeChange(mode)
const getHelperExample = (helper: Helper, js: boolean) => {
let example = helper.example || ""
if (js) {
@ -72,11 +109,17 @@
return example || ""
}
const getCategoryNames = (categories: [string, EnrichedBinding[]][]) => {
const getCategoryNames = (
categories: [string, EnrichedBinding[]][],
showSnippets: boolean
) => {
const names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
if (showSnippets) {
names.push("Snippets")
}
return names
}
@ -90,21 +133,23 @@
stopHidingPopover()
popoverAnchor = target
hoverTarget = {
helper: false,
type: "binding",
code: binding.valueHTML,
}
popover.show()
}
const showHelperPopover = (helper: any, target: HTMLElement) => {
const showHelperPopover = (helper: Helper, target: HTMLElement) => {
stopHidingPopover()
if (!helper.displayText && helper.description) {
return
}
popoverAnchor = target
const doc = new DOMParser().parseFromString(helper.description, "text/html")
hoverTarget = {
helper: true,
description: helper.description,
type: "helper",
description: doc.body.textContent || "",
code: getHelperExample(helper, mode === BindingMode.JavaScript),
}
popover.show()
@ -113,7 +158,7 @@
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
popoverAnchor = undefined
hoverTarget = null
hideTimeout = null
}, 100)
@ -136,34 +181,86 @@
searching = false
search = ""
}
const getFilteredSnippets = (
enableSnippets: boolean,
snippets: Snippet[],
search: string
) => {
if (!enableSnippets || !snippets.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet: Snippet, target: HTMLElement) => {
stopHidingPopover()
if (!snippet.code) {
return
}
popoverAnchor = target
hoverTarget = {
type: "snippet",
code: snippet.code,
}
popover.show()
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e: Event, snippet: Snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
{#if popoverAnchor && hoverTarget}
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="binding-popover" class:helper={hoverTarget.helper}>
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
{#if hoverTarget}
<div
class="binding-popover"
class:has-code={hoverTarget.type !== "binding"}
>
{#if hoverTarget.description}
<div>
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html hoverTarget.description}
{hoverTarget.description}
</div>
{/if}
{#if hoverTarget.code}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<pre>{@html hoverTarget.code}</pre>
{#if mode === BindingMode.Text || (mode === BindingMode.JavaScript && hoverTarget.type === "binding")}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<pre>{@html hoverTarget.code}</pre>
{:else}
<CodeEditor
value={hoverTarget.code?.trim()}
mode={EditorModes.JS}
readonly
/>
{/if}
{/if}
</div>
</Popover>
{/if}
{/if}
</Popover>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
@ -178,6 +275,25 @@
on:click={() => (selectedCategory = null)}
/>
{selectedCategory}
{#if selectedCategory === "Snippets"}
{#if enableSnippets}
<div class="add-snippet-button">
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
</div>
{:else}
<div class="title">
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
{/if}
</div>
{/if}
@ -281,7 +397,6 @@
class="binding"
on:mouseenter={e =>
showHelperPopover(helper, e.currentTarget)}
on:mouseleave={hidePopover}
on:click={() =>
addHelper(helper, mode === BindingMode.JavaScript)}
>
@ -295,10 +410,48 @@
</div>
{/if}
{/if}
{#if selectedCategory === "Snippets" || search}
<div class="snippet-list">
{#if enableSnippets && filteredSnippets.length}
{#each filteredSnippets as snippet}
<li
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.currentTarget)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</li>
{/each}
{:else if !search}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that
can all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</Layout>
</div>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.binding-side-panel {
border-left: var(--border-light);
@ -363,6 +516,7 @@
display: flex;
align-items: center;
gap: var(--spacing-m);
justify-content: space-between;
}
li.binding .binding__typeWrap {
flex: 1;
@ -438,7 +592,7 @@
text-overflow: ellipsis;
overflow: hidden;
}
.binding-popover.helper pre {
.binding-popover.has-code pre {
color: var(--spectrum-global-color-blue-700);
}
.binding-popover pre :global(span) {
@ -450,7 +604,50 @@
padding: 0;
margin: 0;
}
.binding-popover.helper :global(code) {
.binding-popover.has-code :global(code) {
font-size: 12px;
}
.binding-popover.has-code :global(.cm-line),
.binding-popover.has-code :global(.cm-content) {
padding: 0;
}
/* Snippets */
.add-snippet-button {
margin-left: auto;
}
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
}
.snippet {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
</style>

View File

@ -0,0 +1,177 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import {
decodeJSBinding,
encodeJSBinding,
processObjectSync,
} from "@budibase/string-templates"
import {
runtimeToReadableBinding,
readableToRuntimeBinding,
} from "@/dataBinding"
import CodeEditor, { DropdownPosition } from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
snippetAutoComplete,
EditorModes,
bindingsToCompletions,
jsHelperAutocomplete,
} from "../CodeEditor"
import { JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import type {
EnrichedBinding,
Snippet,
CaretPositionFn,
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { BindingCompletion, BindingCompletionOption } from "@/types"
import { snippets } from "@/stores/builder"
const dispatch = createEventDispatcher()
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let autofocusEditor = false
export let placeholder = null
export let height = 180
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
$: readable = runtimeToReadableBinding(bindings, value || "")
$: jsValue = decodeJSBinding(readable)
$: useSnippets = allowSnippets && !$licensing.isFreePlan
$: enrichedBindings = enrichBindings(bindings, context, $snippets)
$: editorMode = EditorModes.JS
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: jsCompletions = getJSCompletions(bindingCompletions, $snippets, {
useHelpers: allowHelpers,
useSnippets,
})
const getJSCompletions = (
bindingCompletions: BindingCompletionOption[],
snippets: Snippet[] | null,
config: {
useHelpers: boolean
useSnippets: boolean
}
) => {
const completions: BindingCompletion[] = []
if (bindingCompletions.length) {
completions.push(jsAutocomplete([...bindingCompletions]))
}
if (config.useHelpers) {
completions.push(
jsHelperAutocomplete([...getHelperCompletions(EditorModes.JS)])
)
}
if (config.useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const highlightJSON = (json: JSONValue) => {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const enrichBindings = (
bindings: EnrichedBinding[],
context: any,
snippets: Snippet[] | null
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
// Account for nasty hardcoded HBS bindings for roles, for legacy
// compatibility
return `{{ ${binding.runtimeBinding} }}`
} else {
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvaluations = processObjectSync(bindingStrings, {
...context,
snippets,
})
// Enrich bindings with evaluations and highlighted HTML
return bindings.map((binding, idx) => {
if (!context || typeof bindingEvaluations !== "object") {
return binding
}
const evalObj: Record<any, any> = bindingEvaluations
const value = JSON.stringify(evalObj[idx], null, 2)
return {
...binding,
value,
valueHTML: highlightJSON(value),
}
})
}
const updateValue = (val: any) => {
dispatch("change", readableToRuntimeBinding(bindings, val))
}
const onChangeJSValue = (e: { detail: string }) => {
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(encodeJSBinding(e.detail))
}
}
</script>
<div class="code-panel" style="height:{height}px;">
<div class="editor">
{#key jsCompletions}
<CodeEditor
value={jsValue || ""}
on:change={onChangeJSValue}
on:blur
completions={jsCompletions}
{bindings}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping
dropdown={DropdownPosition.Absolute}
/>
{/key}
</div>
</div>
<style>
.code-panel {
display: flex;
}
/* Editor */
.editor {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -0,0 +1,68 @@
<script>
import { createEventDispatcher } from "svelte"
import {
ClientBindingPanel,
DrawerBindableSlot,
} from "@/components/common/bindings"
import CodeEditorField from "@/components/common/bindings/CodeEditorField.svelte"
export let value = ""
export let panel = ClientBindingPanel
export let schema = null
export let bindings = []
export let context = {}
export let height = 180
const dispatch = createEventDispatcher()
</script>
<div class="wrapper">
<DrawerBindableSlot
{panel}
{schema}
{value}
{bindings}
{context}
title="Edit Code"
type="longform"
allowJS={true}
allowHBS={false}
updateOnChange={false}
on:change={e => {
value = e.detail
dispatch("change", value)
}}
>
<div class="code-editor-wrapper">
<CodeEditorField
{value}
{bindings}
{context}
{height}
allowHBS={false}
allowJS
placeholder={"Add bindings by typing $"}
on:change={e => (value = e.detail)}
on:blur={() => dispatch("change", value)}
/>
</div>
</DrawerBindableSlot>
</div>
<style>
.wrapper :global(.icon.slot-icon) {
top: 1px;
border-radius: 0 4px 0 4px;
border-right: 0;
border-bottom: 1px solid var(--spectrum-alias-border-color);
}
.wrapper :global(.cm-editor),
.wrapper :global(.cm-scroller) {
border-radius: 4px;
}
.code-editor-wrapper {
box-sizing: border-box;
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
}
</style>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon, Input, Drawer, Button } from "@budibase/bbui"
import { Icon, Input, Drawer, Button, CoreTextArea } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
@ -25,11 +25,13 @@
export let forceModal: boolean = false
export let context = null
export let autocomplete: boolean | undefined = undefined
export let multiline: boolean = false
const dispatch = createEventDispatcher()
let bindingDrawer: any
let currentVal = value
let scrollable = false
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
@ -63,14 +65,16 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:disabled>
<Input
<div class="control" class:multiline class:disabled class:scrollable>
<svelte:component
this={multiline ? CoreTextArea : Input}
{label}
{disabled}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
on:change={event => onChange(event.detail)}
on:blur={onBlur}
on:scrollable={e => (scrollable = e.detail)}
{placeholder}
{updateOnChange}
{autocomplete}
@ -114,36 +118,38 @@
position: relative;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
/* Multiline styles */
.control.multiline :global(textarea) {
min-height: 0 !important;
field-sizing: content;
max-height: 105px;
padding: 6px 11px 6px 11px;
height: auto;
resize: none;
flex: 1 1 auto;
width: 0;
}
.icon {
right: 6px;
top: 8px;
position: absolute;
display: grid;
place-items: center;
box-sizing: border-box;
border-radius: 4px;
color: var(--spectrum-alias-text-color);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
color: var(--spectrum-global-color-blue-600);
}
.control.scrollable .icon {
right: 12px;
}
.control:not(.disabled) :global(.spectrum-Textfield-input) {
padding-right: 40px;
.control:not(.disabled) :global(.spectrum-Textfield-input),
.control:not(.disabled) :global(textarea) {
padding-right: 26px;
}
</style>

View File

@ -22,6 +22,8 @@
export let updateOnChange = true
export let type
export let schema
export let allowHBS = true
export let context = {}
const dispatch = createEventDispatcher()
let bindingDrawer
@ -147,7 +149,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:disabled>
{#if !isValid(value)}
{#if !isValid(value) && !$$slots.default}
<Input
{label}
{disabled}
@ -171,7 +173,7 @@
{:else}
<slot />
{/if}
{#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
{#if !disabled && type !== "formula" && !attachmentTypes.includes(type)}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {
@ -187,7 +189,6 @@
on:drawerShow
bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"}
forceModal={true}
>
<Button cta slot="buttons" on:click={saveBinding}>Save</Button>
<svelte:component
@ -197,7 +198,9 @@
on:change={event => (tempValue = event.detail)}
{bindings}
{allowJS}
{allowHBS}
{allowHelpers}
{context}
/>
</Drawer>
@ -208,22 +211,22 @@
}
.slot-icon {
right: 31px !important;
right: 31px;
border-right: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: 0px !important;
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.text-area-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 1px !important;
border-bottom-right-radius: 0px;
top: 1px;
}
.json-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 1px !important;
right: 0px !important;
border-bottom-right-radius: 0px;
top: 1px;
right: 0px;
}
.icon {

View File

@ -5,6 +5,7 @@
export let bindings = []
export let value = ""
export let allowJS = false
export let allowHBS = true
export let context = null
$: enrichedBindings = enrichBindings(bindings)
@ -22,8 +23,10 @@
<BindingPanel
bindings={enrichedBindings}
snippets={$snippets}
allowHelpers
{value}
{allowJS}
{allowHBS}
{context}
on:change
/>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import {
Button,
Drawer,
@ -14,19 +14,27 @@
import { getSequentialName } from "@/helpers/duplicate"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
import type { Snippet } from "@budibase/types"
export let snippet
export const show = () => drawer.show()
export const show = () => {
if (!snippet) {
key = Math.random().toString()
// Reset state when creating multiple snippets
code = ""
name = defaultName
}
drawer.show()
}
export const hide = () => drawer.hide()
export let snippet: Snippet | null
const firstCharNumberRegex = /^[0-9].*$/
let drawer
let drawer: Drawer
let name = ""
let code = ""
let loading = false
let deleteConfirmationDialog
let deleteConfirmationDialog: ConfirmDialog
$: defaultName = getSequentialName($snippets, "MySnippet", {
getName: x => x.name,
@ -40,11 +48,11 @@
const saveSnippet = async () => {
loading = true
try {
const newSnippet = { name, code: rawJS }
const newSnippet: Snippet = { name, code: rawJS || "" }
await snippets.saveSnippet(newSnippet)
drawer.hide()
notifications.success(`Snippet ${newSnippet.name} saved`)
} catch (error) {
} catch (error: any) {
notifications.error(error.message || "Error saving snippet")
}
loading = false
@ -53,7 +61,9 @@
const deleteSnippet = async () => {
loading = true
try {
await snippets.deleteSnippet(snippet.name)
if (snippet) {
await snippets.deleteSnippet(snippet.name)
}
drawer.hide()
} catch (error) {
notifications.error("Error deleting snippet")
@ -61,7 +71,7 @@
loading = false
}
const validateName = (name, snippets) => {
const validateName = (name: string, snippets: Snippet[]) => {
if (!name?.length) {
return "Name is required"
}
@ -108,7 +118,11 @@
Delete
</Button>
{/if}
<Button cta on:click={saveSnippet} disabled={!code || loading || nameError}>
<Button
cta
on:click={saveSnippet}
disabled={!code || loading || !!nameError}
>
Save
</Button>
</svelte:fragment>
@ -124,9 +138,7 @@
value={code}
on:change={e => (code = e.detail)}
>
<div slot="tabs">
<Input placeholder="Name" />
</div>
<Input placeholder="Name" />
</BindingPanel>
{/key}
</svelte:fragment>

View File

@ -1,278 +0,0 @@
<script>
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import CodeEditor from "@/components/common/CodeEditor/CodeEditor.svelte"
import { EditorModes } from "@/components/common/CodeEditor"
import SnippetDrawer from "./SnippetDrawer.svelte"
import { licensing } from "@/stores/portal"
import UpgradeButton from "@/pages/builder/portal/_components/UpgradeButton.svelte"
export let addSnippet
export let snippets
let search = ""
let searching = false
let popover
let popoverAnchor
let hoveredSnippet
let hideTimeout
let snippetDrawer
let editableSnippet
$: enableSnippets = !$licensing.isFreePlan
$: filteredSnippets = getFilteredSnippets(enableSnippets, snippets, search)
const getFilteredSnippets = (enableSnippets, snippets, search) => {
if (!enableSnippets || !snippets?.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet, target) => {
stopHidingPopover()
popoverAnchor = target
hoveredSnippet = snippet
popover.show()
}
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
hoveredSnippet = null
hideTimeout = null
}, 100)
}
const stopHidingPopover = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const startSearching = () => {
searching = true
search = ""
}
const stopSearching = () => {
searching = false
search = ""
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e, snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="snippet-side-panel">
<Layout noPadding gap="S">
<div class="header">
{#if enableSnippets}
{#if searching}
<div class="search-input">
<Input
placeholder="Search for snippets"
autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/>
{:else}
<div class="title">Snippets</div>
<Icon
size="S"
name="Search"
hoverable
newStyles
on:click={startSearching}
/>
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
{/if}
{:else}
<div class="title">
Snippets
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
</div>
<div class="snippet-list">
{#if enableSnippets && filteredSnippets?.length}
{#each filteredSnippets as snippet}
<div
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.target)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</div>
{/each}
{:else}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that can
all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
</Layout>
</div>
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="snippet-popover">
{#key hoveredSnippet}
<CodeEditor
value={hoveredSnippet.code?.trim()}
mode={EditorModes.JS}
readonly
/>
{/key}
</div>
</Popover>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.snippet-side-panel {
border-left: var(--border-light);
height: 100%;
overflow: auto;
}
/* Header */
.header {
height: 53px;
padding: 0 var(--spacing-l);
display: flex;
align-items: center;
border-bottom: var(--border-light);
position: sticky;
top: 0;
gap: var(--spacing-m);
background: var(--background);
z-index: 1;
}
.header :global(input) {
border: none;
border-radius: 0;
background: none;
padding: 0;
}
.search-input,
.title {
flex: 1 1 auto;
}
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
/* List */
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.snippet {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Popover */
.snippet-popover {
width: 400px;
}
</style>

View File

@ -8,5 +8,3 @@ export { default as DrawerBindableSlot } from "./DrawerBindableSlot.svelte"
export { default as EvaluationSidePanel } from "./EvaluationSidePanel.svelte"
export { default as ModalBindableInput } from "./ModalBindableInput.svelte"
export { default as ServerBindingPanel } from "./ServerBindingPanel.svelte"
export { default as SnippetDrawer } from "./SnippetDrawer.svelte"
export { default as SnippetSidePanel } from "./SnippetSidePanel.svelte"

View File

@ -9,6 +9,8 @@
export let type = "label"
export let size = "M"
export const disableEditingState = () => setEditing(false)
let editing = false
function setEditing(state) {

View File

@ -31,9 +31,11 @@ import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
import FormStepControls from "./controls/FormStepControls.svelte"
import PaywalledSetting from "./controls/PaywalledSetting.svelte"
import TableConditionEditor from "./controls/TableConditionEditor.svelte"
import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte"
const componentMap = {
text: DrawerBindableInput,
"text/multiline": MultilineDrawerBindableInput,
plainText: Input,
select: Select,
radio: RadioGroup,

View File

@ -2,6 +2,7 @@
import { Label, Select, Body } from "@budibase/bbui"
import { findAllMatchingComponents } from "@/helpers/components"
import { selectedScreen } from "@/stores/builder"
import { InlineAlert } from "@budibase/bbui"
export let parameters
@ -27,6 +28,12 @@
<Label small>Table</Label>
<Select bind:value={parameters.componentId} options={componentOptions} />
</div>
<InlineAlert
header="Legacy action"
message="This action is only compatible with the (deprecated) Table Block. Please see the documentation for further info."
link="https://docs.budibase.com/docs/data-actions#clear-row-selection"
linkText="Budibase Documentation"
/>
</div>
<style>

View File

@ -0,0 +1,41 @@
<script>
import { Label, Checkbox } from "@budibase/bbui"
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings = []
</script>
<div class="root">
<Label>Text to copy</Label>
<DrawerBindableInput
title="Text to copy"
{bindings}
value={parameters.textToCopy}
on:change={e => (parameters.textToCopy = e.detail)}
/>
<Label />
<Checkbox text="Show notification" bind:value={parameters.showNotification} />
{#if parameters.showNotification}
<Label>Notification message</Label>
<DrawerBindableInput
title="Notification message"
{bindings}
value={parameters.notificationMessage}
placeholder="Copied to clipboard"
on:change={e => (parameters.notificationMessage = e.detail)}
/>
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 120px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -26,3 +26,4 @@ export { default as CloseModal } from "./CloseModal.svelte"
export { default as ClearRowSelection } from "./ClearRowSelection.svelte"
export { default as DownloadFile } from "./DownloadFile.svelte"
export { default as RowAction } from "./RowAction.svelte"
export { default as CopyToClipboard } from "./CopyToClipboard.svelte"

View File

@ -183,6 +183,17 @@
"name": "Row Action",
"type": "data",
"component": "RowAction"
},
{
"name": "Copy To Clipboard",
"type": "data",
"component": "CopyToClipboard",
"context": [
{
"label": "Copied text",
"value": "copied"
}
]
}
]
}

View File

@ -16,6 +16,7 @@
export let datasource
export let builderType
export let docsURL
export let evaluationContext = {}
</script>
<CoreFilterBuilder
@ -32,5 +33,6 @@
{allowOnEmpty}
{builderType}
{docsURL}
{evaluationContext}
on:change
/>

View File

@ -13,7 +13,7 @@
import { lowercase } from "@/helpers"
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
let dispatch = createEventDispatcher()
const dispatch = createEventDispatcher()
export let defaults
export let object = defaults || {}
@ -39,6 +39,7 @@
export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value
export let context = null
let fields = Object.entries(object || {}).map(([name, value]) => ({
name,
@ -46,10 +47,17 @@
}))
let fieldActivity = buildFieldActivity(activity)
$: object = fields.reduce(
(acc, next) => ({ ...acc, [next.name]: next.value }),
{}
)
$: fullObject = fields.reduce((acc, next) => {
acc[next.name] = next.value
return acc
}, {})
$: object = Object.entries(fullObject).reduce((acc, [key, next]) => {
if (key) {
acc[key] = next
}
return acc
}, {})
function buildFieldActivity(obj) {
if (!obj || typeof obj !== "object") {
@ -77,16 +85,19 @@
}
function changed() {
// Required for reactivity
fields = fields
const newActivity = {}
const trimmedFields = []
for (let idx = 0; idx < fields.length; idx++) {
const fieldName = fields[idx].name
if (fieldName) {
newActivity[fieldName] = fieldActivity[idx]
trimmedFields.push(fields[idx])
}
}
activity = newActivity
dispatch("change", fields)
dispatch("change", trimmedFields)
}
function isJsonArray(value) {
@ -101,7 +112,7 @@
</script>
<!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. -->
{#if Object.keys(object || {}).length > 0}
{#if Object.keys(fullObject || {}).length > 0}
{#if headings}
<div class="container" class:container-active={toggle}>
<Label {tooltip}>{keyHeading || keyPlaceholder}</Label>
@ -132,6 +143,7 @@
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}
<Input readonly={readOnly} bind:value={field.name} on:blur={changed} />
@ -158,6 +170,7 @@
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}
<Input

View File

@ -56,12 +56,13 @@
let query, datasource
let breakQs = {},
requestBindings = {}
let saveId, url
let saveId
let response, schema, enabledHeaders
let authConfigId
let dynamicVariables, addVariableModal, varBinding, globalDynamicBindings
let restBindings = getRestBindings()
let nestedSchemaFields = {}
let saving
let queryNameLabel
$: staticVariables = datasource?.config?.staticVariables || {}
@ -91,7 +92,7 @@
$: datasourceType = datasource?.source
$: integrationInfo = $integrations[datasourceType]
$: queryConfig = integrationInfo?.query
$: url = buildUrl(url, breakQs)
$: url = buildUrl(query?.fields?.path, breakQs)
$: checkQueryName(url)
$: responseSuccess = response?.info?.code >= 200 && response?.info?.code < 400
$: isGet = query?.queryVerb === "read"
@ -103,6 +104,10 @@
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
$: originalQuery = originalQuery ?? cloneDeep(query)
$: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings)
$: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery)
function getSelectedQuery() {
return cloneDeep(
$queries.list.find(q => q._id === queryId) || {
@ -126,7 +131,8 @@
?.trim() || inputUrl
function checkQueryName(inputUrl = null) {
if (query && (!query.name || query.flags.urlName)) {
if (query && (!query.name || query.flags?.urlName)) {
query.flags ??= {}
query.flags.urlName = true
query.name = cleanUrl(inputUrl)
}
@ -147,9 +153,12 @@
return qs.length === 0 ? newUrl : `${newUrl}?${qs}`
}
function buildQuery() {
const newQuery = cloneDeep(query)
const queryString = restUtils.buildQueryString(runtimeUrlQueries)
function buildQuery(fromQuery, urlQueries, requestBindings) {
if (!fromQuery) {
return
}
const newQuery = cloneDeep(fromQuery)
const queryString = restUtils.buildQueryString(urlQueries)
newQuery.parameters = restUtils.keyValueToQueryParameters(requestBindings)
newQuery.fields.requestBody =
@ -157,9 +166,8 @@
? readableToRuntimeMap(mergedBindings, newQuery.fields.requestBody)
: readableToRuntimeBinding(mergedBindings, newQuery.fields.requestBody)
newQuery.fields.path = url.split("?")[0]
newQuery.fields.path = url?.split("?")[0]
newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders)
newQuery.schema = schema || {}
newQuery.nestedSchemaFields = nestedSchemaFields || {}
@ -168,13 +176,12 @@
}
async function saveQuery() {
const toSave = buildQuery()
const toSave = builtQuery
saving = true
try {
const isNew = !query._rev
const { _id } = await queries.save(toSave.datasourceId, toSave)
saveId = _id
query = getSelectedQuery()
notifications.success(`Request saved successfully`)
if (dynamicVariables) {
datasource.config.dynamicVariables = rebuildVariables(saveId)
datasource = await datasources.save({
@ -182,6 +189,13 @@
datasource,
})
}
notifications.success(`Request saved successfully`)
if (isNew) {
$goto(`../../${_id}`)
}
query = getSelectedQuery()
prettifyQueryRequestBody(
query,
requestBindings,
@ -189,11 +203,15 @@
staticVariables,
restBindings
)
if (isNew) {
$goto(`../../${_id}`)
}
// Force rebuilding original query
originalQuery = null
queryNameLabel.disableEditingState()
} catch (err) {
notifications.error(`Error saving query`)
} finally {
saving = false
}
}
@ -227,7 +245,7 @@
async function runQuery() {
try {
await validateQuery()
response = await queries.preview(buildQuery())
response = await queries.preview(builtQuery)
if (response.rows.length === 0) {
notifications.info("Request did not return any data")
} else {
@ -249,22 +267,6 @@
}
}
const getAuthConfigId = () => {
let id = query.fields.authConfigId
if (id) {
// find the matching config on the datasource
const matchedConfig = datasource?.config?.authConfigs?.filter(
c => c._id === id
)[0]
// clear the id if the config is not found (deleted)
// i.e. just show 'None' in the dropdown
if (!matchedConfig) {
id = undefined
}
}
return id
}
const buildAuthConfigs = datasource => {
if (datasource?.config?.authConfigs) {
return datasource.config.authConfigs.map(c => ({
@ -375,13 +377,6 @@
}
}
const paramsChanged = evt => {
breakQs = {}
for (let param of evt.detail) {
breakQs[param.name] = param.value
}
}
const urlChanged = evt => {
breakQs = {}
const qs = evt.target.value.split("?")[1]
@ -426,9 +421,7 @@
) {
query.fields.path = `${datasource.config.url}/${path ? path : ""}`
}
url = buildUrl(query.fields.path, breakQs)
requestBindings = restUtils.queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) {
query.fields.disabledHeaders = {}
}
@ -497,6 +490,7 @@
<Layout gap="S">
<div class="top-bar">
<EditableLabel
bind:this={queryNameLabel}
type="heading"
bind:value={query.name}
defaultValue="Untitled"
@ -504,7 +498,9 @@
on:save={saveQuery}
/>
<div class="controls">
<ConnectedQueryScreens sourceId={query._id} />
{#if query._id}
<ConnectedQueryScreens sourceId={query._id} />
{/if}
<div class="access">
<Label>Access</Label>
<AccessLevelSelect {query} {saveId} />
@ -524,13 +520,13 @@
<div class="url">
<Input
on:blur={urlChanged}
bind:value={url}
bind:value={query.fields.path}
placeholder="http://www.api.com/endpoint"
/>
</div>
<Button primary disabled={!url} on:click={runQuery}>Send</Button>
<Button
disabled={!query.name}
disabled={!query.name || !isModified || saving}
cta
on:click={saveQuery}
tooltip={!hasSchema
@ -557,16 +553,13 @@
/>
</Tab>
<Tab title="Params">
{#key breakQs}
<KeyValueBuilder
on:change={paramsChanged}
object={breakQs}
name="param"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
{/key}
<KeyValueBuilder
bind:object={breakQs}
name="param"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Headers">
<KeyValueBuilder
@ -654,7 +647,7 @@
label="Auth"
labelPosition="left"
placeholder="None"
bind:value={authConfigId}
bind:value={query.fields.authConfigId}
options={authConfigs}
/>
</div>

View File

@ -1,34 +0,0 @@
<script>
import { ModalContent, Body, notifications } from "@budibase/bbui"
import PasswordRepeatInput from "@/components/common/users/PasswordRepeatInput.svelte"
import { auth } from "@/stores/portal"
let password
let error
const updatePassword = async () => {
try {
await auth.updateSelf({ password })
notifications.success("Password changed successfully")
} catch (error) {
notifications.error("Failed to update password")
}
}
const handleKeydown = evt => {
if (evt.key === "Enter" && !error && password) {
updatePassword()
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<ModalContent
title="Update password"
confirmText="Update password"
onConfirm={updatePassword}
disabled={error || !password}
>
<Body size="S">Enter your new password below.</Body>
<PasswordRepeatInput bind:password bind:error />
</ModalContent>

View File

@ -1,29 +0,0 @@
<script>
import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
import { writable } from "svelte/store"
import { auth } from "@/stores/portal"
const values = writable({
firstName: $auth.user.firstName,
lastName: $auth.user.lastName,
})
const updateInfo = async () => {
try {
await auth.updateSelf($values)
notifications.success("Information updated successfully")
} catch (error) {
console.error(error)
notifications.error("Failed to update information")
}
}
</script>
<ModalContent title="My profile" confirmText="Save" onConfirm={updateInfo}>
<Body size="S">
Personalise the platform by adding your first name and last name.
</Body>
<Input disabled bind:value={$auth.user.email} label="Email" />
<Input bind:value={$values.firstName} label="First name" />
<Input bind:value={$values.lastName} label="Last name" />
</ModalContent>

View File

@ -12,8 +12,8 @@
import { appsStore, admin, auth } from "@/stores/portal"
import { onMount } from "svelte"
import { goto } from "@roxi/routify"
import { createValidationStore } from "@/helpers/validation/yup"
import * as appValidation from "@/helpers/validation/yup/app"
import { createValidationStore } from "@budibase/frontend-core/src/utils/validation/yup"
import * as appValidation from "@budibase/frontend-core/src/utils/validation/yup/app"
import TemplateCard from "@/components/common/TemplateCard.svelte"
import { lowercase } from "@/helpers"
import { sdk } from "@budibase/shared-core"

View File

@ -6,9 +6,9 @@
Layout,
keepOpen,
} from "@budibase/bbui"
import { createValidationStore } from "@/helpers/validation/yup"
import { createValidationStore } from "@budibase/frontend-core/src/utils/validation/yup"
import { writable, get } from "svelte/store"
import * as appValidation from "@/helpers/validation/yup/app"
import * as appValidation from "@budibase/frontend-core/src/utils/validation/yup/app"
import { appsStore, auth } from "@/stores/portal"
import { onMount } from "svelte"
import { API } from "@/api"

View File

@ -9,7 +9,7 @@
notifications,
} from "@budibase/bbui"
import { downloadFile } from "@budibase/frontend-core"
import { createValidationStore } from "@/helpers/validation/yup"
import { createValidationStore } from "@budibase/frontend-core/src/utils/validation/yup"
export let app
export let published

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