Merge branch 'master' into BUDI-9077/type-fields
This commit is contained in:
commit
6092d57e83
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.2",
|
||||
"npmClient": "yarn",
|
||||
"concurrency": 20,
|
||||
"command": {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -1,32 +1,34 @@
|
|||
<script lang="ts">
|
||||
import "@spectrum-css/textfield/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import type { FocusEventHandler } from "svelte/elements"
|
||||
|
||||
export let value = ""
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let value: string | null = null
|
||||
export let placeholder: string | null = null
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let id: string | undefined = undefined
|
||||
export let height: string | number | undefined = undefined
|
||||
export let minHeight: string | number | undefined = undefined
|
||||
export const getCaretPosition = () => ({
|
||||
start: textarea.selectionStart,
|
||||
end: textarea.selectionEnd,
|
||||
})
|
||||
export let id: string | null = null
|
||||
export let height: number | null = null
|
||||
export let minHeight: number | null = null
|
||||
export let align = null
|
||||
|
||||
let focus = false
|
||||
let textarea: any
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = (event: any) => {
|
||||
dispatch("change", event.target.value)
|
||||
focus = false
|
||||
let isFocused = false
|
||||
let textarea: HTMLTextAreaElement
|
||||
const dispatch = createEventDispatcher<{ change: string }>()
|
||||
const onChange: FocusEventHandler<HTMLTextAreaElement> = event => {
|
||||
dispatch("change", event.currentTarget.value)
|
||||
isFocused = false
|
||||
}
|
||||
|
||||
const getStyleString = (
|
||||
attribute: string,
|
||||
value: string | number | undefined
|
||||
) => {
|
||||
export function focus() {
|
||||
textarea.focus()
|
||||
}
|
||||
|
||||
export function contents() {
|
||||
return textarea.value
|
||||
}
|
||||
|
||||
const getStyleString = (attribute: string, value: number | null) => {
|
||||
if (!attribute || value == null) {
|
||||
return ""
|
||||
}
|
||||
|
@ -44,7 +46,7 @@
|
|||
style={`${heightString}${minHeightString}`}
|
||||
class="spectrum-Textfield spectrum-Textfield--multiline"
|
||||
class:is-disabled={disabled}
|
||||
class:is-focused={focus}
|
||||
class:is-focused={isFocused}
|
||||
>
|
||||
<!-- prettier-ignore -->
|
||||
<textarea
|
||||
|
@ -55,8 +57,9 @@
|
|||
{disabled}
|
||||
{readonly}
|
||||
{id}
|
||||
on:focus={() => (focus = true)}
|
||||
on:focus={() => (isFocused = true)}
|
||||
on:blur={onChange}
|
||||
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>
|
|
@ -5,17 +5,25 @@
|
|||
|
||||
export let value: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let labelPosition: string = "above"
|
||||
export let labelPosition = "above"
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let disabled = false
|
||||
export let error: string | undefined = undefined
|
||||
export let getCaretPosition: any = undefined
|
||||
export let height: string | number | undefined = undefined
|
||||
export let minHeight: string | number | 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: any) => {
|
||||
const onChange = (e: CustomEvent<string>) => {
|
||||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
|
@ -23,12 +31,13 @@
|
|||
|
||||
<Field {helpText} {label} {labelPosition} {error}>
|
||||
<TextArea
|
||||
bind:getCaretPosition
|
||||
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,22 +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 link = ""
|
||||
export let linkText = ""
|
||||
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":
|
||||
|
|
|
@ -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 | null = null
|
||||
export let height: string | null = null
|
||||
|
||||
let mde
|
||||
let mde: any
|
||||
|
||||
// Keep the value up to date
|
||||
$: mde && mde.value(value || "")
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -56,7 +56,10 @@
|
|||
memo,
|
||||
fetchData,
|
||||
} from "@budibase/frontend-core"
|
||||
import { getSchemaForDatasourcePlus } from "@/dataBinding"
|
||||
import {
|
||||
getSchemaForDatasourcePlus,
|
||||
readableToRuntimeBinding,
|
||||
} from "@/dataBinding"
|
||||
import { TriggerStepID, ActionStepID } from "@/constants/backend/automations"
|
||||
import { onMount, createEventDispatcher } from "svelte"
|
||||
import { writable } from "svelte/store"
|
||||
|
@ -220,7 +223,18 @@
|
|||
const stepStore = writable({})
|
||||
$: stepState = $stepStore?.[block.id]
|
||||
|
||||
$: customStepLayouts($memoBlock, schemaProperties, stepState, fetchedRows)
|
||||
const updateSelectedRow = testData => {
|
||||
selectedRow = testData?.row
|
||||
}
|
||||
$: updateSelectedRow(testData)
|
||||
|
||||
$: customStepLayouts(
|
||||
$memoBlock,
|
||||
schemaProperties,
|
||||
stepState,
|
||||
fetchedRows,
|
||||
selectedRow
|
||||
)
|
||||
|
||||
const customStepLayouts = block => {
|
||||
if (
|
||||
|
@ -387,6 +401,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"],
|
||||
|
@ -413,49 +478,7 @@
|
|||
disabled: isTestModal,
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
...getTestDataSelector(),
|
||||
...getIdConfig(),
|
||||
...getRevConfig(),
|
||||
...getRowTypeConfig(),
|
||||
|
@ -1014,7 +1037,10 @@
|
|||
{bindings}
|
||||
{schema}
|
||||
panel={AutomationBindingPanel}
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
on:change={e =>
|
||||
onChange({
|
||||
[key]: readableToRuntimeBinding(bindings, e.detail),
|
||||
})}
|
||||
context={$memoContext}
|
||||
value={inputData[key]}
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,13 @@
|
|||
</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"
|
||||
|
||||
|
@ -53,11 +59,14 @@
|
|||
import { javascript } from "@codemirror/lang-javascript"
|
||||
import { EditorModes } from "./"
|
||||
import { themeStore } from "@/stores/portal"
|
||||
import type { EditorMode } from "@budibase/types"
|
||||
import { 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[] = []
|
||||
|
@ -86,6 +95,13 @@
|
|||
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"
|
||||
|
||||
$: {
|
||||
if (autofocus && isEditorInitialised) {
|
||||
editor.focus()
|
||||
|
@ -139,6 +155,57 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: 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 })
|
||||
code = resp.code
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notifications.error("Unable to generate code, please try again later.")
|
||||
code = previousContents
|
||||
popoverWidth = 300
|
||||
promptLoading = false
|
||||
popover.hide()
|
||||
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()
|
||||
}
|
||||
|
||||
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]
|
||||
|
@ -428,6 +495,50 @@
|
|||
<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}
|
||||
align="left-outside"
|
||||
>
|
||||
{#if promptLoading}
|
||||
<div class="prompt-spinner">
|
||||
<Spinner size="20" color="white" />
|
||||
</div>
|
||||
{:else if suggestedCode}
|
||||
<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 {
|
||||
|
@ -633,4 +744,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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -37,7 +37,7 @@ const { ContextScopes } = Constants
|
|||
|
||||
// Regex to match all instances of template strings
|
||||
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
||||
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
|
||||
const CAPTURE_VAR_INSIDE_JS = /\$\((["'`])([^"'`]+)\1\)/g
|
||||
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
|
||||
|
||||
const UpdateReferenceAction = {
|
||||
|
|
|
@ -49,7 +49,11 @@
|
|||
data.append("file", fileList[i])
|
||||
}
|
||||
try {
|
||||
return await API.uploadAttachment(formContext?.dataSource?.tableId, data)
|
||||
let sourceId = formContext?.dataSource?.tableId
|
||||
if (formContext?.dataSource?.type === "viewV2") {
|
||||
sourceId = formContext.dataSource.id
|
||||
}
|
||||
return await API.uploadAttachment(sourceId, data)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -31,10 +31,12 @@
|
|||
let attachRequest = new FormData()
|
||||
attachRequest.append("file", signatureFile)
|
||||
|
||||
const resp = await API.uploadAttachment(
|
||||
formContext?.dataSource?.tableId,
|
||||
attachRequest
|
||||
)
|
||||
let sourceId = formContext?.dataSource?.tableId
|
||||
if (formContext?.dataSource?.type === "viewV2") {
|
||||
sourceId = formContext.dataSource.id
|
||||
}
|
||||
|
||||
const resp = await API.uploadAttachment(sourceId, attachRequest)
|
||||
const [signatureAttachment] = resp
|
||||
updateValue = signatureAttachment
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { GenerateJsRequest, GenerateJsResponse } from "@budibase/types"
|
||||
import { BaseAPIClient } from "./types"
|
||||
|
||||
export interface AIEndpoints {
|
||||
generateCronExpression: (prompt: string) => Promise<{ message: string }>
|
||||
generateJs: (req: GenerateJsRequest) => Promise<GenerateJsResponse>
|
||||
}
|
||||
|
||||
export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
|
||||
|
@ -14,4 +16,11 @@ export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
|
|||
body: { prompt },
|
||||
})
|
||||
},
|
||||
|
||||
generateJs: async req => {
|
||||
return await API.post({
|
||||
url: "/api/ai/js",
|
||||
body: req,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit b28dbd549284cf450be7f25ad85aadf614d08f0b
|
||||
Subproject commit bb1ed6fa96ebed30e30659e47b0712567601f3c0
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
CreateOAuth2ConfigRequest,
|
||||
Ctx,
|
||||
FetchOAuth2ConfigsResponse,
|
||||
OAuth2Config,
|
||||
RequiredKeys,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../sdk"
|
||||
|
||||
export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
|
||||
const configs = await sdk.oauth2.fetch()
|
||||
|
||||
const response: FetchOAuth2ConfigsResponse = {
|
||||
configs: (configs || []).map(c => ({
|
||||
name: c.name,
|
||||
})),
|
||||
}
|
||||
ctx.body = response
|
||||
}
|
||||
|
||||
export async function create(ctx: Ctx<CreateOAuth2ConfigRequest, void>) {
|
||||
const newConfig: RequiredKeys<OAuth2Config> = {
|
||||
name: ctx.request.body.name,
|
||||
}
|
||||
|
||||
await sdk.oauth2.create(newConfig)
|
||||
ctx.status = 201
|
||||
}
|
|
@ -29,11 +29,12 @@ import debugRoutes from "./debug"
|
|||
import Router from "@koa/router"
|
||||
import { api as pro } from "@budibase/pro"
|
||||
import rowActionRoutes from "./rowAction"
|
||||
import oauth2Routes from "./oauth2"
|
||||
|
||||
export { default as staticRoutes } from "./static"
|
||||
export { default as publicRoutes } from "./public"
|
||||
|
||||
const aiRoutes = pro.ai
|
||||
const proAiRoutes = pro.ai
|
||||
const appBackupRoutes = pro.appBackups
|
||||
const environmentVariableRoutes = pro.environmentVariables
|
||||
|
||||
|
@ -68,7 +69,8 @@ export const mainRoutes: Router[] = [
|
|||
debugRoutes,
|
||||
environmentVariableRoutes,
|
||||
rowActionRoutes,
|
||||
aiRoutes,
|
||||
proAiRoutes,
|
||||
oauth2Routes,
|
||||
// these need to be handled last as they still use /api/:tableId
|
||||
// this could be breaking as koa may recognise other routes as this
|
||||
tableRoutes,
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import Router from "@koa/router"
|
||||
import { PermissionType } from "@budibase/types"
|
||||
import authorized from "../../middleware/authorized"
|
||||
|
||||
import * as controller from "../controllers/oauth2"
|
||||
|
||||
const router: Router = new Router()
|
||||
|
||||
router.get("/api/oauth2", authorized(PermissionType.BUILDER), controller.fetch)
|
||||
router.post(
|
||||
"/api/oauth2",
|
||||
authorized(PermissionType.BUILDER),
|
||||
controller.create
|
||||
)
|
||||
|
||||
export default router
|
|
@ -1,11 +1,13 @@
|
|||
const setup = require("./utilities")
|
||||
const tableUtils = require("../../controllers/table/utils")
|
||||
import { handleDataImport } from "../../controllers/table/utils"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import { AutoFieldSubType, FieldType, JsonFieldSubType } from "@budibase/types"
|
||||
|
||||
describe("run misc tests", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
|
@ -13,69 +15,67 @@ describe("run misc tests", () => {
|
|||
|
||||
describe("/bbtel", () => {
|
||||
it("check if analytics enabled", async () => {
|
||||
const res = await request
|
||||
.get(`/api/bbtel`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(typeof res.body.enabled).toEqual("boolean")
|
||||
const { enabled } = await config.api.misc.bbtel()
|
||||
expect(enabled).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("/health", () => {
|
||||
it("should confirm healthy", async () => {
|
||||
await request.get("/health").expect(200)
|
||||
await config.api.misc.health()
|
||||
})
|
||||
})
|
||||
|
||||
describe("/version", () => {
|
||||
it("should confirm version", async () => {
|
||||
const res = await request.get("/version").expect(200)
|
||||
const text = res.text
|
||||
if (text.includes("alpha")) {
|
||||
expect(text.split(".").length).toEqual(4)
|
||||
const version = await config.api.misc.version()
|
||||
if (version.includes("alpha")) {
|
||||
expect(version.split(".").length).toEqual(4)
|
||||
} else {
|
||||
expect(text.split(".").length).toEqual(3)
|
||||
expect(version.split(".").length).toEqual(3)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("test table utilities", () => {
|
||||
it("should be able to import data", async () => {
|
||||
return config.doInContext(null, async () => {
|
||||
return config.doInContext("", async () => {
|
||||
const table = await config.createTable({
|
||||
name: "table",
|
||||
type: "table",
|
||||
key: "name",
|
||||
schema: {
|
||||
a: {
|
||||
type: "string",
|
||||
type: FieldType.STRING,
|
||||
name: "a",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "string",
|
||||
name: "b",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
c: {
|
||||
type: "string",
|
||||
name: "c",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
d: {
|
||||
type: "string",
|
||||
name: "d",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
e: {
|
||||
name: "Auto ID",
|
||||
type: "number",
|
||||
subtype: "autoID",
|
||||
type: FieldType.NUMBER,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
|
@ -88,9 +88,9 @@ describe("run misc tests", () => {
|
|||
},
|
||||
},
|
||||
f: {
|
||||
type: "array",
|
||||
type: FieldType.ARRAY,
|
||||
constraints: {
|
||||
type: "array",
|
||||
type: JsonFieldSubType.ARRAY,
|
||||
presence: {
|
||||
allowEmpty: true,
|
||||
},
|
||||
|
@ -100,7 +100,7 @@ describe("run misc tests", () => {
|
|||
sortable: false,
|
||||
},
|
||||
g: {
|
||||
type: "options",
|
||||
type: FieldType.OPTIONS,
|
||||
constraints: {
|
||||
type: "string",
|
||||
presence: false,
|
||||
|
@ -118,16 +118,18 @@ describe("run misc tests", () => {
|
|||
{ a: "13", b: "14", c: "15", d: "16", g: "Omega" },
|
||||
]
|
||||
// Shift specific row tests to the row spec
|
||||
await tableUtils.handleDataImport(table, {
|
||||
importRows,
|
||||
user: { userId: "test" },
|
||||
})
|
||||
await handleDataImport(table, { importRows, userId: "test" })
|
||||
|
||||
// 4 rows imported, the auto ID starts at 1
|
||||
// We expect the handleDataImport function to update the lastID
|
||||
|
||||
// @ts-expect-error - fields have type FieldSchema, not specific
|
||||
// subtypes.
|
||||
expect(table.schema.e.lastID).toEqual(4)
|
||||
|
||||
// Array/Multi - should have added a new value to the inclusion.
|
||||
// @ts-expect-error - fields have type FieldSchema, not specific
|
||||
// subtypes.
|
||||
expect(table.schema.f.constraints.inclusion).toEqual([
|
||||
"Four",
|
||||
"One",
|
||||
|
@ -136,6 +138,8 @@ describe("run misc tests", () => {
|
|||
])
|
||||
|
||||
// Options - should have a new value in the inclusion
|
||||
// @ts-expect-error - fields have type FieldSchema, not specific
|
||||
// subtypes.
|
||||
expect(table.schema.g.constraints.inclusion).toEqual([
|
||||
"Alpha",
|
||||
"Beta",
|
||||
|
@ -143,25 +147,25 @@ describe("run misc tests", () => {
|
|||
"Omega",
|
||||
])
|
||||
|
||||
const rows = await config.getRows()
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(4)
|
||||
|
||||
const rowOne = rows.find(row => row.e === 1)
|
||||
const rowOne = rows.find(row => row.e === 1)!
|
||||
expect(rowOne.a).toEqual("1")
|
||||
expect(rowOne.f).toEqual(["One"])
|
||||
expect(rowOne.g).toEqual("Alpha")
|
||||
|
||||
const rowTwo = rows.find(row => row.e === 2)
|
||||
const rowTwo = rows.find(row => row.e === 2)!
|
||||
expect(rowTwo.a).toEqual("5")
|
||||
expect(rowTwo.f).toEqual([])
|
||||
expect(rowTwo.g).toEqual(undefined)
|
||||
|
||||
const rowThree = rows.find(row => row.e === 3)
|
||||
const rowThree = rows.find(row => row.e === 3)!
|
||||
expect(rowThree.a).toEqual("9")
|
||||
expect(rowThree.f).toEqual(["Two", "Four"])
|
||||
expect(rowThree.g).toEqual(undefined)
|
||||
|
||||
const rowFour = rows.find(row => row.e === 4)
|
||||
const rowFour = rows.find(row => row.e === 4)!
|
||||
expect(rowFour.a).toEqual("13")
|
||||
expect(rowFour.f).toEqual(undefined)
|
||||
expect(rowFour.g).toEqual("Omega")
|
|
@ -0,0 +1,79 @@
|
|||
import { CreateOAuth2ConfigRequest } from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
||||
describe("/oauth2", () => {
|
||||
let config = setup.getConfig()
|
||||
|
||||
function makeOAuth2Config(): CreateOAuth2ConfigRequest {
|
||||
return {
|
||||
name: generator.guid(),
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => await config.init())
|
||||
|
||||
beforeEach(async () => await config.newTenant())
|
||||
|
||||
describe("fetch", () => {
|
||||
it("returns empty when no oauth are created", async () => {
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response).toEqual({
|
||||
configs: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("can create a new configuration", async () => {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
await config.api.oauth2.create(oauth2Config, { status: 201 })
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response).toEqual({
|
||||
configs: [
|
||||
{
|
||||
name: oauth2Config.name,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("can create multiple configurations", async () => {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
const oauth2Config2 = makeOAuth2Config()
|
||||
await config.api.oauth2.create(oauth2Config, { status: 201 })
|
||||
await config.api.oauth2.create(oauth2Config2, { status: 201 })
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toEqual([
|
||||
{
|
||||
name: oauth2Config.name,
|
||||
},
|
||||
{
|
||||
name: oauth2Config2.name,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("cannot create configurations with already existing names", async () => {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
const oauth2Config2 = { ...makeOAuth2Config(), name: oauth2Config.name }
|
||||
await config.api.oauth2.create(oauth2Config, { status: 201 })
|
||||
await config.api.oauth2.create(oauth2Config2, {
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Name already used",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toEqual([
|
||||
{
|
||||
name: oauth2Config.name,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -34,7 +34,7 @@ const checkAuthorized = async (
|
|||
const isCreatorApi = permType === PermissionType.CREATOR
|
||||
const isBuilderApi = permType === PermissionType.BUILDER
|
||||
const isGlobalBuilder = users.isGlobalBuilder(ctx.user)
|
||||
const isCreator = await users.isCreator(ctx.user)
|
||||
const isCreator = await users.isCreatorAsync(ctx.user)
|
||||
const isBuilder = appId
|
||||
? users.isBuilder(ctx.user, appId)
|
||||
: users.hasBuilderPermissions(ctx.user)
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import env from "../environment"
|
||||
import { Ctx } from "@budibase/types"
|
||||
|
||||
// if added as a middleware will stop requests unless builder is in self host mode
|
||||
// or cloud is in self host
|
||||
export default async (ctx: Ctx, next: any) => {
|
||||
if (env.SELF_HOSTED) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
ctx.throw(400, "Endpoint unavailable in cloud hosting.")
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
import ensureTenantAppOwnership from "../ensureTenantAppOwnership"
|
||||
import { tenancy, utils } from "@budibase/backend-core"
|
||||
|
||||
jest.mock("@budibase/backend-core", () => ({
|
||||
...jest.requireActual("@budibase/backend-core"),
|
||||
tenancy: {
|
||||
getTenantId: jest.fn(),
|
||||
},
|
||||
utils: {
|
||||
getAppIdFromCtx: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
class TestConfiguration {
|
||||
constructor(appId = "tenant_1") {
|
||||
this.next = jest.fn()
|
||||
this.throw = jest.fn()
|
||||
this.middleware = ensureTenantAppOwnership
|
||||
|
||||
this.ctx = {
|
||||
next: this.next,
|
||||
throw: this.throw,
|
||||
}
|
||||
|
||||
utils.getAppIdFromCtx.mockResolvedValue(appId)
|
||||
}
|
||||
|
||||
async executeMiddleware() {
|
||||
return this.middleware(this.ctx, this.next)
|
||||
}
|
||||
|
||||
afterEach() {
|
||||
jest.clearAllMocks()
|
||||
}
|
||||
}
|
||||
|
||||
describe("Ensure Tenant Ownership Middleware", () => {
|
||||
let config
|
||||
|
||||
beforeEach(() => {
|
||||
config = new TestConfiguration()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
config.afterEach()
|
||||
})
|
||||
|
||||
it("calls next() when appId matches tenant ID", async () => {
|
||||
tenancy.getTenantId.mockReturnValue("tenant_1")
|
||||
|
||||
await config.executeMiddleware()
|
||||
|
||||
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
|
||||
expect(config.next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("throws when tenant appId does not match tenant ID", async () => {
|
||||
const appId = "app_dev_tenant3_fce449c4d75b4e4a9c7a6980d82a3e22"
|
||||
utils.getAppIdFromCtx.mockResolvedValue(appId)
|
||||
tenancy.getTenantId.mockReturnValue("tenant_2")
|
||||
|
||||
await config.executeMiddleware()
|
||||
|
||||
expect(utils.getAppIdFromCtx).toHaveBeenCalledWith(config.ctx)
|
||||
expect(config.throw).toHaveBeenCalledWith(403, "Unauthorized")
|
||||
})
|
||||
|
||||
it("throws 400 when appId is missing", async () => {
|
||||
utils.getAppIdFromCtx.mockResolvedValue(null)
|
||||
|
||||
await config.executeMiddleware()
|
||||
|
||||
expect(config.throw).toHaveBeenCalledWith(400, "appId must be provided")
|
||||
})
|
||||
})
|
|
@ -0,0 +1,52 @@
|
|||
import { UserCtx } from "@budibase/types"
|
||||
import ensureTenantAppOwnership from "../ensureTenantAppOwnership"
|
||||
import { context, Header, HTTPError } from "@budibase/backend-core"
|
||||
|
||||
function ctx(opts?: { appId: string }) {
|
||||
const ctx = {
|
||||
throw: (status: number, message: string) => {
|
||||
throw new HTTPError(message, status)
|
||||
},
|
||||
path: "",
|
||||
request: {
|
||||
headers: {},
|
||||
},
|
||||
} as unknown as UserCtx
|
||||
if (opts?.appId) {
|
||||
ctx.request.headers[Header.APP_ID] = opts.appId
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe("Ensure Tenant Ownership Middleware", () => {
|
||||
const tenantId = "tenant1"
|
||||
const appId = `app_dev_${tenantId}_fce449c4d75b4e4a9c7a6980d82a3e22`
|
||||
|
||||
it("calls next() when appId matches tenant ID", async () => {
|
||||
await context.doInTenant(tenantId, async () => {
|
||||
let called = false
|
||||
await ensureTenantAppOwnership(ctx({ appId }), () => {
|
||||
called = true
|
||||
})
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("throws when tenant appId does not match tenant ID", async () => {
|
||||
let called = false
|
||||
await expect(async () => {
|
||||
await context.doInTenant("tenant_2", async () => {
|
||||
await ensureTenantAppOwnership(ctx({ appId }), () => {
|
||||
called = true
|
||||
})
|
||||
})
|
||||
}).rejects.toThrow("Unauthorized")
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it("throws 400 when appId is missing", async () => {
|
||||
await expect(ensureTenantAppOwnership(ctx(), () => {})).rejects.toThrow(
|
||||
"appId must be provided"
|
||||
)
|
||||
})
|
||||
})
|
|
@ -1,105 +0,0 @@
|
|||
const {
|
||||
paramResource,
|
||||
paramSubResource,
|
||||
bodyResource,
|
||||
bodySubResource,
|
||||
ResourceIdGetter,
|
||||
} = require("../resourceId")
|
||||
|
||||
class TestConfiguration {
|
||||
constructor(middleware) {
|
||||
this.middleware = middleware
|
||||
this.ctx = {
|
||||
request: {},
|
||||
}
|
||||
this.next = jest.fn()
|
||||
}
|
||||
|
||||
setParams(params) {
|
||||
this.ctx.params = params
|
||||
}
|
||||
|
||||
setBody(body) {
|
||||
this.ctx.body = body
|
||||
}
|
||||
|
||||
executeMiddleware() {
|
||||
return this.middleware(this.ctx, this.next)
|
||||
}
|
||||
}
|
||||
|
||||
describe("resourceId middleware", () => {
|
||||
it("calls next() when there is no request object to parse", () => {
|
||||
const config = new TestConfiguration(paramResource("main"))
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.next).toHaveBeenCalled()
|
||||
expect(config.ctx.resourceId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context query parameters", () => {
|
||||
const config = new TestConfiguration(paramResource("main"))
|
||||
config.setParams({
|
||||
main: "test",
|
||||
})
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.ctx.resourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context query sub parameters", () => {
|
||||
const config = new TestConfiguration(paramSubResource("main", "sub"))
|
||||
config.setParams({
|
||||
main: "main",
|
||||
sub: "test",
|
||||
})
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.ctx.resourceId).toEqual("main")
|
||||
expect(config.ctx.subResourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context request body", () => {
|
||||
const config = new TestConfiguration(bodyResource("main"))
|
||||
config.setBody({
|
||||
main: "test",
|
||||
})
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.ctx.resourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context request body sub fields", () => {
|
||||
const config = new TestConfiguration(bodySubResource("main", "sub"))
|
||||
config.setBody({
|
||||
main: "main",
|
||||
sub: "test",
|
||||
})
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.ctx.resourceId).toEqual("main")
|
||||
expect(config.ctx.subResourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("parses resourceIds correctly for custom middlewares", () => {
|
||||
const middleware = new ResourceIdGetter("body")
|
||||
.mainResource("custom")
|
||||
.subResource("customSub")
|
||||
.build()
|
||||
let config = new TestConfiguration(middleware)
|
||||
config.setBody({
|
||||
custom: "test",
|
||||
customSub: "subtest",
|
||||
})
|
||||
|
||||
config.executeMiddleware()
|
||||
|
||||
expect(config.ctx.resourceId).toEqual("test")
|
||||
expect(config.ctx.subResourceId).toEqual("subtest")
|
||||
})
|
||||
})
|
|
@ -0,0 +1,93 @@
|
|||
import { Ctx } from "@budibase/types"
|
||||
import {
|
||||
paramResource,
|
||||
paramSubResource,
|
||||
bodyResource,
|
||||
bodySubResource,
|
||||
ResourceIdGetter,
|
||||
} from "../resourceId"
|
||||
|
||||
describe("resourceId middleware", () => {
|
||||
it("calls next() when there is no request object to parse", () => {
|
||||
const ctx = { request: {} } as Ctx
|
||||
let called = false
|
||||
paramResource("main")(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context query parameters", () => {
|
||||
const ctx = { request: {}, params: { main: "test" } } as unknown as Ctx
|
||||
let called = false
|
||||
paramResource("main")(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context query sub parameters", () => {
|
||||
const ctx = {
|
||||
request: {},
|
||||
params: { main: "main", sub: "test" },
|
||||
} as unknown as Ctx
|
||||
let called = false
|
||||
paramSubResource("main", "sub")(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toEqual("main")
|
||||
expect(ctx.subResourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context request body", () => {
|
||||
const ctx = { request: {}, body: { main: "main" } } as unknown as Ctx
|
||||
let called = false
|
||||
bodyResource("main")(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toEqual("main")
|
||||
})
|
||||
|
||||
it("generates a resourceId middleware for context request body sub fields", () => {
|
||||
const ctx = {
|
||||
request: {},
|
||||
body: { main: "main", sub: "test" },
|
||||
} as unknown as Ctx
|
||||
let called = false
|
||||
bodySubResource("main", "sub")(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toEqual("main")
|
||||
expect(ctx.subResourceId).toEqual("test")
|
||||
})
|
||||
|
||||
it("parses resourceIds correctly for custom middlewares", () => {
|
||||
const middleware = new ResourceIdGetter("body")
|
||||
.mainResource("custom")
|
||||
.subResource("customSub")
|
||||
.build()
|
||||
|
||||
const ctx = {
|
||||
request: {},
|
||||
body: { custom: "test", customSub: "subTest" },
|
||||
} as unknown as Ctx
|
||||
let called = false
|
||||
middleware(ctx, () => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(ctx.resourceId).toEqual("test")
|
||||
expect(ctx.subResourceId).toEqual("subTest")
|
||||
})
|
||||
})
|
|
@ -1,43 +0,0 @@
|
|||
const selfHostMiddleware = require("../selfhost").default
|
||||
const env = require("../../environment")
|
||||
jest.mock("../../environment")
|
||||
|
||||
class TestConfiguration {
|
||||
constructor() {
|
||||
this.next = jest.fn()
|
||||
this.throw = jest.fn()
|
||||
this.middleware = selfHostMiddleware
|
||||
|
||||
this.ctx = {
|
||||
next: this.next,
|
||||
throw: this.throw,
|
||||
}
|
||||
}
|
||||
|
||||
executeMiddleware() {
|
||||
return this.middleware(this.ctx, this.next)
|
||||
}
|
||||
|
||||
afterEach() {
|
||||
jest.clearAllMocks()
|
||||
}
|
||||
}
|
||||
|
||||
describe("Self host middleware", () => {
|
||||
let config
|
||||
|
||||
beforeEach(() => {
|
||||
config = new TestConfiguration()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
config.afterEach()
|
||||
})
|
||||
|
||||
it("calls next() when SELF_HOSTED env var is set", async () => {
|
||||
env.SELF_HOSTED = 1
|
||||
|
||||
await config.executeMiddleware()
|
||||
expect(config.next).toHaveBeenCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,28 @@
|
|||
import { context, HTTPError } from "@budibase/backend-core"
|
||||
import { DocumentType, OAuth2Config, OAuth2Configs } from "@budibase/types"
|
||||
|
||||
export async function fetch(): Promise<OAuth2Config[]> {
|
||||
const db = context.getAppDB()
|
||||
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
return Object.values(result.configs)
|
||||
}
|
||||
|
||||
export async function create(config: OAuth2Config) {
|
||||
const db = context.getAppDB()
|
||||
const doc: OAuth2Configs = (await db.tryGet<OAuth2Configs>(
|
||||
DocumentType.OAUTH2_CONFIG
|
||||
)) ?? {
|
||||
_id: DocumentType.OAUTH2_CONFIG,
|
||||
configs: {},
|
||||
}
|
||||
|
||||
if (doc.configs[config.name]) {
|
||||
throw new HTTPError("Name already used", 400)
|
||||
}
|
||||
|
||||
doc.configs[config.name] = config
|
||||
await db.put(doc)
|
||||
}
|
|
@ -13,6 +13,7 @@ import * as permissions from "./app/permissions"
|
|||
import * as rowActions from "./app/rowActions"
|
||||
import * as screens from "./app/screens"
|
||||
import * as common from "./app/common"
|
||||
import * as oauth2 from "./app/oauth2"
|
||||
|
||||
const sdk = {
|
||||
backups,
|
||||
|
@ -30,6 +31,7 @@ const sdk = {
|
|||
links,
|
||||
rowActions,
|
||||
common,
|
||||
oauth2,
|
||||
}
|
||||
|
||||
// default export for TS
|
||||
|
|
|
@ -19,6 +19,8 @@ import { PluginAPI } from "./plugin"
|
|||
import { WebhookAPI } from "./webhook"
|
||||
import { EnvironmentAPI } from "./environment"
|
||||
import { UserPublicAPI } from "./public/user"
|
||||
import { MiscAPI } from "./misc"
|
||||
import { OAuth2API } from "./oauth2"
|
||||
|
||||
export default class API {
|
||||
application: ApplicationAPI
|
||||
|
@ -28,6 +30,8 @@ export default class API {
|
|||
datasource: DatasourceAPI
|
||||
environment: EnvironmentAPI
|
||||
legacyView: LegacyViewAPI
|
||||
misc: MiscAPI
|
||||
oauth2: OAuth2API
|
||||
permission: PermissionAPI
|
||||
plugin: PluginAPI
|
||||
query: QueryAPI
|
||||
|
@ -53,6 +57,8 @@ export default class API {
|
|||
this.datasource = new DatasourceAPI(config)
|
||||
this.environment = new EnvironmentAPI(config)
|
||||
this.legacyView = new LegacyViewAPI(config)
|
||||
this.misc = new MiscAPI(config)
|
||||
this.oauth2 = new OAuth2API(config)
|
||||
this.permission = new PermissionAPI(config)
|
||||
this.plugin = new PluginAPI(config)
|
||||
this.query = new QueryAPI(config)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { AnalyticsEnabledResponse } from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
|
||||
export class MiscAPI extends TestAPI {
|
||||
health = async (expectations?: Expectations) => {
|
||||
return await this._get<void>("/health", { expectations })
|
||||
}
|
||||
|
||||
version = async (expectations?: Expectations) => {
|
||||
return (await this._requestRaw("get", "/version", { expectations })).text
|
||||
}
|
||||
|
||||
bbtel = async (expectations?: Expectations) => {
|
||||
return await this._get<AnalyticsEnabledResponse>("/api/bbtel", {
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import {
|
||||
CreateOAuth2ConfigRequest,
|
||||
FetchOAuth2ConfigsResponse,
|
||||
} from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
|
||||
export class OAuth2API extends TestAPI {
|
||||
fetch = async (expectations?: Expectations) => {
|
||||
return await this._get<FetchOAuth2ConfigsResponse>("/api/oauth2", {
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
|
||||
create = async (
|
||||
body: CreateOAuth2ConfigRequest,
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
return await this._post<CreateOAuth2ConfigRequest>("/api/oauth2", {
|
||||
body,
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface GenerateJsRequest {
|
||||
prompt: string
|
||||
}
|
||||
export interface GenerateJsResponse {
|
||||
code: string
|
||||
}
|
|
@ -1,21 +1,22 @@
|
|||
export * from "./backup"
|
||||
export * from "./datasource"
|
||||
export * from "./view"
|
||||
export * from "./rows"
|
||||
export * from "./table"
|
||||
export * from "./permission"
|
||||
export * from "./attachment"
|
||||
export * from "./user"
|
||||
export * from "./rowAction"
|
||||
export * from "./automation"
|
||||
export * from "./component"
|
||||
export * from "./integration"
|
||||
export * from "./metadata"
|
||||
export * from "./query"
|
||||
export * from "./screen"
|
||||
export * from "./application"
|
||||
export * from "./layout"
|
||||
export * from "./attachment"
|
||||
export * from "./automation"
|
||||
export * from "./backup"
|
||||
export * from "./component"
|
||||
export * from "./datasource"
|
||||
export * from "./deployment"
|
||||
export * from "./integration"
|
||||
export * from "./layout"
|
||||
export * from "./metadata"
|
||||
export * from "./oauth2"
|
||||
export * from "./permission"
|
||||
export * from "./query"
|
||||
export * from "./role"
|
||||
export * from "./webhook"
|
||||
export * from "./rowAction"
|
||||
export * from "./rows"
|
||||
export * from "./screen"
|
||||
export * from "./static"
|
||||
export * from "./table"
|
||||
export * from "./user"
|
||||
export * from "./view"
|
||||
export * from "./webhook"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
interface OAuth2Config {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FetchOAuth2ConfigsResponse {
|
||||
configs: OAuth2Config[]
|
||||
}
|
||||
|
||||
export interface CreateOAuth2ConfigRequest extends OAuth2Config {}
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./ai"
|
||||
export * from "./analytics"
|
||||
export * from "./auth"
|
||||
export * from "./user"
|
||||
|
|
|
@ -1,22 +1,23 @@
|
|||
export * from "../document"
|
||||
export * from "./app"
|
||||
export * from "./automation"
|
||||
export * from "./backup"
|
||||
export * from "./component"
|
||||
export * from "./datasource"
|
||||
export * from "./deployment"
|
||||
export * from "./layout"
|
||||
export * from "./links"
|
||||
export * from "./metadata"
|
||||
export * from "./oauth2"
|
||||
export * from "./query"
|
||||
export * from "./role"
|
||||
export * from "./table"
|
||||
export * from "./screen"
|
||||
export * from "./view"
|
||||
export * from "../document"
|
||||
export * from "./row"
|
||||
export * from "./user"
|
||||
export * from "./backup"
|
||||
export * from "./webhook"
|
||||
export * from "./links"
|
||||
export * from "./component"
|
||||
export * from "./sqlite"
|
||||
export * from "./snippet"
|
||||
export * from "./rowAction"
|
||||
export * from "./screen"
|
||||
export * from "./snippet"
|
||||
export * from "./sqlite"
|
||||
export * from "./table"
|
||||
export * from "./theme"
|
||||
export * from "./deployment"
|
||||
export * from "./metadata"
|
||||
export * from "./user"
|
||||
export * from "./view"
|
||||
export * from "./webhook"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { Document } from "../document"
|
||||
|
||||
export interface OAuth2Config {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface OAuth2Configs extends Document {
|
||||
configs: Record<string, OAuth2Config>
|
||||
}
|
|
@ -40,6 +40,7 @@ export enum DocumentType {
|
|||
APP_MIGRATION_METADATA = "_design/migrations",
|
||||
SCIM_LOG = "scimlog",
|
||||
ROW_ACTIONS = "ra",
|
||||
OAUTH2_CONFIG = "oauth2",
|
||||
}
|
||||
|
||||
// Because DocumentTypes can overlap, we need to make sure that we search
|
||||
|
|
|
@ -136,7 +136,7 @@ export interface Database {
|
|||
get<T extends Document>(id?: string): Promise<T>
|
||||
tryGet<T extends Document>(id?: string): Promise<T | undefined>
|
||||
getMultiple<T extends Document>(
|
||||
ids: string[],
|
||||
ids?: string[],
|
||||
opts?: { allowMissing?: boolean; excludeDocs?: boolean }
|
||||
): Promise<T[]>
|
||||
remove(idOrDoc: Document): Promise<Nano.DocumentDestroyResponse>
|
||||
|
|
|
@ -3,10 +3,13 @@ export enum FeatureFlag {
|
|||
|
||||
// Account-portal
|
||||
DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL",
|
||||
|
||||
AI_JS_GENERATION = "AI_JS_GENERATION",
|
||||
}
|
||||
|
||||
export const FeatureFlagDefaults = {
|
||||
export const FeatureFlagDefaults: Record<FeatureFlag, boolean> = {
|
||||
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
|
||||
[FeatureFlag.AI_JS_GENERATION]: false,
|
||||
|
||||
// Account-portal
|
||||
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,
|
||||
|
|
Loading…
Reference in New Issue