Merge branch 'master' into ts/form
This commit is contained in:
commit
3e9357b2e8
|
@ -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 &
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"};`
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
export let heading
|
||||
<script lang="ts">
|
||||
export let heading: string
|
||||
</script>
|
||||
|
||||
<li role="presentation">
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
export let title
|
||||
export let icon = ""
|
||||
export let id
|
||||
export let id = undefined
|
||||
export let href = "#"
|
||||
export let link = false
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
|
@ -31,6 +31,10 @@ class AnalyticsHub {
|
|||
posthog.captureEvent(eventName, props)
|
||||
}
|
||||
|
||||
enableSessionRecording() {
|
||||
posthog.enableSessionRecording()
|
||||
}
|
||||
|
||||
async logout() {
|
||||
posthog.logout()
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
const validateDescription = description => {
|
||||
if (!description?.length) {
|
||||
return "Please enter a name"
|
||||
return "Please enter a description"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
/>
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
export let type = "label"
|
||||
export let size = "M"
|
||||
|
||||
export const disableEditingState = () => setEditing(false)
|
||||
|
||||
let editing = false
|
||||
|
||||
function setEditing(state) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue