Merge branch 'master' into screen-store-ts-conversion

This commit is contained in:
deanhannigan 2025-01-20 09:09:27 +00:00 committed by GitHub
commit 9d49d685a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
218 changed files with 5709 additions and 2746 deletions

View File

@ -8,41 +8,15 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
with:
days-before-stale: 330
operations-per-run: 1
# stale rules for PRs
days-before-pr-stale: 7
stale-issue-label: stale
exempt-pr-labels: pinned,security,roadmap
days-before-pr-close: 7
days-before-issue-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for high priority bugs
days-before-stale: 30
only-issue-labels: bug,High priority
stale-issue-label: warn
days-before-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for medium priority bugs
days-before-stale: 90
only-issue-labels: bug,Medium priority
stale-issue-label: warn
days-before-close: 30
- uses: actions/stale@v8
with:
operations-per-run: 3
# stale rules for all bugs
days-before-stale: 180
stale-issue-label: stale
only-issue-labels: bug
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for six months."
days-before-close: 30
- uses: actions/stale@v8
with:
# Issues
days-before-stale: 180
stale-issue-label: stale
days-before-close: 30
stale-issue-message: "This issue has been automatically marked as stale as there has been no activity for 6 months."
# Pull requests
days-before-pr-stale: 7
days-before-pr-close: 14
exempt-pr-labels: pinned,security,roadmap
operations-per-run: 100

View File

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

View File

@ -21,7 +21,7 @@
"scripts": {
"prebuild": "rimraf dist/",
"prepack": "cp package.json dist",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null && tsc -p tsconfig.test.json --paths null",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"build:oss": "node ./scripts/build.js",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",

View File

@ -1,6 +1,6 @@
import env from "../../environment"
export const getCouchInfo = (connection?: string) => {
export const getCouchInfo = (connection?: string | null) => {
// clean out any auth credentials
const urlInfo = getUrlInfo(connection)
let username
@ -45,7 +45,7 @@ export const getCouchInfo = (connection?: string) => {
}
}
export const getUrlInfo = (url = env.COUCH_DB_URL) => {
export const getUrlInfo = (url: string | null = env.COUCH_DB_URL) => {
let cleanUrl, username, password, host
if (url) {
// Ensure the URL starts with a protocol

View File

@ -1,5 +1,6 @@
require("../../../tests")
const getUrlInfo = require("../couch").getUrlInfo
import { getUrlInfo } from "../couch"
describe("pouch", () => {
describe("Couch DB URL parsing", () => {

View File

@ -32,8 +32,12 @@ export async function errorHandling(ctx: any, next: any) {
}
if (environment.isTest() && ctx.headers["x-budibase-include-stacktrace"]) {
let rootErr = err
while (rootErr.cause) {
rootErr = rootErr.cause
}
// @ts-ignore
error.stack = err.stack
error.stack = rootErr.stack
}
ctx.body = error

View File

@ -272,17 +272,6 @@ class InternalBuilder {
return parts.join(".")
}
private isFullSelectStatementRequired(): boolean {
for (let column of Object.values(this.table.schema)) {
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(column)) {
return true
} else if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(column)) {
return true
}
}
return false
}
private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
const { table, resource } = this.query
@ -292,11 +281,9 @@ class InternalBuilder {
const alias = this.getTableName(table)
const schema = this.table.schema
if (!this.isFullSelectStatementRequired()) {
return [this.knex.raw("??", [`${alias}.*`])]
}
// get just the fields for this table
return resource.fields
const tableFields = resource.fields
.map(field => {
const parts = field.split(/\./g)
let table: string | undefined = undefined
@ -311,34 +298,33 @@ class InternalBuilder {
return { table, column, field }
})
.filter(({ table }) => !table || table === alias)
.map(({ table, column, field }) => {
const columnSchema = schema[column]
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
return this.knex.raw(`??::money::numeric as ??`, [
this.rawQuotedIdentifier([table, column].join(".")),
this.knex.raw(this.quote(field)),
])
}
return tableFields.map(({ table, column, field }) => {
const columnSchema = schema[column]
if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
return this.knex.raw(`??::money::numeric as ??`, [
this.rawQuotedIdentifier([table, column].join(".")),
this.knex.raw(this.quote(field)),
])
}
// TODO: figure out how to express this safely without string
// interpolation.
return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [
this.rawQuotedIdentifier(field),
this.knex.raw(this.quote(field)),
])
}
if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
if (table) {
return this.rawQuotedIdentifier(`${table}.${column}`)
} else {
return this.rawQuotedIdentifier(field)
}
})
return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [
this.rawQuotedIdentifier(field),
this.knex.raw(this.quote(field)),
])
}
if (table) {
return this.rawQuotedIdentifier(`${table}.${column}`)
} else {
return this.rawQuotedIdentifier(field)
}
})
}
// OracleDB can't use character-large-objects (CLOBs) in WHERE clauses,
@ -816,14 +802,29 @@ class InternalBuilder {
filters.oneOf,
ArrayOperator.ONE_OF,
(q, key: string, array) => {
const schema = this.getFieldSchema(key)
const values = Array.isArray(array) ? array : [array]
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.convertClobs(key)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
for (const value of values) {
if (value != null) {
q = q.or.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
} else {
q = q.or.whereNull(key)
}
}
return q
}
return q.whereIn(key, Array.isArray(array) ? array : [array])
return q.whereIn(key, values)
},
(q, key: string[], array) => {
if (shouldOr) {
@ -882,6 +883,19 @@ class InternalBuilder {
let high = value.high
let low = value.low
if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (high != null) {
high = `${high.toISOString().slice(0, 10)}T23:59:59.999Z`
}
if (low != null) {
low = low.toISOString().slice(0, 10)
}
}
if (this.client === SqlClient.ORACLE) {
rawKey = this.convertClobs(key)
} else if (
@ -914,6 +928,7 @@ class InternalBuilder {
}
if (filters.equal) {
iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => {
const schema = this.getFieldSchema(key)
if (shouldOr) {
q = q.or
}
@ -928,6 +943,16 @@ class InternalBuilder {
// @ts-expect-error knex types are wrong, raw is fine here
subq.whereNotNull(identifier).andWhere(identifier, value)
)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (value != null) {
return q.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
} else {
return q.whereNull(key)
}
} else {
return q.whereRaw(`COALESCE(?? = ?, FALSE)`, [
this.rawQuotedIdentifier(key),
@ -938,6 +963,7 @@ class InternalBuilder {
}
if (filters.notEqual) {
iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => {
const schema = this.getFieldSchema(key)
if (shouldOr) {
q = q.or
}
@ -959,6 +985,18 @@ class InternalBuilder {
// @ts-expect-error knex types are wrong, raw is fine here
.or.whereNull(identifier)
)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (value != null) {
return q.not
.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
.or.whereNull(key)
} else {
return q.not.whereNull(key)
}
} else {
return q.whereRaw(`COALESCE(?? != ?, TRUE)`, [
this.rawQuotedIdentifier(key),
@ -1134,20 +1172,22 @@ class InternalBuilder {
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
const composite = `${aliased}.${key}`
let identifier
if (this.isAggregateField(key)) {
query = query.orderBy(key, direction, nulls)
identifier = this.rawQuotedIdentifier(key)
} else if (this.client === SqlClient.ORACLE) {
identifier = this.convertClobs(composite)
} else {
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderByRaw(`?? ?? nulls ??`, [
this.convertClobs(composite),
this.knex.raw(direction),
this.knex.raw(nulls as string),
])
} else {
query = query.orderBy(composite, direction, nulls)
}
identifier = this.rawQuotedIdentifier(composite)
}
query = query.orderByRaw(`?? ?? ${nulls ? "nulls ??" : ""}`, [
identifier,
this.knex.raw(direction),
...(nulls ? [this.knex.raw(nulls as string)] : []),
])
}
}
@ -1239,6 +1279,7 @@ class InternalBuilder {
if (!toTable || !fromTable) {
continue
}
const relatedTable = tables[toTable]
if (!relatedTable) {
throw new Error(`related table "${toTable}" not found in datasource`)
@ -1267,6 +1308,10 @@ class InternalBuilder {
const fieldList = relationshipFields.map(field =>
this.buildJsonField(relatedTable, field)
)
if (!fieldList.length) {
continue
}
const fieldListFormatted = fieldList
.map(f => {
const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
@ -1301,13 +1346,17 @@ class InternalBuilder {
// add the correlation to the overall query
subQuery = subQuery.where(
correlatedTo,
this.rawQuotedIdentifier(correlatedTo),
"=",
this.rawQuotedIdentifier(correlatedFrom)
)
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
subQuery = subQuery.select(`${toAlias}.*`).limit(getRelationshipLimit())
subQuery = subQuery
.select(
relationshipFields.map(field => this.rawQuotedIdentifier(field))
)
.limit(getRelationshipLimit())
// @ts-ignore - the from alias syntax isn't in Knex typing
return knex.select(select).from({
[toAlias]: subQuery,
@ -1537,11 +1586,12 @@ class InternalBuilder {
limits?: { base: number; query: number }
} = {}
): Knex.QueryBuilder {
let { operation, filters, paginate, relationships, table } = this.query
const { operation, filters, paginate, relationships, table } = this.query
const { limits } = opts
// start building the query
let query = this.qualifiedKnex()
// handle pagination
let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base
@ -1590,7 +1640,7 @@ class InternalBuilder {
const mainTable = this.query.tableAliases?.[table.name] || table.name
const cte = this.addSorting(
this.knex
.with("paginated", query)
.with("paginated", query.clone().clearSelect().select("*"))
.select(this.generateSelectStatement())
.from({
[mainTable]: "paginated",

View File

@ -14,7 +14,7 @@ import environment from "../environment"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) {
@ -149,15 +149,7 @@ export function isInvalidISODateString(str: string) {
}
export function isValidISODateString(str: string) {
const trimmedValue = str.trim()
if (!ISO_DATE_REGEX.test(trimmedValue)) {
return false
}
let d = new Date(trimmedValue)
if (isNaN(d.getTime())) {
return false
}
return d.toISOString() === trimmedValue
return ISO_DATE_REGEX.test(str.trim())
}
export function isValidFilter(value: any) {

View File

@ -1,17 +1,17 @@
const _ = require("lodash/fp")
const { structures } = require("../../../tests")
import { range } from "lodash/fp"
import { structures } from "../.."
jest.mock("../../../src/context")
jest.mock("../../../src/db")
const context = require("../../../src/context")
const db = require("../../../src/db")
import * as context from "../../../src/context"
import * as db from "../../../src/db"
const { getCreatorCount } = require("../../../src/users/users")
import { getCreatorCount } from "../../../src/users/users"
describe("Users", () => {
let getGlobalDBMock
let paginationMock
let getGlobalDBMock: jest.SpyInstance
let paginationMock: jest.SpyInstance
beforeEach(() => {
jest.resetAllMocks()
@ -22,11 +22,10 @@ describe("Users", () => {
jest.spyOn(db, "getGlobalUserParams")
})
it("Retrieves the number of creators", async () => {
const getUsers = (offset, limit, creators = false) => {
const range = _.range(offset, limit)
it("retrieves the number of creators", async () => {
const getUsers = (offset: number, limit: number, creators = false) => {
const opts = creators ? { builder: { global: true } } : undefined
return range.map(() => structures.users.user(opts))
return range(offset, limit).map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "dist",
"sourceMap": true
},
"include": ["tests/**/*.js", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -3,7 +3,7 @@
"description": "A UI solution used in the different Budibase projects.",
"version": "0.0.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"svelte": "src/index.ts",
"module": "dist/bbui.mjs",
"exports": {
".": {
@ -14,7 +14,8 @@
"./spectrum-icons-vite.js": "./src/spectrum-icons-vite.js"
},
"scripts": {
"build": "vite build"
"build": "vite build",
"dev": "vite build --watch --mode=dev"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "1.4.0",

View File

@ -10,12 +10,12 @@
export let size = "M"
export let hoverable = false
export let disabled = false
export let color
export let hoverColor
export let tooltip
export let color = undefined
export let hoverColor = undefined
export let tooltip = undefined
export let tooltipPosition = TooltipPosition.Bottom
export let tooltipType = TooltipType.Default
export let tooltipColor
export let tooltipColor = undefined
export let tooltipWrap = true
export let newStyles = false
</script>

View File

@ -4,7 +4,7 @@
export let size = "M"
export let tooltip = ""
export let muted
export let muted = undefined
</script>
<TooltipWrapper {tooltip} {size}>

View File

@ -2,10 +2,10 @@
import "@spectrum-css/typography/dist/index-vars.css"
// Sizes
export let size = "M"
export let textAlign = undefined
export let noPadding = false
export let weight = "default" // light, heavy, default
export let size: "XS" | "S" | "M" | "L" = "M"
export let textAlign: string | undefined = undefined
export let noPadding: boolean = false
export let weight: "light" | "heavy" | "default" = "default"
</script>
<h1

View File

@ -6,9 +6,8 @@ export const deepGet = helpers.deepGet
/**
* Generates a DOM safe UUID.
* Starting with a letter is important to make it DOM safe.
* @return {string} a random DOM safe UUID
*/
export function uuid() {
export function uuid(): string {
return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
@ -18,22 +17,18 @@ export function uuid() {
/**
* Capitalises a string
* @param string the string to capitalise
* @return {string} the capitalised string
*/
export const capitalise = string => {
export const capitalise = (string?: string | null): string => {
if (!string) {
return string
return ""
}
return string.substring(0, 1).toUpperCase() + string.substring(1)
}
/**
* Computes a short hash of a string
* @param string the string to compute a hash of
* @return {string} the hash string
*/
export const hashString = string => {
export const hashString = (string?: string | null): string => {
if (!string) {
return "0"
}
@ -54,11 +49,12 @@ export const hashString = string => {
* will override the value "foo" rather than "bar".
* If a deep path is specified and the parent keys don't exist then these will
* be created.
* @param obj the object
* @param key the key
* @param value the value
*/
export const deepSet = (obj, key, value) => {
export const deepSet = (
obj: Record<string, any> | null,
key: string | null,
value: any
): void => {
if (!obj || !key) {
return
}
@ -82,9 +78,8 @@ export const deepSet = (obj, key, value) => {
/**
* Deeply clones an object. Functions are not supported.
* @param obj the object to clone
*/
export const cloneDeep = obj => {
export const cloneDeep = <T>(obj: T): T => {
if (!obj) {
return obj
}
@ -93,9 +88,8 @@ export const cloneDeep = obj => {
/**
* Copies a value to the clipboard
* @param value the value to copy
*/
export const copyToClipboard = value => {
export const copyToClipboard = (value: any): Promise<void> => {
return new Promise(res => {
if (navigator.clipboard && window.isSecureContext) {
// Try using the clipboard API first
@ -117,9 +111,12 @@ export const copyToClipboard = value => {
})
}
// Parsed a date value. This is usually an ISO string, but can be a
// Parse a date value. This is usually an ISO string, but can be a
// bunch of different formats and shapes depending on schema flags.
export const parseDate = (value, { enableTime = true }) => {
export const parseDate = (
value: string | dayjs.Dayjs | null,
{ enableTime = true }
): dayjs.Dayjs | null => {
// If empty then invalid
if (!value) {
return null
@ -128,7 +125,7 @@ export const parseDate = (value, { enableTime = true }) => {
// Certain string values need transformed
if (typeof value === "string") {
// Check for time only values
if (!isNaN(new Date(`0-${value}`))) {
if (!isNaN(new Date(`0-${value}`).valueOf())) {
value = `0-${value}`
}
@ -153,9 +150,9 @@ export const parseDate = (value, { enableTime = true }) => {
// Stringifies a dayjs object to create an ISO string that respects the various
// schema flags
export const stringifyDate = (
value,
value: null | dayjs.Dayjs,
{ enableTime = true, timeOnly = false, ignoreTimezones = false } = {}
) => {
): string | null => {
if (!value) {
return null
}
@ -192,7 +189,7 @@ export const stringifyDate = (
}
// Determine the dayjs-compatible format of the browser's default locale
const getPatternForPart = part => {
const getPatternForPart = (part: Intl.DateTimeFormatPart): string => {
switch (part.type) {
case "day":
return "D".repeat(part.value.length)
@ -214,9 +211,9 @@ const localeDateFormat = new Intl.DateTimeFormat()
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value,
value: dayjs.Dayjs | null,
{ enableTime = true, timeOnly = false } = {}
) => {
): string => {
if (!value?.isValid()) {
return ""
}
@ -229,7 +226,7 @@ export const getDateDisplayValue = (
}
}
export const hexToRGBA = (color, opacity) => {
export const hexToRGBA = (color: string, opacity: number): string => {
if (color.includes("#")) {
color = color.replace("#", "")
}

View File

@ -0,0 +1,7 @@
const { vitePreprocess } = require("@sveltejs/vite-plugin-svelte")
const config = {
preprocess: vitePreprocess(),
}
module.exports = config

View File

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"allowJs": true,
"outDir": "./dist",
"lib": ["ESNext"],
"baseUrl": ".",
"paths": {
"@budibase/*": [
"../*/src/index.ts",
"../*/src/index.js",
"../*",
"../../node_modules/@budibase/*"
]
}
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
}

View File

@ -9,7 +9,7 @@ export default defineConfig(({ mode }) => {
build: {
sourcemap: !isProduction,
lib: {
entry: "src/index.js",
entry: "src/index.ts",
formats: ["es"],
},
},

View File

@ -74,7 +74,6 @@
"dayjs": "^1.10.8",
"downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1",
"json-format-highlight": "^1.0.4",
"lodash": "4.17.21",
"posthog-js": "^1.118.0",
"remixicon": "2.5.0",
@ -94,6 +93,7 @@
"@sveltejs/vite-plugin-svelte": "1.4.0",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/svelte": "^4.1.0",
"@types/sanitize-html": "^2.13.0",
"@types/shortid": "^2.2.0",
"babel-jest": "^29.6.2",
"identity-obj-proxy": "^3.0.0",

View File

@ -9,7 +9,7 @@
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { flags } from "@/stores/builder"
import { featureFlags, licensing } from "@/stores/portal"
import { licensing } from "@/stores/portal"
import { API } from "@/api"
import MagicWand from "../../../../assets/MagicWand.svelte"
@ -27,8 +27,7 @@
let loadingAICronExpression = false
$: aiEnabled =
($featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled) ||
($featureFlags.BUDIBASE_AI && $licensing.budibaseAIEnabled)
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: {
if (cronExpression) {
try {

View File

@ -26,7 +26,7 @@
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "@/stores/builder"
import { featureFlags } from "@/stores/portal"
import { licensing } from "@/stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
import {
FIELDS,
@ -49,7 +49,6 @@
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled } from "@/helpers/featureFlags"
import { getUserBindings } from "@/dataBinding"
export let field
@ -101,7 +100,8 @@
let optionsValid = true
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled = $featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
@ -168,7 +168,6 @@
// used to select what different options can be displayed for column type
$: canBeDisplay =
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
$: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired =
editableColumn?.type !== FieldType.LINK &&
@ -300,7 +299,7 @@
}
// Ensure we don't have a default value if we can't have one
if (!canHaveDefault || !defaultValuesEnabled) {
if (!canHaveDefault) {
delete saveColumn.default
}
@ -848,51 +847,49 @@
</div>
{/if}
{#if defaultValuesEnabled}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
</Layout>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
@ -12,7 +12,6 @@
completionStatus,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
@ -25,6 +24,7 @@
MatchDecorator,
ViewPlugin,
Decoration,
EditorView,
} from "@codemirror/view"
import {
bracketMatching,
@ -44,12 +44,14 @@
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types"
export let label
export let completions = []
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
export let label: string | undefined = undefined
// TODO: work out what best type fits this
export let completions: any[] = []
export let mode: EditorMode = EditorModes.Handlebars
export let value: string | null = ""
export let placeholder: string | null = null
export let autocompleteEnabled = true
export let autofocus = false
export let jsBindingWrapping = true
@ -58,8 +60,8 @@
const dispatch = createEventDispatcher()
let textarea
let editor
let textarea: HTMLDivElement
let editor: EditorView
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
@ -100,15 +102,22 @@
/**
* Will refresh the editor contents only after
* it has been fully initialised
* @param value {string} the editor value
*/
const refresh = (value, initialised, mounted) => {
const refresh = (
value: string | null,
initialised?: boolean,
mounted?: boolean
) => {
if (!initialised || !mounted) {
queuedRefresh = true
return
}
if (editor.state.doc.toString() !== value || queuedRefresh) {
if (
editor &&
value &&
(editor.state.doc.toString() !== value || queuedRefresh)
) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
})
@ -120,12 +129,17 @@
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
start: selection_range?.from,
end: selection_range?.to,
}
}
export const insertAtPos = opts => {
export const insertAtPos = (opts: {
start: number
end?: number
value: string
cursor: { anchor: number }
}) => {
// Updating the value inside.
// Retain focus
editor.dispatch({
@ -192,7 +206,7 @@
const indentWithTabCustom = {
key: "Tab",
run: view => {
run: (view: EditorView) => {
if (completionStatus(view.state) === "active") {
acceptCompletion(view)
return true
@ -200,7 +214,7 @@
indentMore(view)
return true
},
shift: view => {
shift: (view: EditorView) => {
indentLess(view)
return true
},
@ -232,7 +246,8 @@
// None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime
const buildExtensions = base => {
// TODO: work out type for base
const buildExtensions = (base: any[]) => {
let complete = [...base]
if (autocompleteEnabled) {
@ -242,7 +257,7 @@
closeOnBlur: true,
icons: false,
optionClass: completion =>
completion.simple
"simple" in completion && completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
})
@ -347,7 +362,7 @@
{#if label}
<div>
<Label small>{label}</Label>
<Label size="S">{label}</Label>
</div>
{/if}

View File

@ -1,8 +1,15 @@
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash"
import {
BindingCompletion,
EditorModesMap,
Helper,
Snippet,
} from "@budibase/types"
import { CompletionContext } from "@codemirror/autocomplete"
export const EditorModes = {
export const EditorModes: EditorModesMap = {
JS: {
name: "javascript",
json: false,
@ -26,7 +33,7 @@ export const SECTIONS = {
},
}
export const buildHelperInfoNode = (completion, helper) => {
export const buildHelperInfoNode = (completion: any, helper: Helper) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
@ -46,7 +53,7 @@ export const buildHelperInfoNode = (completion, helper) => {
return ele
}
const toSpectrumIcon = name => {
const toSpectrumIcon = (name: string) => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
@ -58,7 +65,12 @@ const toSpectrumIcon = name => {
</svg>`
}
export const buildSectionHeader = (type, sectionName, icon, rank) => {
export const buildSectionHeader = (
type: string,
sectionName: string,
icon: string,
rank: number
) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
if (type) {
@ -72,43 +84,52 @@ export const buildSectionHeader = (type, sectionName, icon, rank) => {
}
}
export const helpersToCompletion = (helpers, mode) => {
export const helpersToCompletion = (
helpers: Record<string, Helper>,
mode: { name: "javascript" | "handlebars" }
) => {
const { type, name: sectionName, icon } = SECTIONS.HB_HELPER
const helperSection = buildSectionHeader(type, sectionName, icon, 99)
return Object.keys(helpers).reduce((acc, key) => {
let helper = helpers[key]
acc.push({
label: key,
info: completion => {
return Object.keys(helpers).flatMap(helperName => {
let helper = helpers[helperName]
return {
label: helperName,
info: (completion: BindingCompletion) => {
return buildHelperInfoNode(completion, helper)
},
type: "helper",
section: helperSection,
detail: "Function",
apply: (view, completion, from, to) => {
insertBinding(view, from, to, key, mode)
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertBinding(view, from, to, helperName, mode)
},
})
return acc
}, [])
}
})
}
export const getHelperCompletions = mode => {
const manifest = getManifest()
return Object.keys(manifest).reduce((acc, key) => {
acc = acc || []
return [...acc, ...helpersToCompletion(manifest[key], mode)]
}, [])
export const getHelperCompletions = (mode: {
name: "javascript" | "handlebars"
}) => {
// TODO: manifest needs to be properly typed
const manifest: any = getManifest()
return Object.keys(manifest).flatMap(key => {
return helpersToCompletion(manifest[key], mode)
})
}
export const snippetAutoComplete = snippets => {
return function myCompletions(context) {
export const snippetAutoComplete = (snippets: Snippet[]) => {
return function myCompletions(context: CompletionContext) {
if (!snippets?.length) {
return null
}
const word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit) {
if (!word || (word.from == word.to && !context.explicit)) {
return null
}
return {
@ -117,7 +138,12 @@ export const snippetAutoComplete = snippets => {
label: `snippets.${snippet.name}`,
type: "text",
simple: true,
apply: (view, completion, from, to) => {
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertSnippet(view, from, to, completion.label)
},
})),
@ -125,7 +151,7 @@ export const snippetAutoComplete = snippets => {
}
}
const bindingFilter = (options, query) => {
const bindingFilter = (options: BindingCompletion[], query: string) => {
return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase()
const label_parsed = completion.label.toLowerCase()
@ -138,8 +164,8 @@ const bindingFilter = (options, query) => {
})
}
export const hbAutocomplete = baseCompletions => {
async function coreCompletion(context) {
export const hbAutocomplete = (baseCompletions: BindingCompletion[]) => {
async function coreCompletion(context: CompletionContext) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match)
let options = baseCompletions || []
@ -149,6 +175,9 @@ export const hbAutocomplete = baseCompletions => {
}
// Accommodate spaces
const match = bindingStart.text.match(/{{[\s]*/)
if (!match) {
return null
}
const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
@ -162,14 +191,17 @@ export const hbAutocomplete = baseCompletions => {
return coreCompletion
}
export const jsAutocomplete = baseCompletions => {
async function coreCompletion(context) {
export const jsAutocomplete = (baseCompletions: BindingCompletion[]) => {
async function coreCompletion(context: CompletionContext) {
let jsBinding = context.matchBefore(/\$\("[\s\w]*/)
let options = baseCompletions || []
if (jsBinding) {
// Accommodate spaces
const match = jsBinding.text.match(/\$\("[\s]*/)
if (!match) {
return null
}
const query = jsBinding.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
@ -185,7 +217,10 @@ export const jsAutocomplete = baseCompletions => {
return coreCompletion
}
export const buildBindingInfoNode = (completion, binding) => {
export const buildBindingInfoNode = (
completion: BindingCompletion,
binding: any
) => {
if (!binding.valueHTML || binding.value == null) {
return null
}
@ -196,7 +231,12 @@ export const buildBindingInfoNode = (completion, binding) => {
}
// Readdress these methods. They shouldn't be used
export const hbInsert = (value, from, to, text) => {
export const hbInsert = (
value: string,
from: number,
to: number,
text: string
) => {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
@ -212,11 +252,14 @@ export const hbInsert = (value, from, to, text) => {
}
export function jsInsert(
value,
from,
to,
text,
{ helper, disableWrapping } = {}
value: string,
from: number,
to: number,
text: string,
{
helper,
disableWrapping,
}: { helper?: boolean; disableWrapping?: boolean } = {}
) {
let parsedInsert = ""
@ -236,7 +279,13 @@ export function jsInsert(
}
// Autocomplete apply behaviour
export const insertBinding = (view, from, to, text, mode) => {
export const insertBinding = (
view: any,
from: number,
to: number,
text: string,
mode: { name: "javascript" | "handlebars" }
) => {
let parsedInsert
if (mode.name == "javascript") {
@ -270,7 +319,12 @@ export const insertBinding = (view, from, to, text, mode) => {
})
}
export const insertSnippet = (view, from, to, text) => {
export const insertSnippet = (
view: any,
from: number,
to: number,
text: string
) => {
let cursorPos = from + text.length
view.dispatch({
changes: {
@ -284,9 +338,13 @@ export const insertSnippet = (view, from, to, text) => {
})
}
export const bindingsToCompletions = (bindings, mode) => {
// TODO: typing in this function isn't great
export const bindingsToCompletions = (
bindings: any,
mode: { name: "javascript" | "handlebars" }
) => {
const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => {
const categoryMeta = bindings?.reduce((acc: any, ele: any) => {
acc[ele.category] = acc[ele.category] || {}
if (ele.icon) {
@ -298,36 +356,46 @@ export const bindingsToCompletions = (bindings, mode) => {
return acc
}, {})
const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => {
const { icon, rank } = categoryMeta[catKey] || {}
const completions = Object.keys(bindingByCategory).reduce(
(comps: any, catKey: string) => {
const { icon, rank } = categoryMeta[catKey] || {}
const bindindSectionHeader = buildSectionHeader(
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
const bindingSectionHeader = buildSectionHeader(
// @ts-ignore something wrong with this - logically this should be dictionary
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label: binding.display?.name || binding.readableBinding || "NO NAME",
info: completion => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindindSectionHeader,
apply: (view, completion, from, to) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
}, [])
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label:
binding.display?.name || binding.readableBinding || "NO NAME",
info: (completion: BindingCompletion) => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindingSectionHeader,
apply: (
view: any,
completion: BindingCompletion,
from: number,
to: number
) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
},
[]
)
return completions
}

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import {
DrawerContent,
ActionButton,
@ -28,45 +28,45 @@
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils"
import formatHighlight from "json-format-highlight"
import { capitalise } from "@/helpers"
import { Utils } from "@budibase/frontend-core"
import { Utils, JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import { BindingMode, SidePanel } from "@budibase/types"
import type {
EnrichedBinding,
BindingCompletion,
Snippet,
Helper,
CaretPositionFn,
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { CompletionContext } from "@codemirror/autocomplete"
const dispatch = createEventDispatcher()
export let bindings = []
export let value = ""
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHBS = true
export let allowJS = false
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let snippets = null
export let snippets: Snippet[] | null = null
export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const Modes = {
Text: "Text",
JavaScript: "JavaScript",
}
const SidePanels = {
Bindings: "FlashOn",
Evaluation: "Play",
Snippets: "Code",
}
let mode
let sidePanel
let mode: BindingMode | null
let sidePanel: SidePanel | null
let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let getCaretPosition
let insertAtPos
let targetMode = null
let expressionResult
let expressionError
let jsValue: string | null = initialValueJS ? value : null
let hbsValue: string | null = initialValueJS ? null : value
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
let targetMode: BindingMode | null = null
let expressionResult: string | undefined
let expressionError: string | undefined
let evaluating = false
$: useSnippets = allowSnippets && !$licensing.isFreePlan
@ -78,10 +78,12 @@
mode
)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === Modes.JavaScript
$: usingJS = mode === BindingMode.JavaScript
$: editorMode =
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
mode === BindingMode.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = (editorMode === EditorModes.JS ? jsValue : hbsValue) as
| string
| null
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
@ -95,7 +97,7 @@
}
}
const getHBSCompletions = bindingCompletions => {
const getHBSCompletions = (bindingCompletions: BindingCompletion[]) => {
return [
hbAutocomplete([
...bindingCompletions,
@ -104,71 +106,87 @@
]
}
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
const completions = [
const getJSCompletions = (
bindingCompletions: BindingCompletion[],
snippets: Snippet[] | null,
useSnippets?: boolean
) => {
const completions: ((_: CompletionContext) => any)[] = [
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
if (useSnippets) {
if (useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS, allowJS) => {
const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
let options = []
if (allowHBS) {
options.push(Modes.Text)
options.push(BindingMode.Text)
}
if (allowJS) {
options.push(Modes.JavaScript)
options.push(BindingMode.JavaScript)
}
return options
}
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
const getSidePanelOptions = (
bindings: EnrichedBinding[],
context: any,
useSnippets: boolean,
mode: BindingMode | null
) => {
let options = []
if (bindings?.length) {
options.push(SidePanels.Bindings)
options.push(SidePanel.Bindings)
}
if (context && Object.keys(context).length > 0) {
options.push(SidePanels.Evaluation)
options.push(SidePanel.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {
options.push(SidePanels.Snippets)
if (useSnippets && mode === BindingMode.JavaScript) {
options.push(SidePanel.Snippets)
}
return options
}
const debouncedEval = Utils.debounce((expression, context, snippets) => {
try {
expressionError = null
expressionResult = processStringSync(
expression || "",
{
...context,
snippets,
},
{
noThrow: false,
}
)
} catch (err) {
expressionResult = null
expressionError = err
}
evaluating = false
}, 260)
const debouncedEval = Utils.debounce(
(expression: string | null, context: any, snippets: Snippet[]) => {
try {
expressionError = undefined
expressionResult = processStringSync(
expression || "",
{
...context,
snippets,
},
{
noThrow: false,
}
)
} catch (err: any) {
expressionResult = undefined
expressionError = err
}
evaluating = false
},
260
)
const requestEval = (expression, context, snippets) => {
const requestEval = (
expression: string | null,
context: any,
snippets: Snippet[] | null
) => {
evaluating = true
debouncedEval(expression, context, snippets)
}
const highlightJSON = json => {
return formatHighlight(json, {
const highlightJSON = (json: JSONValue) => {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
@ -178,7 +196,11 @@
})
}
const enrichBindings = (bindings, context, snippets) => {
const enrichBindings = (
bindings: EnrichedBinding[],
context: any,
snippets: Snippet[] | null
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
@ -189,17 +211,18 @@
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvauations = processObjectSync(bindingStrings, {
const bindingEvaluations = processObjectSync(bindingStrings, {
...context,
snippets,
})
// Enrich bindings with evaluations and highlighted HTML
return bindings.map((binding, idx) => {
if (!context) {
if (!context || typeof bindingEvaluations !== "object") {
return binding
}
const value = JSON.stringify(bindingEvauations[idx], null, 2)
const evalObj: Record<any, any> = bindingEvaluations
const value = JSON.stringify(evalObj[idx], null, 2)
return {
...binding,
value,
@ -208,29 +231,38 @@
})
}
const updateValue = val => {
const updateValue = (val: any) => {
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val)
requestEval(runtimeExpression, context, snippets)
}
const onSelectHelper = (helper, js) => {
bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, { js })
const onSelectHelper = (helper: Helper, js?: boolean) => {
bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, {
js,
dontDecode: undefined,
})
}
const onSelectBinding = (binding, { forceJS } = {}) => {
const onSelectBinding = (
binding: EnrichedBinding,
{ forceJS }: { forceJS?: boolean } = {}
) => {
const js = usingJS || forceJS
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, {
js,
dontDecode: undefined,
})
}
const changeMode = newMode => {
const changeMode = (newMode: BindingMode) => {
if (targetMode || newMode === mode) {
return
}
// Get the raw editor value to see if we are abandoning changes
let rawValue = editorValue
if (mode === Modes.JavaScript) {
if (mode === BindingMode.JavaScript && rawValue) {
rawValue = decodeJSBinding(rawValue)
}
@ -249,16 +281,16 @@
targetMode = null
}
const changeSidePanel = newSidePanel => {
const changeSidePanel = (newSidePanel: SidePanel) => {
sidePanel = newSidePanel === sidePanel ? null : newSidePanel
}
const onChangeHBSValue = e => {
const onChangeHBSValue = (e: { detail: string }) => {
hbsValue = e.detail
updateValue(hbsValue)
}
const onChangeJSValue = e => {
const onChangeJSValue = (e: { detail: string }) => {
jsValue = encodeJSBinding(e.detail)
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
@ -268,9 +300,14 @@
}
}
const addSnippet = (snippet: Snippet) =>
bindingHelpers.onSelectSnippet(snippet)
onMount(() => {
// Set the initial mode appropriately
const initialValueMode = initialValueJS ? Modes.JavaScript : Modes.Text
const initialValueMode = initialValueJS
? BindingMode.JavaScript
: BindingMode.Text
if (editorModeOptions.includes(initialValueMode)) {
mode = initialValueMode
} else {
@ -314,7 +351,7 @@
</div>
{/if}
<div class="editor">
{#if mode === Modes.Text}
{#if mode === BindingMode.Text}
{#key hbsCompletions}
<CodeEditor
value={hbsValue}
@ -328,10 +365,10 @@
jsBindingWrapping={false}
/>
{/key}
{:else if mode === Modes.JavaScript}
{:else if mode === BindingMode.JavaScript}
{#key jsCompletions}
<CodeEditor
value={decodeJSBinding(jsValue)}
value={jsValue ? decodeJSBinding(jsValue) : jsValue}
on:change={onChangeJSValue}
completions={jsCompletions}
mode={EditorModes.JS}
@ -371,7 +408,7 @@
</div>
</div>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanels.Bindings}
{#if sidePanel === SidePanel.Bindings}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
@ -380,18 +417,15 @@
addBinding={onSelectBinding}
mode={editorMode}
/>
{:else if sidePanel === SidePanels.Evaluation}
{:else if sidePanel === SidePanel.Evaluation}
<EvaluationSidePanel
{expressionResult}
{expressionError}
{evaluating}
expression={editorValue}
/>
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
expression={editorValue ? editorValue : ""}
/>
{:else if sidePanel === SidePanel.Snippets}
<SnippetSidePanel {addSnippet} {snippets} />
{/if}
</div>
</div>

View File

@ -1,28 +1,31 @@
<script>
import formatHighlight from "json-format-highlight"
<script lang="ts">
import { JsonFormatter } from "@budibase/frontend-core"
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { Helpers } from "@budibase/bbui"
import { fade } from "svelte/transition"
import { UserScriptError } from "@budibase/string-templates"
import type { JSONValue } from "@budibase/types"
export let expressionResult
export let expressionError
// this can be essentially any primitive response from the JS function
export let expressionResult: JSONValue | undefined = undefined
export let expressionError: string | undefined = undefined
export let evaluating = false
export let expression = null
export let expression: string | null = null
$: error = expressionError != null
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
const formatError = err => {
const formatError = (err: any) => {
if (err.code === UserScriptError.code) {
return err.userScriptError.toString()
}
return err.toString()
}
const highlight = json => {
// json can be any primitive type
const highlight = (json?: any | null) => {
if (json == null) {
return ""
}
@ -31,10 +34,10 @@
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
// couldn't parse/stringify, just treat it as the raw input
}
return formatHighlight(json, {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
@ -45,11 +48,11 @@
}
const copy = () => {
let clipboardVal = expressionResult.result
let clipboardVal = expressionResult
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
copyToClipboard(clipboardVal)
Helpers.copyToClipboard(clipboardVal)
notifications.success("Value copied to clipboard")
}
</script>

View File

@ -28,7 +28,9 @@
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: defaultName = getSequentialName($snippets, "MySnippet", {
getName: x => x.name,
})
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""

View File

@ -43,7 +43,6 @@
export let showDataProviders = true
const dispatch = createEventDispatcher()
const arrayTypes = ["attachment", "array"]
let anchorRight, dropdownRight
let drawer
@ -116,8 +115,11 @@
}
})
$: fields = bindings
.filter(x => arrayTypes.includes(x.fieldSchema?.type))
.filter(x => x.fieldSchema?.tableId != null)
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema

View File

@ -16,7 +16,10 @@ export {
export const AUTO_COLUMN_SUB_TYPES = AutoFieldSubType
export const AUTO_COLUMN_DISPLAY_NAMES = {
export const AUTO_COLUMN_DISPLAY_NAMES: Record<
keyof typeof AUTO_COLUMN_SUB_TYPES,
string
> = {
AUTO_ID: "Auto ID",
CREATED_BY: "Created By",
CREATED_AT: "Created At",
@ -209,13 +212,6 @@ export const Roles = {
BUILDER: "BUILDER",
}
export function isAutoColumnUserRelationship(subtype) {
return (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY ||
subtype === AUTO_COLUMN_SUB_TYPES.UPDATED_BY
)
}
export const PrettyRelationshipDefinitions = {
MANY: "Many rows",
ONE: "One row",

View File

@ -10,13 +10,13 @@
*
* Repl
*/
export const duplicateName = (name, allNames) => {
export const duplicateName = (name: string, allNames: string[]) => {
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
const baseName = name.split(duplicatePattern)[0]
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
// get the sequence from matched names
const sequence = []
const sequence: number[] = []
allNames.filter(n => {
if (n === baseName) {
return true
@ -70,12 +70,18 @@ export const duplicateName = (name, allNames) => {
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (
items,
prefix,
{ getName = x => x, numberFirstItem = false } = {}
export const getSequentialName = <T extends any>(
items: T[] | null,
prefix: string | null,
{
getName,
numberFirstItem,
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
} = {}
) => {
if (!prefix?.length || !getName) {
if (!prefix?.length) {
return null
}
const trimmedPrefix = prefix.trim()
@ -85,7 +91,7 @@ export const getSequentialName = (
}
let max = 0
items.forEach(item => {
const name = getName(item)
const name = getName?.(item) ?? item
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}

View File

@ -1,7 +1,8 @@
import { FeatureFlag } from "@budibase/types"
import { auth } from "../stores/portal"
import { get } from "svelte/store"
export const isEnabled = featureFlag => {
export const isEnabled = (featureFlag: FeatureFlag | `${FeatureFlag}`) => {
const user = get(auth).user
return !!user?.flags?.[featureFlag]
}

View File

@ -1,13 +1,21 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export default function (url) {
const store = writable({ status: "LOADING", data: {}, error: {} })
export default function (url: string) {
const store = writable<{
status: "LOADING" | "SUCCESS" | "ERROR"
data: object
error?: unknown
}>({
status: "LOADING",
data: {},
error: {},
})
async function get() {
store.update(u => ({ ...u, status: "LOADING" }))
try {
const data = await API.get({ url })
const data = await API.get<object>({ url })
store.set({ data, status: "SUCCESS" })
} catch (e) {
store.set({ data: {}, error: e, status: "ERROR" })

View File

@ -1,46 +0,0 @@
import { last, flow } from "lodash/fp"
export const buildStyle = styles => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = str => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg, funcs) => flow(funcs)(arg)
export const capitalise = s => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = s =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = e => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -0,0 +1,50 @@
import type { Many } from "lodash"
import { last, flow } from "lodash/fp"
export const buildStyle = (styles: Record<string, any>) => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = (str: string) => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg: string, funcs: Many<(...args: any[]) => any>) =>
flow(funcs)(arg)
export const capitalise = (s: string) => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = (s: string) =>
s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = (s: string) =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = (s: string) => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = (name: string) =>
pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag!) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -1,7 +0,0 @@
function handleEnter(fnc) {
return e => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -0,0 +1,7 @@
function handleEnter(fnc: () => void) {
return (e: KeyboardEvent) => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -1,6 +1,16 @@
import { writable } from "svelte/store"
function defaultValue() {
interface PaginationStore {
nextPage: string | null | undefined
page: string | null | undefined
hasPrevPage: boolean
hasNextPage: boolean
loading: boolean
pageNumber: number
pages: string[]
}
function defaultValue(): PaginationStore {
return {
nextPage: null,
page: undefined,
@ -29,13 +39,13 @@ export function createPaginationStore() {
update(state => {
state.pageNumber++
state.page = state.nextPage
state.pages.push(state.page)
state.pages.push(state.page!)
state.hasPrevPage = state.pageNumber > 1
return state
})
}
function fetched(hasNextPage, nextPage) {
function fetched(hasNextPage: boolean, nextPage: string) {
update(state => {
state.hasNextPage = hasNextPage
state.nextPage = nextPage

View File

@ -1,6 +1,6 @@
import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) {
export function getFormattedPlanName(userPlanType: PlanType) {
let planName
switch (userPlanType) {
case PlanType.PRO:
@ -29,6 +29,6 @@ export function getFormattedPlanName(userPlanType) {
return `${planName} Plan`
}
export function isPremiumOrAbove(userPlanType) {
export function isPremiumOrAbove(userPlanType: PlanType) {
return ![PlanType.PRO, PlanType.TEAM, PlanType.FREE].includes(userPlanType)
}

View File

@ -1,4 +1,4 @@
export default function (url) {
export default function (url: string) {
return url
.split("/")
.map(part => {

View File

@ -1,75 +0,0 @@
import { FieldType } from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
isAutoColumnUserRelationship,
} from "@/constants/backend"
import { isEnabled } from "@/helpers/featureFlags"
export function getAutoColumnInformation(enabled = true) {
let info = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns, we disable their creation when the DEFAULT_VALUES
// feature flag is enabled.
if (isEnabled("DEFAULT_VALUES")) {
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
}
info[subtype] = { enabled, name: AUTO_COLUMN_DISPLAY_NAMES[key] }
}
return info
}
export function buildAutoColumn(tableName, name, subtype) {
let type, constraints
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
type = FieldType.LINK
constraints = FIELDS.LINK.constraints
break
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
type = FieldType.NUMBER
constraints = FIELDS.NUMBER.constraints
break
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
type = FieldType.DATETIME
constraints = FIELDS.DATETIME.constraints
break
default:
type = FieldType.STRING
constraints = FIELDS.STRING.constraints
break
}
if (Object.values(AUTO_COLUMN_SUB_TYPES).indexOf(subtype) === -1) {
throw "Cannot build auto column with supplied subtype"
}
const base = {
name,
type,
subtype,
icon: "ri-magic-line",
autocolumn: true,
constraints,
}
if (isAutoColumnUserRelationship(subtype)) {
base.tableId = TableNames.USERS
base.fieldName = `${tableName}-${name}`
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -0,0 +1,96 @@
import {
AutoFieldSubType,
Automation,
DateFieldMetadata,
FieldType,
NumberFieldMetadata,
RelationshipFieldMetadata,
RelationshipType,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
} from "@/constants/backend"
import { utils } from "@budibase/shared-core"
type AutoColumnInformation = Partial<
Record<AutoFieldSubType, { enabled: boolean; name: string }>
>
export function getAutoColumnInformation(
enabled = true
): AutoColumnInformation {
const info: AutoColumnInformation = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns with user column default values, we disable their creation
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
const typedKey = key as keyof typeof AUTO_COLUMN_SUB_TYPES
info[subtype] = {
enabled,
name: AUTO_COLUMN_DISPLAY_NAMES[typedKey],
}
}
return info
}
export function buildAutoColumn(
tableName: string,
name: string,
subtype: AutoFieldSubType
): RelationshipFieldMetadata | NumberFieldMetadata | DateFieldMetadata {
const base = {
name,
icon: "ri-magic-line",
autocolumn: true,
}
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
return {
...base,
type: FieldType.LINK,
subtype,
constraints: FIELDS.LINK.constraints,
tableId: TableNames.USERS,
fieldName: `${tableName}-${name}`,
relationshipType: RelationshipType.MANY_TO_ONE,
}
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
return {
...base,
type: FieldType.NUMBER,
subtype,
constraints: FIELDS.NUMBER.constraints,
}
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
return {
...base,
type: FieldType.DATETIME,
subtype,
constraints: FIELDS.DATETIME.constraints,
}
default:
throw utils.unreachable(subtype, {
message: "Cannot build auto column with supplied subtype",
})
}
}
export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -1,4 +1,4 @@
export const suppressWarnings = warnings => {
export const suppressWarnings = (warnings: string[]) => {
if (!warnings?.length) {
return
}

View File

@ -236,13 +236,13 @@
}
if (!role) {
await groups.actions.removeApp(target._id, prodAppId)
await groups.removeApp(target._id, prodAppId)
} else {
await groups.actions.addApp(target._id, prodAppId, role)
await groups.addApp(target._id, prodAppId, role)
}
await usersFetch.refresh()
await groups.actions.init()
await groups.init()
}
const onUpdateGroup = async (group, role) => {
@ -268,7 +268,7 @@
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(appId)
return groups.getGroupAppIds(group).includes(appId)
})
}
@ -299,7 +299,7 @@
role: group?.builder?.apps.includes(prodAppId)
? Constants.Roles.CREATOR
: group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
groups.getGroupAppIds(group).find(x => x === prodAppId)
],
}
}
@ -442,13 +442,11 @@
const onUpdateUserInvite = async (invite, role) => {
let updateBody = {
code: invite.code,
apps: {
...invite.apps,
[prodAppId]: role,
},
}
if (role === Constants.Roles.CREATOR) {
updateBody.builder = updateBody.builder || {}
updateBody.builder.apps = [...(updateBody.builder.apps ?? []), prodAppId]
@ -456,7 +454,7 @@
} else if (role !== Constants.Roles.CREATOR && invite?.builder?.apps) {
invite.builder.apps = []
}
await users.updateInvite(updateBody)
await users.updateInvite(invite.code, updateBody)
await filterInvites(query)
}
@ -470,8 +468,7 @@
let updated = { ...invite }
delete updated.info.apps[prodAppId]
return await users.updateInvite({
code: updated.code,
return await users.updateInvite(updated.code, {
apps: updated.apps,
})
}
@ -485,12 +482,12 @@
}
const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
await groups.removeGroupAppBuilder(groupId, prodAppId)
}
const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) {
await groups.actions.init()
await groups.init()
}
loaded = true
}

View File

@ -1,6 +1,6 @@
<script>
import { viewsV2, rowActions } from "@/stores/builder"
import { admin, themeStore, featureFlags } from "@/stores/portal"
import { admin, themeStore, licensing } from "@/stores/portal"
import { Grid } from "@budibase/frontend-core"
import { API } from "@/api"
import { notifications } from "@budibase/bbui"
@ -53,7 +53,7 @@
{buttons}
allowAddRows
allowDeleteRows
aiEnabled={$featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS}
aiEnabled={$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled}
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud}

View File

@ -8,7 +8,7 @@
rowActions,
roles,
} from "@/stores/builder"
import { themeStore, admin, featureFlags } from "@/stores/portal"
import { themeStore, admin, licensing } from "@/stores/portal"
import { TableNames } from "@/constants"
import { Grid } from "@budibase/frontend-core"
import { API } from "@/api"
@ -130,7 +130,8 @@
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
isCloud={$admin.cloud}
aiEnabled={$featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS}
aiEnabled={$licensing.customAIConfigsEnabled ||
$licensing.budibaseAIEnabled}
{buttons}
buttonsCollapsed
on:updatedatasource={handleGridTableUpdate}

View File

@ -53,7 +53,7 @@
}
if (!Object.keys(user?.roles).length && user?.userGroups) {
return userGroups.find(group => {
return groups.actions
return groups
.getGroupAppIds(group)
.map(role => appsStore.extractAppId(role))
.includes(app.appId)
@ -86,7 +86,7 @@
try {
await organisation.init()
await appsStore.load()
await groups.actions.init()
await groups.init()
} catch (error) {
notifications.error("Error loading apps")
}

View File

@ -24,7 +24,7 @@
promises.push(templates.load())
}
promises.push(groups.actions.init())
promises.push(groups.init())
// Always load latest
await Promise.all(promises)

View File

@ -191,8 +191,14 @@
? "View errors"
: "View error"}
on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId })
await appsStore.load()
const automationId = Object.keys(automationErrors[appId] || {})[0]
if (automationId) {
await automationStore.actions.clearLogErrors({
appId,
automationId,
})
await appsStore.load()
}
}}
message={automationErrorMessage(appId)}
/>

View File

@ -12,7 +12,7 @@
Tags,
Tag,
} from "@budibase/bbui"
import { admin, licensing, featureFlags } from "@/stores/portal"
import { admin, licensing } from "@/stores/portal"
import { API } from "@/api"
import AIConfigModal from "./ConfigModal.svelte"
import AIConfigTile from "./AIConfigTile.svelte"
@ -27,8 +27,7 @@
let editingUuid
$: isCloud = $admin.cloud
$: customAIConfigsEnabled =
$featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() {
try {

View File

@ -1,10 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
import { featureFlags } from "@/stores/portal"
if ($featureFlags.AI_CUSTOM_CONFIGS) {
$redirect("./ai")
} else {
$redirect("./auth")
}
$redirect("./ai")
</script>

View File

@ -53,9 +53,7 @@
$: readonly = !isAdmin || isScimGroup
$: groupApps = $appsStore.apps
.filter(app =>
groups.actions
.getGroupAppIds(group)
.includes(appsStore.getProdAppID(app.devId))
groups.getGroupAppIds(group).includes(appsStore.getProdAppID(app.devId))
)
.map(app => ({
...app,
@ -72,7 +70,7 @@
async function deleteGroup() {
try {
await groups.actions.delete(group)
await groups.delete(group)
notifications.success("User group deleted successfully")
$goto("./")
} catch (error) {
@ -82,7 +80,7 @@
async function saveGroup(group) {
try {
await groups.actions.save(group)
await groups.save(group)
} catch (error) {
if (error.message) {
notifications.error(error.message)
@ -93,7 +91,7 @@
}
const removeApp = async app => {
await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
await groups.removeApp(groupId, appsStore.getProdAppID(app.devId))
}
setContext("roles", {
updateRole: () => {},
@ -102,7 +100,7 @@
onMount(async () => {
try {
await Promise.all([groups.actions.init(), roles.fetch()])
await Promise.all([groups.init(), roles.fetch()])
loaded = true
} catch (error) {
notifications.error("Error fetching user group data")

View File

@ -23,7 +23,7 @@
return keepOpen
} else {
await groups.actions.addApp(group._id, prodAppId, selectedRoleId)
await groups.addApp(group._id, prodAppId, selectedRoleId)
}
}
</script>

View File

@ -50,11 +50,11 @@
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={async e => {
await groups.actions.addUser(groupId, e.detail)
await groups.addUser(groupId, e.detail)
onUsersUpdated()
}}
on:deselect={async e => {
await groups.actions.removeUser(groupId, e.detail)
await groups.removeUser(groupId, e.detail)
onUsersUpdated()
}}
/>

View File

@ -52,7 +52,7 @@
]
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
await groups.removeUser(groupId, id)
fetchGroupUsers.refresh()
}

View File

@ -60,7 +60,7 @@
async function saveGroup(group) {
try {
group = await groups.actions.save(group)
group = await groups.save(group)
$goto(`./${group._id}`)
notifications.success(`User group created successfully`)
} catch (error) {
@ -83,7 +83,7 @@
try {
// always load latest
await licensing.init()
await groups.actions.init()
await groups.init()
} catch (error) {
notifications.error("Error getting user groups")
}

View File

@ -87,6 +87,7 @@
let popover
let user, tenantOwner
let loaded = false
let userFieldsToUpdate = {}
$: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync)
@ -164,40 +165,45 @@
return label
}
async function updateUserFirstName(evt) {
async function saveUser() {
try {
await users.save({ ...user, firstName: evt.target.value })
await users.save({ ...user, ...userFieldsToUpdate })
userFieldsToUpdate = {}
await fetchUser()
} catch (error) {
notifications.error("Error updating user")
}
}
async function updateUserFirstName(evt) {
userFieldsToUpdate.firstName = evt.target.value
}
async function updateUserLastName(evt) {
try {
await users.save({ ...user, lastName: evt.target.value })
await fetchUser()
} catch (error) {
notifications.error("Error updating user")
}
userFieldsToUpdate.lastName = evt.target.value
}
async function updateUserRole({ detail }) {
let flags = {}
if (detail === Constants.BudibaseRoles.Developer) {
toggleFlags({ admin: { global: false }, builder: { global: true } })
flags = { admin: { global: false }, builder: { global: true } }
} else if (detail === Constants.BudibaseRoles.Admin) {
toggleFlags({ admin: { global: true }, builder: { global: true } })
flags = { admin: { global: true }, builder: { global: true } }
} else if (detail === Constants.BudibaseRoles.AppUser) {
toggleFlags({ admin: { global: false }, builder: { global: false } })
flags = { admin: { global: false }, builder: { global: false } }
} else if (detail === Constants.BudibaseRoles.Creator) {
toggleFlags({
flags = {
admin: { global: false },
builder: {
global: false,
creator: true,
apps: user?.builder?.apps || [],
},
})
}
}
userFieldsToUpdate = {
...userFieldsToUpdate,
...flags,
}
}
@ -209,22 +215,13 @@
tenantOwner = await users.getAccountHolder()
}
async function toggleFlags(detail) {
try {
await users.save({ ...user, ...detail })
await fetchUser()
} catch (error) {
notifications.error("Error updating user")
}
}
const addGroup = async groupId => {
await groups.actions.addUser(groupId, userId)
await groups.addUser(groupId, userId)
await fetchUser()
}
const removeGroup = async groupId => {
await groups.actions.removeUser(groupId, userId)
await groups.removeUser(groupId, userId)
await fetchUser()
}
@ -234,7 +231,7 @@
onMount(async () => {
try {
await Promise.all([fetchUser(), groups.actions.init(), roles.fetch()])
await Promise.all([fetchUser(), groups.init(), roles.fetch()])
loaded = true
} catch (error) {
notifications.error("Error getting user groups")
@ -296,7 +293,7 @@
<Input
disabled={readonly}
value={user?.firstName}
on:blur={updateUserFirstName}
on:input={updateUserFirstName}
/>
</div>
<div class="field">
@ -304,7 +301,7 @@
<Input
disabled={readonly}
value={user?.lastName}
on:blur={updateUserLastName}
on:input={updateUserLastName}
/>
</div>
<!-- don't let a user remove the privileges that let them be here -->
@ -325,6 +322,13 @@
{/if}
</div>
</Layout>
<div>
<Button
cta
disabled={Object.keys(userFieldsToUpdate).length === 0}
on:click={saveUser}>Save</Button
>
</div>
{#if $licensing.groupsEnabled}
<!-- User groups -->

View File

@ -247,10 +247,11 @@
try {
bulkSaveResponse = await users.create(await removingDuplicities(userData))
notifications.success("Successfully created user")
await groups.actions.init()
await groups.init()
passwordModal.show()
await fetch.refresh()
} catch (error) {
console.error(error)
notifications.error("Error creating user")
}
}
@ -317,7 +318,7 @@
onMount(async () => {
try {
await groups.actions.init()
await groups.init()
groupsLoaded = true
} catch (error) {
notifications.error("Error fetching user group data")

View File

@ -1,27 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createPermissionStore() {
const { subscribe } = writable([])
return {
subscribe,
save: async ({ level, role, resource }) => {
return await API.updatePermissionForResource(resource, role, level)
},
remove: async ({ level, role, resource }) => {
return await API.removePermissionFromResource(resource, role, level)
},
forResource: async resourceId => {
return (await API.getPermissionForResource(resourceId)).permissions
},
forResourceDetailed: async resourceId => {
return await API.getPermissionForResource(resourceId)
},
getDependantsInfo: async resourceId => {
return await API.getDependants(resourceId)
},
}
}
export const permissions = createPermissionStore()

View File

@ -0,0 +1,50 @@
import { BudiStore } from "../BudiStore"
import { API } from "@/api"
import {
PermissionLevel,
GetResourcePermsResponse,
GetDependantResourcesResponse,
ResourcePermissionInfo,
} from "@budibase/types"
interface Permission {
level: PermissionLevel
role: string
resource: string
}
export class PermissionStore extends BudiStore<Permission[]> {
constructor() {
super([])
}
save = async (permission: Permission) => {
const { level, role, resource } = permission
return await API.updatePermissionForResource(resource, role, level)
}
remove = async (permission: Permission) => {
const { level, role, resource } = permission
return await API.removePermissionFromResource(resource, role, level)
}
forResource = async (
resourceId: string
): Promise<Record<string, ResourcePermissionInfo>> => {
return (await API.getPermissionForResource(resourceId)).permissions
}
forResourceDetailed = async (
resourceId: string
): Promise<GetResourcePermsResponse> => {
return await API.getPermissionForResource(resourceId)
}
getDependantsInfo = async (
resourceId: string
): Promise<GetDependantResourcesResponse> => {
return await API.getDependants(resourceId)
}
}
export const permissions = new PermissionStore()

View File

@ -1,13 +1,16 @@
import { appStore } from "./app"
import { appsStore } from "@/stores/portal/apps"
import { deploymentStore } from "./deployments"
import { derived } from "svelte/store"
import { derived, type Readable } from "svelte/store"
import { DeploymentProgressResponse, DeploymentStatus } from "@budibase/types"
export const appPublished = derived(
export const appPublished: Readable<boolean> = derived(
[appStore, appsStore, deploymentStore],
([$appStore, $appsStore, $deploymentStore]) => {
const app = $appsStore.apps.find(app => app.devId === $appStore.appId)
const deployments = $deploymentStore.filter(x => x.status === "SUCCESS")
const deployments = $deploymentStore.filter(
(x: DeploymentProgressResponse) => x.status === DeploymentStatus.SUCCESS
)
return app?.status === "published" && deployments.length > 0
}
)

View File

@ -1,130 +0,0 @@
import { writable, get, derived } from "svelte/store"
import { datasources } from "./datasources"
import { integrations } from "./integrations"
import { API } from "@/api"
import { duplicateName } from "@/helpers/duplicate"
const sortQueries = queryList => {
queryList.sort((q1, q2) => {
return q1.name.localeCompare(q2.name)
})
}
export function createQueriesStore() {
const store = writable({
list: [],
selectedQueryId: null,
})
const derivedStore = derived(store, $store => ({
...$store,
selected: $store.list?.find(q => q._id === $store.selectedQueryId),
}))
const fetch = async () => {
const queries = await API.getQueries()
sortQueries(queries)
store.update(state => ({
...state,
list: queries,
}))
}
const save = async (datasourceId, query) => {
const _integrations = get(integrations)
const dataSource = get(datasources).list.filter(
ds => ds._id === datasourceId
)
// Check if readable attribute is found
if (dataSource.length !== 0) {
const integration = _integrations[dataSource[0].source]
const readable = integration.query[query.queryVerb].readable
if (readable) {
query.readable = readable
}
}
query.datasourceId = datasourceId
const savedQuery = await API.saveQuery(query)
store.update(state => {
const idx = state.list.findIndex(query => query._id === savedQuery._id)
const queries = state.list
if (idx >= 0) {
queries.splice(idx, 1, savedQuery)
} else {
queries.push(savedQuery)
}
sortQueries(queries)
return {
list: queries,
selectedQueryId: savedQuery._id,
}
})
return savedQuery
}
const importQueries = async ({ data, datasourceId }) => {
return await API.importQueries(datasourceId, data)
}
const select = id => {
store.update(state => ({
...state,
selectedQueryId: id,
}))
}
const preview = async query => {
const result = await API.previewQuery(query)
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
const schema = {}
for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = metadata || { type: "string" }
}
return { ...result, schema, rows: result.rows || [] }
}
const deleteQuery = async query => {
await API.deleteQuery(query._id, query._rev)
store.update(state => {
state.list = state.list.filter(existing => existing._id !== query._id)
return state
})
}
const duplicate = async query => {
let list = get(store).list
const newQuery = { ...query }
const datasourceId = query.datasourceId
delete newQuery._id
delete newQuery._rev
newQuery.name = duplicateName(
query.name,
list.map(q => q.name)
)
return await save(datasourceId, newQuery)
}
const removeDatasourceQueries = datasourceId => {
store.update(state => ({
...state,
list: state.list.filter(table => table.datasourceId !== datasourceId),
}))
}
return {
subscribe: derivedStore.subscribe,
fetch,
init: fetch,
select,
save,
import: importQueries,
delete: deleteQuery,
preview,
duplicate,
removeDatasourceQueries,
}
}
export const queries = createQueriesStore()

View File

@ -0,0 +1,156 @@
import { derived, get, Writable } from "svelte/store"
import { datasources } from "./datasources"
import { integrations } from "./integrations"
import { API } from "@/api"
import { duplicateName } from "@/helpers/duplicate"
import { DerivedBudiStore } from "@/stores/BudiStore"
import {
Query,
QueryPreview,
PreviewQueryResponse,
SaveQueryRequest,
ImportRestQueryRequest,
QuerySchema,
} from "@budibase/types"
const sortQueries = (queryList: Query[]) => {
queryList.sort((q1, q2) => {
return q1.name.localeCompare(q2.name)
})
}
interface BuilderQueryStore {
list: Query[]
selectedQueryId: string | null
}
interface DerivedQueryStore extends BuilderQueryStore {
selected?: Query
}
export class QueryStore extends DerivedBudiStore<
BuilderQueryStore,
DerivedQueryStore
> {
constructor() {
const makeDerivedStore = (store: Writable<BuilderQueryStore>) => {
return derived(store, ($store): DerivedQueryStore => {
return {
list: $store.list,
selectedQueryId: $store.selectedQueryId,
selected: $store.list?.find(q => q._id === $store.selectedQueryId),
}
})
}
super(
{
list: [],
selectedQueryId: null,
},
makeDerivedStore
)
this.select = this.select.bind(this)
}
async fetch() {
const queries = await API.getQueries()
sortQueries(queries)
this.store.update(state => ({
...state,
list: queries,
}))
}
async save(datasourceId: string, query: SaveQueryRequest) {
const _integrations = get(integrations)
const dataSource = get(datasources).list.filter(
ds => ds._id === datasourceId
)
// Check if readable attribute is found
if (dataSource.length !== 0) {
const integration = _integrations[dataSource[0].source]
const readable = integration.query[query.queryVerb].readable
if (readable) {
query.readable = readable
}
}
query.datasourceId = datasourceId
const savedQuery = await API.saveQuery(query)
this.store.update(state => {
const idx = state.list.findIndex(query => query._id === savedQuery._id)
const queries = state.list
if (idx >= 0) {
queries.splice(idx, 1, savedQuery)
} else {
queries.push(savedQuery)
}
sortQueries(queries)
return {
list: queries,
selectedQueryId: savedQuery._id || null,
}
})
return savedQuery
}
async importQueries(data: ImportRestQueryRequest) {
return await API.importQueries(data)
}
select(id: string | null) {
this.store.update(state => ({
...state,
selectedQueryId: id,
}))
}
async preview(query: QueryPreview): Promise<PreviewQueryResponse> {
const result = await API.previewQuery(query)
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
const schema: Record<string, QuerySchema> = {}
for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = (metadata as QuerySchema) || { type: "string" }
}
return { ...result, schema, rows: result.rows || [] }
}
async delete(query: Query) {
if (!query._id || !query._rev) {
throw new Error("Query ID or Revision is missing")
}
await API.deleteQuery(query._id, query._rev)
this.store.update(state => ({
...state,
list: state.list.filter(existing => existing._id !== query._id),
}))
}
async duplicate(query: Query) {
let list = get(this.store).list
const newQuery = { ...query }
const datasourceId = query.datasourceId
delete newQuery._id
delete newQuery._rev
newQuery.name = duplicateName(
query.name,
list.map(q => q.name)
)
return await this.save(datasourceId, newQuery)
}
removeDatasourceQueries(datasourceId: string) {
this.store.update(state => ({
...state,
list: state.list.filter(table => table.datasourceId !== datasourceId),
}))
}
init = this.fetch
}
export const queries = new QueryStore()

View File

@ -1,88 +0,0 @@
import { derived, writable, get } from "svelte/store"
import { API } from "@/api"
import { RoleUtils } from "@budibase/frontend-core"
export function createRolesStore() {
const store = writable([])
const enriched = derived(store, $store => {
return $store.map(role => ({
...role,
// Ensure we have new metadata for all roles
uiMetadata: {
displayName: role.uiMetadata?.displayName || role.name,
color:
role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)",
description: role.uiMetadata?.description || "Custom role",
},
}))
})
function setRoles(roles) {
store.set(
roles.sort((a, b) => {
const priorityA = RoleUtils.getRolePriority(a._id)
const priorityB = RoleUtils.getRolePriority(b._id)
if (priorityA !== priorityB) {
return priorityA > priorityB ? -1 : 1
}
const nameA = a.uiMetadata?.displayName || a.name
const nameB = b.uiMetadata?.displayName || b.name
return nameA < nameB ? -1 : 1
})
)
}
const actions = {
fetch: async () => {
const roles = await API.getRoles()
setRoles(roles)
},
fetchByAppId: async appId => {
const { roles } = await API.getRolesForApp(appId)
setRoles(roles)
},
delete: async role => {
await API.deleteRole(role._id, role._rev)
await actions.fetch()
},
save: async role => {
const savedRole = await API.saveRole(role)
await actions.fetch()
return savedRole
},
replace: (roleId, role) => {
// Handles external updates of roles
if (!roleId) {
return
}
// Handle deletion
if (!role) {
store.update(state => state.filter(x => x._id !== roleId))
return
}
// Add new role
const index = get(store).findIndex(x => x._id === role._id)
if (index === -1) {
store.update(state => [...state, role])
}
// Update existing role
else if (role) {
store.update(state => {
state[index] = role
return [...state]
})
}
},
}
return {
subscribe: enriched.subscribe,
...actions,
}
}
export const roles = createRolesStore()

View File

@ -0,0 +1,94 @@
import { derived, get, type Writable } from "svelte/store"
import { API } from "@/api"
import { RoleUtils } from "@budibase/frontend-core"
import { DerivedBudiStore } from "../BudiStore"
import { Role } from "@budibase/types"
export class RoleStore extends DerivedBudiStore<Role[], Role[]> {
constructor() {
const makeDerivedStore = (store: Writable<Role[]>) =>
derived(store, $store => {
return $store.map((role: Role) => ({
...role,
// Ensure we have new metadata for all roles
uiMetadata: {
displayName: role.uiMetadata?.displayName || role.name,
color:
role.uiMetadata?.color ||
"var(--spectrum-global-color-magenta-400)",
description: role.uiMetadata?.description || "Custom role",
},
}))
})
super([], makeDerivedStore)
}
setRoles = (roles: Role[]) => {
this.set(
roles.sort((a, b) => {
// Ensure we have valid IDs for priority comparison
const priorityA = RoleUtils.getRolePriority(a._id)
const priorityB = RoleUtils.getRolePriority(b._id)
if (priorityA !== priorityB) {
return priorityA > priorityB ? -1 : 1
}
const nameA = a.uiMetadata?.displayName || a.name
const nameB = b.uiMetadata?.displayName || b.name
return nameA < nameB ? -1 : 1
})
)
}
fetch = async () => {
const roles = await API.getRoles()
this.setRoles(roles)
}
fetchByAppId = async (appId: string) => {
const { roles } = await API.getRolesForApp(appId)
this.setRoles(roles)
}
delete = async (role: Role) => {
if (!role._id || !role._rev) {
return
}
await API.deleteRole(role._id, role._rev)
await this.fetch()
}
save = async (role: Role) => {
const savedRole = await API.saveRole(role)
await this.fetch()
return savedRole
}
replace = (roleId: string, role?: Role) => {
// Handles external updates of roles
if (!roleId) {
return
}
// Handle deletion
if (!role) {
this.update(state => state.filter(x => x._id !== roleId))
return
}
// Add new role
const index = get(this).findIndex(x => x._id === role._id)
if (index === -1) {
this.update(state => [...state, role])
}
// Update existing role
else if (role) {
this.update(state => {
state[index] = role
return [...state]
})
}
}
}
export const roles = new RoleStore()

View File

@ -62,7 +62,7 @@ export class RowActionStore extends BudiStore<RowActionState> {
const existingRowActions = get(this)[tableId] || []
name = getSequentialName(existingRowActions, "New row action ", {
getName: x => x.name,
})
})!
}
if (!name) {

View File

@ -1,67 +0,0 @@
import { writable, derived } from "svelte/store"
import { tables } from "./tables"
import { API } from "@/api"
export function createViewsStore() {
const store = writable({
selectedViewName: null,
})
const derivedStore = derived([store, tables], ([$store, $tables]) => {
let list = []
$tables.list?.forEach(table => {
const views = Object.values(table?.views || {}).filter(view => {
return view.version !== 2
})
list = list.concat(views)
})
return {
...$store,
list,
selected: list.find(view => view.name === $store.selectedViewName),
}
})
const select = name => {
store.update(state => ({
...state,
selectedViewName: name,
}))
}
const deleteView = async view => {
await API.deleteView(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
delete table.views[view.name]
return { ...state }
})
}
const save = async view => {
const savedView = await API.saveView(view)
select(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
return {
subscribe: derivedStore.subscribe,
select,
delete: deleteView,
save,
}
}
export const views = createViewsStore()

View File

@ -0,0 +1,94 @@
import { DerivedBudiStore } from "../BudiStore"
import { tables } from "./tables"
import { API } from "@/api"
import { View } from "@budibase/types"
import { helpers } from "@budibase/shared-core"
import { derived, Writable } from "svelte/store"
interface BuilderViewStore {
selectedViewName: string | null
}
interface DerivedViewStore extends BuilderViewStore {
list: View[]
selected?: View
}
export class ViewsStore extends DerivedBudiStore<
BuilderViewStore,
DerivedViewStore
> {
constructor() {
const makeDerivedStore = (store: Writable<BuilderViewStore>) => {
return derived([store, tables], ([$store, $tables]): DerivedViewStore => {
let list: View[] = []
$tables.list?.forEach(table => {
const views = Object.values(table?.views || {}).filter(
(view): view is View => !helpers.views.isV2(view)
)
list = list.concat(views)
})
return {
selectedViewName: $store.selectedViewName,
list,
selected: list.find(view => view.name === $store.selectedViewName),
}
})
}
super(
{
selectedViewName: null,
},
makeDerivedStore
)
this.select = this.select.bind(this)
}
select = (name: string) => {
this.store.update(state => ({
...state,
selectedViewName: name,
}))
}
delete = async (view: View) => {
if (!view.name) {
throw new Error("View name is required")
}
await API.deleteView(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table?.views && view.name) {
delete table.views[view.name]
}
return { ...state }
})
}
save = async (view: View & { originalName?: string }) => {
if (!view.name) {
throw new Error("View name is required")
}
const savedView = await API.saveView(view)
this.select(view.name)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table?.views && view.name) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
}
export const views = new ViewsStore()

View File

@ -1,103 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { licensing } from "@/stores/portal"
export function createGroupsStore() {
const store = writable([])
const updateStore = group => {
store.update(state => {
const currentIdx = state.findIndex(gr => gr._id === group._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}
const getGroup = async groupId => {
const group = await API.getGroup(groupId)
updateStore(group)
}
const actions = {
init: async () => {
// only init if there is a groups license, just to be sure but the feature will be blocked
// on the backend anyway
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
store.set(groups.data)
}
},
get: getGroup,
save: async group => {
const { ...dataToSave } = group
delete dataToSave.scimInfo
delete dataToSave.userGroups
const response = await API.saveGroup(dataToSave)
group._id = response._id
group._rev = response._rev
updateStore(group)
return group
},
delete: async group => {
await API.deleteGroup(group._id, group._rev)
store.update(state => {
state = state.filter(state => state._id !== group._id)
return state
})
},
addUser: async (groupId, userId) => {
await API.addUsersToGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
removeUser: async (groupId, userId) => {
await API.removeUsersFromGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
addApp: async (groupId, appId, roleId) => {
await API.addAppsToGroup(groupId, [{ appId, roleId }])
// refresh the group roles
await getGroup(groupId)
},
removeApp: async (groupId, appId) => {
await API.removeAppsFromGroup(groupId, [{ appId }])
// refresh the group roles
await getGroup(groupId)
},
getGroupAppIds: group => {
let groupAppIds = Object.keys(group?.roles || {})
if (group?.builder?.apps) {
groupAppIds = groupAppIds.concat(group.builder.apps)
}
return groupAppIds
},
addGroupAppBuilder: async (groupId, appId) => {
return await API.addGroupAppBuilder(groupId, appId)
},
removeGroupAppBuilder: async (groupId, appId) => {
return await API.removeGroupAppBuilder(groupId, appId)
},
}
return {
subscribe: store.subscribe,
actions,
}
}
export const groups = createGroupsStore()

View File

@ -0,0 +1,96 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { licensing } from "@/stores/portal"
import { UserGroup } from "@budibase/types"
import { BudiStore } from "../BudiStore"
class GroupStore extends BudiStore<UserGroup[]> {
constructor() {
super([])
}
updateStore = (group: UserGroup) => {
this.update(state => {
const currentIdx = state.findIndex(gr => gr._id === group._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}
async init() {
// Only init if there is a groups license, just to be sure but the feature will be blocked
// on the backend anyway
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
this.set(groups)
}
}
private async refreshGroup(groupId: string) {
const group = await API.getGroup(groupId)
this.updateStore(group)
}
async save(group: UserGroup) {
const { ...dataToSave } = group
delete dataToSave.scimInfo
const response = await API.saveGroup(dataToSave)
group._id = response._id
group._rev = response._rev
this.updateStore(group)
return group
}
async delete(group: UserGroup) {
await API.deleteGroup(group._id!, group._rev!)
this.update(groups => {
const index = groups.findIndex(g => g._id === group._id)
if (index !== -1) {
groups.splice(index, 1)
}
return groups
})
}
async addUser(groupId: string, userId: string) {
await API.addUsersToGroup(groupId, [userId])
await this.refreshGroup(groupId)
}
async removeUser(groupId: string, userId: string) {
await API.removeUsersFromGroup(groupId, [userId])
await this.refreshGroup(groupId)
}
async addApp(groupId: string, appId: string, roleId: string) {
await API.addAppsToGroup(groupId, [{ appId, roleId }])
await this.refreshGroup(groupId)
}
async removeApp(groupId: string, appId: string) {
await API.removeAppsFromGroup(groupId, [{ appId }])
await this.refreshGroup(groupId)
}
getGroupAppIds(group: UserGroup) {
let groupAppIds = Object.keys(group?.roles || {})
if (group?.builder?.apps) {
groupAppIds = groupAppIds.concat(group.builder.apps)
}
return groupAppIds
}
async addGroupAppBuilder(groupId: string, appId: string) {
return await API.addGroupAppBuilder(groupId, appId)
}
async removeGroupAppBuilder(groupId: string, appId: string) {
return await API.removeGroupAppBuilder(groupId, appId)
}
}
export const groups = new GroupStore()

View File

@ -1,279 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth, admin } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "@/components/portal/licensing/constants"
import { PlanModel } from "@budibase/types"
const UNLIMITED = -1
export const createLicensingStore = () => {
const DEFAULT = {
// navigation
goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
isEnterprisePlan: true,
isBusinessPlan: true,
// features
groupsEnabled: false,
backupsEnabled: false,
brandingEnabled: false,
scimEnabled: false,
environmentVariablesEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
auditLogsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
usageMetrics: undefined,
// quota reset
quotaResetDaysRemaining: undefined,
quotaResetDate: undefined,
// failed payments
accountPastDue: undefined,
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: undefined,
// user limits
userCount: undefined,
userLimit: undefined,
userLimitReached: false,
errUserLimit: false,
}
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT)
function usersLimitReached(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount >= userLimit
}
function usersLimitExceeded(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount > userLimit
}
async function isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
const actions = {
init: async () => {
actions.setNavigation()
actions.setLicense()
await actions.setQuotaUsage()
},
setNavigation: () => {
const adminStore = get(admin)
const authStore = get(auth)
const upgradeUrl = authStore?.user?.accountPortalAccess
? `${adminStore.accountPortalUrl}/portal/upgrade`
: "/builder/portal/account/upgrade"
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
store.update(state => {
return {
...state,
goToUpgradePage,
goToPricingPage,
}
})
},
setLicense: () => {
const license = get(auth).user.license
const planType = license?.plan.type
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS
)
const backupsEnabled = license.features.includes(
Constants.Features.APP_BACKUPS
)
const scimEnabled = license.features.includes(Constants.Features.SCIM)
const environmentVariablesEnabled = license.features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = license.features.includes(
Constants.Features.ENFORCEABLE_SSO
)
const brandingEnabled = license.features.includes(
Constants.Features.BRANDING
)
const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS
)
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
const triggerAutomationRunEnabled = license.features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS
)
const budibaseAIEnabled = license.features.includes(
Constants.Features.BUDIBASE_AI
)
const customAIConfigsEnabled = license.features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
store.update(state => {
return {
...state,
license,
isEnterprisePlan,
isFreePlan,
isBusinessPlan,
isEnterpriseTrial,
groupsEnabled,
backupsEnabled,
brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
triggerAutomationRunEnabled,
perAppBuildersEnabled,
}
})
},
setQuotaUsage: async () => {
const quotaUsage = await API.getQuotaUsage()
store.update(state => {
return {
...state,
quotaUsage,
}
})
await actions.setUsageMetrics()
},
usersLimitReached: userCount => {
return usersLimitReached(userCount, get(store).userLimit)
},
usersLimitExceeded(userCount) {
return usersLimitExceeded(userCount, get(store).userLimit)
},
setUsageMetrics: async () => {
const usage = get(store).quotaUsage
const license = get(auth).user.license
const now = new Date()
const getMetrics = (keys, license, quota) => {
if (!license || !quota || !keys) {
return {}
}
return keys.reduce((acc, key) => {
const quotaLimit = license[key].value
const quotaUsed = (quota[key] / quotaLimit) * 100
acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1
return acc
}, {})
}
const monthlyMetrics = getMetrics(
["queries", "automations"],
license.quotas.usage.monthly,
usage.monthly.current
)
const staticMetrics = getMetrics(
["apps", "rows"],
license.quotas.usage.static,
usage.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
return dateEnd > dateStart
? Math.round(
(dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds
)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
license?.billing?.subscription?.downgradeAt &&
license?.billing?.subscription?.downgradeAt <= now.getTime() &&
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license?.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds =
license?.billing?.subscription?.downgradeAt
let pastDueDaysRemaining
let pastDueEndDate
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
},
}
return {
subscribe: store.subscribe,
...actions,
}
}
export const licensing = createLicensingStore()

View File

@ -0,0 +1,305 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth, admin } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "@/components/portal/licensing/constants"
import {
License,
MonthlyQuotaName,
PlanModel,
QuotaUsage,
StaticQuotaName,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
const UNLIMITED = -1
const ONE_DAY_MILLIS = 86400000
type MonthlyMetrics = { [key in MonthlyQuotaName]?: number }
type StaticMetrics = { [key in StaticQuotaName]?: number }
type UsageMetrics = MonthlyMetrics & StaticMetrics
interface LicensingState {
goToUpgradePage: () => void
goToPricingPage: () => void
// the top level license
license?: License
isFreePlan: boolean
isEnterprisePlan: boolean
isBusinessPlan: boolean
// features
groupsEnabled: boolean
backupsEnabled: boolean
brandingEnabled: boolean
scimEnabled: boolean
environmentVariablesEnabled: boolean
budibaseAIEnabled: boolean
customAIConfigsEnabled: boolean
auditLogsEnabled: boolean
// the currently used quotas from the db
quotaUsage?: QuotaUsage
// derived quota metrics for percentages used
usageMetrics?: UsageMetrics
// quota reset
quotaResetDaysRemaining?: number
quotaResetDate?: Date
// failed payments
accountPastDue: boolean
pastDueEndDate?: Date
pastDueDaysRemaining?: number
accountDowngraded: boolean
// user limits
userCount?: number
userLimit?: number
userLimitReached: boolean
errUserLimit: boolean
}
class LicensingStore extends BudiStore<LicensingState> {
constructor() {
super({
// navigation
goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
isEnterprisePlan: true,
isBusinessPlan: true,
// features
groupsEnabled: false,
backupsEnabled: false,
brandingEnabled: false,
scimEnabled: false,
environmentVariablesEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
auditLogsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
usageMetrics: undefined,
// quota reset
quotaResetDaysRemaining: undefined,
quotaResetDate: undefined,
// failed payments
accountPastDue: false,
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: false,
// user limits
userCount: undefined,
userLimit: undefined,
userLimitReached: false,
errUserLimit: false,
})
}
usersLimitReached(userCount: number, userLimit = get(this.store).userLimit) {
if (userLimit === UNLIMITED || userLimit === undefined) {
return false
}
return userCount >= userLimit
}
usersLimitExceeded(userCount: number, userLimit = get(this.store).userLimit) {
if (userLimit === UNLIMITED || userLimit === undefined) {
return false
}
return userCount > userLimit
}
async isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
async init() {
this.setNavigation()
this.setLicense()
await this.setQuotaUsage()
}
setNavigation() {
const adminStore = get(admin)
const authStore = get(auth)
const upgradeUrl = authStore?.user?.accountPortalAccess
? `${adminStore.accountPortalUrl}/portal/upgrade`
: "/builder/portal/account/upgrade"
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
this.update(state => {
return {
...state,
goToUpgradePage,
goToPricingPage,
}
})
}
setLicense() {
const license = get(auth).user?.license
const planType = license?.plan.type
const features = license?.features || []
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = features.includes(Constants.Features.USER_GROUPS)
const backupsEnabled = features.includes(Constants.Features.APP_BACKUPS)
const scimEnabled = features.includes(Constants.Features.SCIM)
const environmentVariablesEnabled = features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
const brandingEnabled = features.includes(Constants.Features.BRANDING)
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
const syncAutomationsEnabled = features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
const triggerAutomationRunEnabled = features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = features.includes(
Constants.Features.APP_BUILDERS
)
const budibaseAIEnabled = features.includes(Constants.Features.BUDIBASE_AI)
const customAIConfigsEnabled = features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
this.update(state => {
return {
...state,
license,
isEnterprisePlan,
isFreePlan,
isBusinessPlan,
isEnterpriseTrial,
groupsEnabled,
backupsEnabled,
brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
triggerAutomationRunEnabled,
perAppBuildersEnabled,
}
})
}
async setQuotaUsage() {
const quotaUsage = await API.getQuotaUsage()
this.update(state => {
return {
...state,
quotaUsage,
}
})
await this.setUsageMetrics()
}
async setUsageMetrics() {
const usage = get(this.store).quotaUsage
const license = get(auth).user?.license
const now = new Date()
if (!license || !usage) {
return
}
// Process monthly metrics
const monthlyMetrics = [
MonthlyQuotaName.QUERIES,
MonthlyQuotaName.AUTOMATIONS,
].reduce((acc: MonthlyMetrics, key) => {
const limit = license.quotas.usage.monthly[key].value
const used = ((usage.monthly.current?.[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1
return acc
}, {})
// Process static metrics
const staticMetrics = [StaticQuotaName.APPS, StaticQuotaName.ROWS].reduce(
(acc: StaticMetrics, key) => {
const limit = license.quotas.usage.static[key].value
const used = ((usage.usageQuota[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1
return acc
},
{}
)
const getDaysBetween = (dateStart: Date, dateEnd: Date) => {
return dateEnd > dateStart
? Math.round((dateEnd.getTime() - dateStart.getTime()) / ONE_DAY_MILLIS)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
!!license.billing?.subscription?.downgradeAt &&
license.billing?.subscription?.downgradeAt <= now.getTime() &&
license.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds = license.billing?.subscription?.downgradeAt
let pastDueDaysRemaining: number
let pastDueEndDate: Date
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota.value
const userCount = usage.usageQuota.users
const userLimitReached = this.usersLimitReached(userCount, userLimit)
const userLimitExceeded = this.usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await this.isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
this.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
}
}
export const licensing = new LicensingStore()

View File

@ -1,138 +0,0 @@
import { derived } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { isEnabled } from "@/helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
export const menu = derived([admin, auth], ([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = sdk.users.isAdmin(user)
const cloud = $admin?.cloud
// Determine user sub pages
let userSubPages = [
{
title: "Users",
href: "/builder/portal/users/users",
},
]
userSubPages.push({
title: "Groups",
href: "/builder/portal/users/groups",
})
// Pages that all devs and admins can access
let menu = [
{
title: "Apps",
href: "/builder/portal/apps",
},
]
if (sdk.users.isGlobalBuilder(user)) {
menu.push({
title: "Users",
href: "/builder/portal/users",
subPages: userSubPages,
})
menu.push({
title: "Plugins",
href: "/builder/portal/plugins",
})
}
// Add settings page for admins
if (isAdmin) {
let settingsSubPages = [
{
title: "Auth",
href: "/builder/portal/settings/auth",
},
{
title: "Email",
href: "/builder/portal/settings/email",
},
{
title: "Organisation",
href: "/builder/portal/settings/organisation",
},
{
title: "Branding",
href: "/builder/portal/settings/branding",
},
{
title: "Environment",
href: "/builder/portal/settings/environment",
},
]
if (isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) {
settingsSubPages.push({
title: "AI",
href: "/builder/portal/settings/ai",
})
}
if (!cloud) {
settingsSubPages.push({
title: "Version",
href: "/builder/portal/settings/version",
})
settingsSubPages.push({
title: "Diagnostics",
href: "/builder/portal/settings/diagnostics",
})
}
menu.push({
title: "Settings",
href: "/builder/portal/settings",
subPages: [...settingsSubPages].sort((a, b) =>
a.title.localeCompare(b.title)
),
})
}
// Add account page
let accountSubPages = [
{
title: "Usage",
href: "/builder/portal/account/usage",
},
]
if (isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
})
if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",
href: $admin?.accountPortalUrl + "/portal/upgrade",
})
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (user?.accountPortalAccess && user.account.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({
title: "Account",
href: "/builder/portal/account",
subPages: accountSubPages,
})
return menu
})

View File

@ -0,0 +1,145 @@
import { derived, Readable } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { sdk } from "@budibase/shared-core"
interface MenuItem {
title: string
href: string
subPages?: MenuItem[]
}
export const menu: Readable<MenuItem[]> = derived(
[admin, auth],
([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = user != null && sdk.users.isAdmin(user)
const isGlobalBuilder = user != null && sdk.users.isGlobalBuilder(user)
const cloud = $admin?.cloud
// Determine user sub pages
let userSubPages: MenuItem[] = [
{
title: "Users",
href: "/builder/portal/users/users",
},
]
userSubPages.push({
title: "Groups",
href: "/builder/portal/users/groups",
})
// Pages that all devs and admins can access
let menu: MenuItem[] = [
{
title: "Apps",
href: "/builder/portal/apps",
},
]
if (isGlobalBuilder) {
menu.push({
title: "Users",
href: "/builder/portal/users",
subPages: userSubPages,
})
menu.push({
title: "Plugins",
href: "/builder/portal/plugins",
})
}
// Add settings page for admins
if (isAdmin) {
let settingsSubPages: MenuItem[] = [
{
title: "Auth",
href: "/builder/portal/settings/auth",
},
{
title: "Email",
href: "/builder/portal/settings/email",
},
{
title: "Organisation",
href: "/builder/portal/settings/organisation",
},
{
title: "Branding",
href: "/builder/portal/settings/branding",
},
{
title: "Environment",
href: "/builder/portal/settings/environment",
},
{
title: "AI",
href: "/builder/portal/settings/ai",
},
]
if (!cloud) {
settingsSubPages.push({
title: "Version",
href: "/builder/portal/settings/version",
})
settingsSubPages.push({
title: "Diagnostics",
href: "/builder/portal/settings/diagnostics",
})
}
menu.push({
title: "Settings",
href: "/builder/portal/settings",
subPages: [...settingsSubPages].sort((a, b) =>
a.title.localeCompare(b.title)
),
})
}
// Add account page
let accountSubPages: MenuItem[] = [
{
title: "Usage",
href: "/builder/portal/account/usage",
},
]
if (isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
})
if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",
href: $admin?.accountPortalUrl + "/portal/upgrade",
})
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (user?.accountPortalAccess && user?.account?.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({
title: "Account",
href: "/builder/portal/account",
subPages: accountSubPages,
})
return menu
}
)

View File

@ -1,31 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
const OIDC_CONFIG = {
logo: undefined,
name: undefined,
uuid: undefined,
}
export function createOidcStore() {
const store = writable(OIDC_CONFIG)
const { set, subscribe } = store
return {
subscribe,
set,
init: async () => {
const tenantId = get(auth).tenantId
const config = await API.getOIDCConfig(tenantId)
if (Object.keys(config || {}).length) {
// Just use the first config for now.
// We will be support multiple logins buttons later on.
set(...config)
} else {
set(OIDC_CONFIG)
}
},
}
}
export const oidc = createOidcStore()

View File

@ -0,0 +1,21 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import { BudiStore } from "../BudiStore"
import { PublicOIDCConfig } from "@budibase/types"
class OIDCStore extends BudiStore<PublicOIDCConfig> {
constructor() {
super({})
}
async init() {
const tenantId = get(auth).tenantId
const configs = await API.getOIDCConfigs(tenantId)
// Just use the first config for now.
// We will be support multiple logins buttons later on.
this.set(configs[0] || {})
}
}
export const oidc = new OIDCStore()

View File

@ -1,66 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import _ from "lodash"
const DEFAULT_CONFIG = {
platformUrl: "",
logoUrl: undefined,
faviconUrl: undefined,
emailBrandingEnabled: true,
testimonialsEnabled: true,
platformTitle: "Budibase",
loginHeading: undefined,
loginButton: undefined,
metaDescription: undefined,
metaImageUrl: undefined,
metaTitle: undefined,
docsUrl: undefined,
company: "Budibase",
oidc: undefined,
google: undefined,
googleDatasourceConfigured: undefined,
oidcCallbackUrl: "",
googleCallbackUrl: "",
isSSOEnforced: false,
loaded: false,
}
export function createOrganisationStore() {
const store = writable(DEFAULT_CONFIG)
const { subscribe, set } = store
async function init() {
const tenantId = get(auth).tenantId
const settingsConfigDoc = await API.getTenantConfig(tenantId)
set({ ...DEFAULT_CONFIG, ...settingsConfigDoc.config, loaded: true })
}
async function save(config) {
// Delete non-persisted fields
const storeConfig = _.cloneDeep(get(store))
delete storeConfig.oidc
delete storeConfig.google
delete storeConfig.googleDatasourceConfigured
delete storeConfig.oidcCallbackUrl
delete storeConfig.googleCallbackUrl
// delete internal store field
delete storeConfig.loaded
await API.saveConfig({
type: "settings",
config: { ...storeConfig, ...config },
})
await init()
}
return {
subscribe,
set,
save,
init,
}
}
export const organisation = createOrganisationStore()

View File

@ -0,0 +1,71 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import {
ConfigType,
PublicSettingsInnerConfig,
SettingsBrandingConfig,
SettingsInnerConfig,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface LocalOrganisationState {
loaded: boolean
}
type SavedOrganisationState = SettingsInnerConfig & SettingsBrandingConfig
type OrganisationState = SavedOrganisationState &
PublicSettingsInnerConfig &
LocalOrganisationState
const DEFAULT_STATE: OrganisationState = {
platformUrl: "",
emailBrandingEnabled: true,
testimonialsEnabled: true,
platformTitle: "Budibase",
company: "Budibase",
google: false,
googleDatasourceConfigured: false,
oidc: false,
oidcCallbackUrl: "",
googleCallbackUrl: "",
loaded: false,
}
class OrganisationStore extends BudiStore<OrganisationState> {
constructor() {
super(DEFAULT_STATE)
}
async init() {
const tenantId = get(auth).tenantId
const settingsConfigDoc = await API.getTenantConfig(tenantId)
this.set({ ...DEFAULT_STATE, ...settingsConfigDoc.config, loaded: true })
}
async save(changes: Partial<SavedOrganisationState>) {
// Strip non persisted fields
const {
oidc,
google,
googleDatasourceConfigured,
oidcCallbackUrl,
googleCallbackUrl,
loaded,
...config
} = get(this.store)
// Save new config
const newConfig: SavedOrganisationState = {
...config,
...changes,
}
await API.saveConfig({
type: ConfigType.SETTINGS,
config: newConfig,
})
await this.init()
}
}
export const organisation = new OrganisationStore()

View File

@ -1,41 +1,71 @@
import { writable } from "svelte/store"
import { API } from "@/api"
import { update } from "lodash"
import { licensing } from "."
import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
import {
DeleteInviteUsersRequest,
InviteUsersRequest,
SearchUsersRequest,
SearchUsersResponse,
UpdateInviteRequest,
User,
UserIdentifier,
UnsavedUser,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
export function createUsersStore() {
const { subscribe, set } = writable({})
interface UserInfo {
email: string
password: string
forceResetPassword?: boolean
role: keyof typeof Constants.BudibaseRoles
}
// opts can contain page and search params
async function search(opts = {}) {
type UserState = SearchUsersResponse & SearchUsersRequest
class UserStore extends BudiStore<UserState> {
constructor() {
super({
data: [],
})
}
async search(opts: SearchUsersRequest = {}) {
const paged = await API.searchUsers(opts)
set({
this.set({
...paged,
...opts,
})
return paged
}
async function get(userId) {
async get(userId: string) {
try {
return await API.getUser(userId)
} catch (err) {
return null
}
}
const fetch = async () => {
async fetch() {
return await API.getUsers()
}
// One or more users.
async function onboard(payload) {
async onboard(payload: InviteUsersRequest) {
return await API.onboardUsers(payload)
}
async function invite(payload) {
const users = payload.map(user => {
async invite(
payload: {
admin?: boolean
builder?: boolean
creator?: boolean
email: string
apps?: any[]
groups?: any[]
}[]
) {
const users: InviteUsersRequest = payload.map(user => {
let builder = undefined
if (user.admin || user.builder) {
builder = { global: true }
@ -55,11 +85,16 @@ export function createUsersStore() {
return API.inviteUsers(users)
}
async function removeInvites(payload) {
async removeInvites(payload: DeleteInviteUsersRequest) {
return API.removeUserInvites(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
async acceptInvite(
inviteCode: string,
password: string,
firstName: string,
lastName?: string
) {
return API.acceptInvite({
inviteCode,
password,
@ -68,21 +103,25 @@ export function createUsersStore() {
})
}
async function fetchInvite(inviteCode) {
async fetchInvite(inviteCode: string) {
return API.getUserInvite(inviteCode)
}
async function getInvites() {
async getInvites() {
return API.getUserInvites()
}
async function updateInvite(invite) {
return API.updateUserInvite(invite.code, invite)
async updateInvite(code: string, invite: UpdateInviteRequest) {
return API.updateUserInvite(code, invite)
}
async function create(data) {
let mappedUsers = data.users.map(user => {
const body = {
async getUserCountByApp(appId: string) {
return await API.getUserCountByApp(appId)
}
async create(data: { users: UserInfo[]; groups: any[] }) {
let mappedUsers: UnsavedUser[] = data.users.map((user: any) => {
const body: UnsavedUser = {
email: user.email,
password: user.password,
roles: {},
@ -92,17 +131,17 @@ export function createUsersStore() {
}
switch (user.role) {
case "appUser":
case Constants.BudibaseRoles.AppUser:
body.builder = { global: false }
body.admin = { global: false }
break
case "developer":
case Constants.BudibaseRoles.Developer:
body.builder = { global: true }
break
case "creator":
case Constants.BudibaseRoles.Creator:
body.builder = { creator: true, global: false }
break
case "admin":
case Constants.BudibaseRoles.Admin:
body.admin = { global: true }
body.builder = { global: true }
break
@ -111,43 +150,47 @@ export function createUsersStore() {
return body
})
const response = await API.createUsers(mappedUsers, data.groups)
licensing.setQuotaUsage()
// re-search from first page
await search()
await this.search()
return response
}
async function del(id) {
async delete(id: string) {
await API.deleteUser(id)
update(users => users.filter(user => user._id !== id))
licensing.setQuotaUsage()
}
async function getUserCountByApp(appId) {
return await API.getUserCountByApp(appId)
async bulkDelete(users: UserIdentifier[]) {
const res = API.deleteUsers(users)
licensing.setQuotaUsage()
return res
}
async function bulkDelete(users) {
return API.deleteUsers(users)
async save(user: User) {
const res = await API.saveUser(user)
licensing.setQuotaUsage()
return res
}
async function save(user) {
return await API.saveUser(user)
}
async function addAppBuilder(userId, appId) {
async addAppBuilder(userId: string, appId: string) {
return await API.addAppBuilder(userId, appId)
}
async function removeAppBuilder(userId, appId) {
async removeAppBuilder(userId: string, appId: string) {
return await API.removeAppBuilder(userId, appId)
}
async function getAccountHolder() {
async getAccountHolder() {
return await API.getAccountHolder()
}
const getUserRole = user => {
if (user && user.email === user.tenantOwnerEmail) {
getUserRole(user?: User & { tenantOwnerEmail?: string }) {
if (!user) {
return Constants.BudibaseRoles.AppUser
}
if (user.email === user.tenantOwnerEmail) {
return Constants.BudibaseRoles.Owner
} else if (sdk.users.isAdmin(user)) {
return Constants.BudibaseRoles.Admin
@ -159,38 +202,6 @@ export function createUsersStore() {
return Constants.BudibaseRoles.AppUser
}
}
const refreshUsage =
fn =>
async (...args) => {
const response = await fn(...args)
await licensing.setQuotaUsage()
return response
}
return {
subscribe,
search,
get,
getUserRole,
fetch,
invite,
onboard,
fetchInvite,
getInvites,
removeInvites,
updateInvite,
getUserCountByApp,
addAppBuilder,
removeAppBuilder,
// any operation that adds or deletes users
acceptInvite,
create: refreshUsage(create),
save: refreshUsage(save),
bulkDelete: refreshUsage(bulkDelete),
delete: refreshUsage(del),
getAccountHolder,
}
}
export const users = createUsersStore()
export const users = new UserStore()

View File

@ -5139,7 +5139,8 @@
{
"type": "text",
"label": "File name",
"key": "key"
"key": "key",
"nested": true
},
{
"type": "event",

View File

@ -1,6 +1,10 @@
import { createAPIClient } from "@budibase/frontend-core"
import { authStore } from "../stores/auth.js"
import { notificationStore, devToolsEnabled, devToolsStore } from "../stores/"
import { authStore } from "../stores/auth"
import {
notificationStore,
devToolsEnabled,
devToolsStore,
} from "../stores/index"
import { get } from "svelte/store"
export const API = createAPIClient({

View File

@ -1,5 +1,5 @@
import { API } from "./api.js"
import { patchAPI } from "./patches.js"
import { API } from "./api"
import { patchAPI } from "./patches"
// Certain endpoints which return rows need patched so that they transform
// and enrich the row docs, so that they can be correctly handled by the

View File

@ -1,19 +1,20 @@
import { Constants } from "@budibase/frontend-core"
import { Constants, APIClient } from "@budibase/frontend-core"
import { FieldTypes } from "../constants"
import { Row, Table } from "@budibase/types"
export const patchAPI = API => {
export const patchAPI = (API: APIClient) => {
/**
* Enriches rows which contain certain field types so that they can
* be properly displayed.
* The ability to create these bindings has been removed, but they will still
* exist in client apps to support backwards compatibility.
*/
const enrichRows = async (rows, tableId) => {
const enrichRows = async (rows: Row[], tableId: string) => {
if (!Array.isArray(rows)) {
return []
}
if (rows.length) {
const tables = {}
const tables: Record<string, Table> = {}
for (let row of rows) {
// Fall back to passed in tableId if row doesn't have it specified
let rowTableId = row.tableId || tableId
@ -54,7 +55,7 @@ export const patchAPI = API => {
const fetchSelf = API.fetchSelf
API.fetchSelf = async () => {
const user = await fetchSelf()
if (user && user._id) {
if (user && "_id" in user && user._id) {
if (user.roleId === "PUBLIC") {
// Don't try to enrich a public user as it will 403
return user
@ -66,10 +67,9 @@ export const patchAPI = API => {
}
}
const fetchRelationshipData = API.fetchRelationshipData
API.fetchRelationshipData = async params => {
const tableId = params?.tableId
const rows = await fetchRelationshipData(params)
return await enrichRows(rows, tableId)
API.fetchRelationshipData = async (sourceId, rowId, fieldName) => {
const rows = await fetchRelationshipData(sourceId, rowId, fieldName)
return await enrichRows(rows, sourceId)
}
const fetchTableData = API.fetchTableData
API.fetchTableData = async tableId => {
@ -85,19 +85,20 @@ export const patchAPI = API => {
}
}
const fetchViewData = API.fetchViewData
API.fetchViewData = async params => {
API.fetchViewData = async (viewName, params) => {
const tableId = params?.tableId
const rows = await fetchViewData(params)
const rows = await fetchViewData(viewName, params)
return await enrichRows(rows, tableId)
}
// Wipe any HBS formulae from table definitions, as these interfere with
// Wipe any HBS formulas from table definitions, as these interfere with
// handlebars enrichment
const fetchTableDefinition = API.fetchTableDefinition
API.fetchTableDefinition = async tableId => {
const definition = await fetchTableDefinition(tableId)
Object.keys(definition?.schema || {}).forEach(field => {
if (definition.schema[field]?.type === "formula") {
// @ts-expect-error TODO check what use case removing that would break
delete definition.schema[field].formula
}
})

View File

@ -1,7 +1,7 @@
<script>
import { getContext, onDestroy, onMount, setContext } from "svelte"
import { builderStore } from "stores/builder.js"
import { blockStore } from "stores/blocks.js"
import { blockStore } from "stores/blocks"
const component = getContext("component")
const { styleable } = getContext("sdk")

View File

@ -39,7 +39,7 @@
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
import { gridLayout } from "utils/grid.js"
import { gridLayout } from "utils/grid"
export let instance = {}
export let parent = null

View File

@ -1,22 +1,39 @@
<script>
<script lang="ts">
import { getContext } from "svelte"
import { Pagination, ProgressCircle } from "@budibase/bbui"
import { fetchData, QueryUtils } from "@budibase/frontend-core"
import { LogicalOperator, EmptyFilterOption } from "@budibase/types"
import {
LogicalOperator,
EmptyFilterOption,
TableSchema,
SortOrder,
SearchFilters,
UISearchFilter,
DataFetchDatasource,
UserDatasource,
GroupUserDatasource,
DataFetchOptions,
} from "@budibase/types"
import { SDK, Component } from "../../index"
export let dataSource
export let filter
export let sortColumn
export let sortOrder
export let limit
export let paginate
export let autoRefresh
type ProviderDatasource = Exclude<
DataFetchDatasource,
UserDatasource | GroupUserDatasource
>
const { styleable, Provider, ActionTypes, API } = getContext("sdk")
const component = getContext("component")
export let dataSource: ProviderDatasource
export let filter: UISearchFilter
export let sortColumn: string
export let sortOrder: SortOrder
export let limit: number
export let paginate: boolean
export let autoRefresh: number
let interval
let queryExtensions = {}
const { styleable, Provider, ActionTypes, API } = getContext<SDK>("sdk")
const component = getContext<Component>("component")
let interval: ReturnType<typeof setInterval>
let queryExtensions: Record<string, any> = {}
$: defaultQuery = QueryUtils.buildQuery(filter)
@ -49,8 +66,14 @@
},
{
type: ActionTypes.SetDataProviderSorting,
callback: ({ column, order }) => {
let newOptions = {}
callback: ({
column,
order,
}: {
column: string
order: SortOrder | undefined
}) => {
let newOptions: Partial<DataFetchOptions> = {}
if (column) {
newOptions.sortColumn = column
}
@ -63,6 +86,7 @@
},
},
]
$: dataContext = {
rows: $fetch.rows,
info: $fetch.info,
@ -75,14 +99,12 @@
id: $component?.id,
state: {
query: $fetch.query,
sortColumn: $fetch.sortColumn,
sortOrder: $fetch.sortOrder,
},
limit,
primaryDisplay: $fetch.definition?.primaryDisplay,
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
}
const createFetch = datasource => {
const createFetch = (datasource: ProviderDatasource) => {
return fetchData({
API,
datasource,
@ -96,7 +118,7 @@
})
}
const sanitizeSchema = schema => {
const sanitizeSchema = (schema: TableSchema | null) => {
if (!schema) {
return schema
}
@ -109,14 +131,14 @@
return cloned
}
const addQueryExtension = (key, extension) => {
const addQueryExtension = (key: string, extension: any) => {
if (!key || !extension) {
return
}
queryExtensions = { ...queryExtensions, [key]: extension }
}
const removeQueryExtension = key => {
const removeQueryExtension = (key: string) => {
if (!key) {
return
}
@ -125,11 +147,14 @@
queryExtensions = newQueryExtensions
}
const extendQuery = (defaultQuery, extensions) => {
const extendQuery = (
defaultQuery: SearchFilters,
extensions: Record<string, any>
): SearchFilters => {
if (!Object.keys(extensions).length) {
return defaultQuery
}
const extended = {
const extended: SearchFilters = {
[LogicalOperator.AND]: {
conditions: [
...(defaultQuery ? [defaultQuery] : []),
@ -140,12 +165,12 @@
}
// If there are no conditions applied at all, clear the request.
return extended[LogicalOperator.AND]?.conditions?.length > 0
return (extended[LogicalOperator.AND]?.conditions?.length ?? 0) > 0
? extended
: null
: {}
}
const setUpAutoRefresh = autoRefresh => {
const setUpAutoRefresh = (autoRefresh: number) => {
clearInterval(interval)
if (autoRefresh) {
interval = setInterval(fetch.refresh, Math.max(10000, autoRefresh * 1000))

View File

@ -3,7 +3,7 @@
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { enrichSearchColumns, enrichFilter } from "utils/blocks"
import { get } from "svelte/store"
export let title

View File

@ -5,7 +5,7 @@
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { enrichSearchColumns, enrichFilter } from "utils/blocks"
import { Utils } from "@budibase/frontend-core"
export let title

View File

@ -2,6 +2,8 @@
import Field from "./Field.svelte"
import { CoreDropzone, ProgressCircle, Helpers } from "@budibase/bbui"
import { getContext, onMount, onDestroy } from "svelte"
import { builderStore } from "stores/builder.js"
import { processStringSync } from "@budibase/string-templates"
export let datasourceId
export let bucket
@ -12,6 +14,8 @@
export let validation
export let onChange
const context = getContext("context")
let fieldState
let fieldApi
let localFiles = []
@ -42,6 +46,9 @@
// Process the file input and return a serializable structure expected by
// the dropzone component to display the file
const processFiles = async fileList => {
if ($builderStore.inBuilder) {
return []
}
return await new Promise(resolve => {
if (!fileList?.length) {
return []
@ -78,9 +85,15 @@
}
const upload = async () => {
const processedFileKey = processStringSync(key, $context)
loading = true
try {
const res = await API.externalUpload(datasourceId, bucket, key, data)
const res = await API.externalUpload(
datasourceId,
bucket,
processedFileKey,
data
)
notificationStore.actions.success("File uploaded successfully")
loading = false
return res
@ -126,7 +139,7 @@
bind:fieldApi
defaultValue={[]}
>
<div class="content">
<div class="content" class:builder={$builderStore.inBuilder}>
{#if fieldState}
<CoreDropzone
value={localFiles}
@ -149,6 +162,9 @@
</Field>
<style>
.content.builder :global(.spectrum-Dropzone) {
pointer-events: none;
}
.content {
position: relative;
}

5
packages/client/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
interface Window {
"##BUDIBASE_APP_ID##": string
"##BUDIBASE_IN_BUILDER##": string
MIGRATING_APP: boolean
}

View File

@ -0,0 +1,15 @@
import { APIClient } from "@budibase/frontend-core"
import type { ActionTypes } from "./constants"
import { Readable } from "svelte/store"
export interface SDK {
API: APIClient
styleable: any
Provider: any
ActionTypes: typeof ActionTypes
}
export type Component = Readable<{
id: string
styles: any
}>

View File

@ -29,7 +29,7 @@ import { ActionTypes } from "./constants"
import {
fetchDatasourceSchema,
fetchDatasourceDefinition,
} from "./utils/schema.js"
} from "./utils/schema"
import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js"
import { processStringSync, makePropSafe } from "@budibase/string-templates"
@ -74,6 +74,7 @@ export default {
fetchData,
QueryUtils,
ContextScopes: Constants.ContextScopes,
// This is not used internally but exposed to users to be used in plugins
getAPIKey,
enrichButtonActions,
processStringSync,

View File

@ -2,7 +2,9 @@ import { API } from "api"
import { writable } from "svelte/store"
const createAuthStore = () => {
const store = writable(null)
const store = writable<{
csrfToken?: string
} | null>(null)
// Fetches the user object if someone is logged in and has reloaded the page
const fetchUser = async () => {

View File

@ -6,7 +6,7 @@ import { screenStore } from "./screens"
import { builderStore } from "./builder"
import Router from "../components/Router.svelte"
import * as AppComponents from "../components/app/index.js"
import { ScreenslotType } from "../constants.js"
import { ScreenslotType } from "../constants"
export const BudibasePrefix = "@budibase/standard-components/"

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