Merge branch 'develop' of github.com:Budibase/budibase into chore/esbuild
This commit is contained in:
commit
f3785892a9
|
@ -79,6 +79,7 @@ jobs:
|
|||
node-version: 14.x
|
||||
- run: yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn build --scope=@budibase/types --scope=@budibase/shared-core
|
||||
- run: yarn test --scope=@budibase/pro
|
||||
|
||||
integration-test:
|
||||
|
|
|
@ -6,11 +6,6 @@ on:
|
|||
tags:
|
||||
- v*-alpha.*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Release tag"
|
||||
required: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
# Posthog token used by ui at build time
|
||||
|
@ -29,7 +24,7 @@ jobs:
|
|||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fail if tag is not develop
|
||||
run: |
|
||||
|
@ -52,9 +47,10 @@ jobs:
|
|||
| sed 's/[",]//g')
|
||||
echo "Setting version $version"
|
||||
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
|
||||
echo "Updating dependencies"
|
||||
node scripts/syncLocalDependencies.js $version
|
||||
echo "Syncing yarn workspace"
|
||||
yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn build
|
||||
- run: yarn build:sdk
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ concurrency: release
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
# Exclude all pre-releases
|
||||
- '!v*[0-9]+.[0-9]+.[0-9]+-*'
|
||||
- "!v*[0-9]+.[0-9]+.[0-9]+-*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
|
@ -29,7 +29,7 @@ jobs:
|
|||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fail if branch is not master
|
||||
if: github.ref != 'refs/heads/master'
|
||||
|
@ -52,9 +52,10 @@ jobs:
|
|||
| sed 's/[",]//g')
|
||||
echo "Setting version $version"
|
||||
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
|
||||
echo "Updating dependencies"
|
||||
node scripts/syncLocalDependencies.js $version
|
||||
echo "Syncing yarn workspace"
|
||||
yarn
|
||||
- run: yarn bootstrap
|
||||
- run: yarn lint
|
||||
- run: yarn build
|
||||
- run: yarn build:sdk
|
||||
|
|
|
@ -30,9 +30,10 @@ jobs:
|
|||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- run: yarn
|
||||
- name: Tag prerelease
|
||||
run: |
|
||||
# setup the username and email.
|
||||
git config --global user.name "Budibase Staging Release Bot"
|
||||
git config --global user.email "<>"
|
||||
./scripts/versionCommit.sh alpha
|
||||
./scripts/versionCommit.sh prerelease
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: Tag prerelease
|
||||
name: Tag release
|
||||
concurrency: release-prerelease
|
||||
|
||||
on:
|
||||
|
@ -40,6 +40,7 @@ jobs:
|
|||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- run: yarn
|
||||
- name: Tag prerelease
|
||||
run: |
|
||||
# setup the username and email.
|
||||
|
|
|
@ -5,8 +5,11 @@ ENV COUCHDB_PASSWORD admin
|
|||
EXPOSE 5984
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common wget unzip curl && \
|
||||
apt-add-repository 'deb http://security.debian.org/debian-security stretch/updates main' && \
|
||||
apt-get update && apt-get install -y --no-install-recommends openjdk-8-jre && \
|
||||
wget -qO - https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public | apt-key add - && \
|
||||
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
|
||||
apt-add-repository 'deb http://archive.debian.org/debian stretch-backports main' && \
|
||||
apt-add-repository --yes https://adoptopenjdk.jfrog.io/adoptopenjdk/deb/ && \
|
||||
apt-get update && apt-get install -y --no-install-recommends adoptopenjdk-8-hotspot && \
|
||||
rm -rf /var/lib/apt/lists/
|
||||
|
||||
# setup clouseau
|
||||
|
|
|
@ -55,7 +55,7 @@ http {
|
|||
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
|
||||
set $csp_object "object-src 'none'";
|
||||
set $csp_base_uri "base-uri 'self'";
|
||||
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
|
||||
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
|
||||
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
|
||||
set $csp_frame "frame-src 'self' https:";
|
||||
set $csp_img "img-src http: https: data: blob:";
|
||||
|
@ -82,6 +82,12 @@ http {
|
|||
set $couchdb ${COUCHDB_UPSTREAM_URL};
|
||||
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
add_header 'Content-Type' 'application/json';
|
||||
return 200 '{ "status": "OK" }';
|
||||
}
|
||||
|
||||
location /app {
|
||||
proxy_pass $apps;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ FROM budibase/couchdb
|
|||
ARG TARGETARCH
|
||||
ENV TARGETARCH $TARGETARCH
|
||||
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)
|
||||
# e.g. docker build --build-arg TARGETBUILD=aas ....
|
||||
# e.g. docker build --build-arg TARGETBUILD=aas ....
|
||||
ARG TARGETBUILD=single
|
||||
ENV TARGETBUILD $TARGETBUILD
|
||||
|
||||
|
@ -32,7 +32,7 @@ COPY --from=build /worker /worker
|
|||
# install base dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server && \
|
||||
apt-add-repository 'deb http://security.debian.org/debian-security stretch/updates main' && \
|
||||
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
|
||||
apt-get update
|
||||
|
||||
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.5.10-alpha.0",
|
||||
"version": "2.6.16-alpha.4",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/backend-core",
|
||||
|
@ -31,4 +31,4 @@
|
|||
"loadEnvFiles": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@
|
|||
"prettier-plugin-svelte": "^2.3.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup-plugin-replace": "^2.2.0",
|
||||
"semver": "^7.5.0",
|
||||
"svelte": "^3.38.2",
|
||||
"typescript": "4.7.3"
|
||||
},
|
||||
|
|
|
@ -21,7 +21,7 @@ export enum ViewName {
|
|||
AUTOMATION_LOGS = "automation_logs",
|
||||
ACCOUNT_BY_EMAIL = "account_by_email",
|
||||
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
|
||||
USER_BY_GROUP = "by_group_user",
|
||||
USER_BY_GROUP = "user_by_group",
|
||||
APP_BACKUP_BY_TRIGGER = "by_trigger",
|
||||
}
|
||||
|
||||
|
|
|
@ -69,10 +69,10 @@ function findVersion() {
|
|||
try {
|
||||
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
|
||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||
const version = JSON.parse(content).version
|
||||
return version
|
||||
return JSON.parse(content).version
|
||||
} catch {
|
||||
throw new Error("Cannot find a valid version in its package.json")
|
||||
// throwing an error here is confusing/causes backend-core to be hard to import
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,7 +95,8 @@ const environment = {
|
|||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
||||
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD || "budibase",
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
|
||||
MOCK_REDIS: process.env.MOCK_REDIS,
|
||||
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
||||
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
|
||||
|
|
|
@ -40,6 +40,12 @@ function logging(queue: Queue, jobQueue: JobQueue) {
|
|||
case JobQueue.APP_BACKUP:
|
||||
eventType = "app-backup-event"
|
||||
break
|
||||
case JobQueue.AUDIT_LOG:
|
||||
eventType = "audit-log-event"
|
||||
break
|
||||
case JobQueue.SYSTEM_EVENT_QUEUE:
|
||||
eventType = "system-event"
|
||||
break
|
||||
}
|
||||
if (process.env.NODE_DEBUG?.includes("bull")) {
|
||||
queue
|
||||
|
|
|
@ -12,7 +12,7 @@ import * as timers from "../timers"
|
|||
|
||||
const RETRY_PERIOD_MS = 2000
|
||||
const STARTUP_TIMEOUT_MS = 5000
|
||||
const CLUSTERED = false
|
||||
const CLUSTERED = env.REDIS_CLUSTERED
|
||||
const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
|
||||
|
||||
// for testing just generate the client once
|
||||
|
@ -81,7 +81,7 @@ function init(selectDb = DEFAULT_SELECT_DB) {
|
|||
if (client) {
|
||||
client.disconnect()
|
||||
}
|
||||
const { redisProtocolUrl, opts, host, port } = getRedisOptions(CLUSTERED)
|
||||
const { redisProtocolUrl, opts, host, port } = getRedisOptions()
|
||||
|
||||
if (CLUSTERED) {
|
||||
client = new Redis.Cluster([{ host, port }], opts)
|
||||
|
|
|
@ -85,7 +85,7 @@ export const doWithLock = async <T>(
|
|||
opts: LockOptions,
|
||||
task: () => Promise<T>
|
||||
): Promise<RedlockExecution<T>> => {
|
||||
const redlock = await getClient(opts.type)
|
||||
const redlock = await getClient(opts.type, opts.customOptions)
|
||||
let lock
|
||||
try {
|
||||
// determine lock name
|
||||
|
|
|
@ -57,7 +57,7 @@ export enum SelectableDatabase {
|
|||
UNUSED_14 = 15,
|
||||
}
|
||||
|
||||
export function getRedisOptions(clustered = false) {
|
||||
export function getRedisOptions() {
|
||||
let password = env.REDIS_PASSWORD
|
||||
let url: string[] | string = env.REDIS_URL.split("//")
|
||||
// get rid of the protocol
|
||||
|
@ -83,7 +83,7 @@ export function getRedisOptions(clustered = false) {
|
|||
const opts: any = {
|
||||
connectTimeout: CONNECT_TIMEOUT_MS,
|
||||
}
|
||||
if (clustered) {
|
||||
if (env.REDIS_CLUSTERED) {
|
||||
opts.redisOptions = {}
|
||||
opts.redisOptions.tls = {}
|
||||
opts.redisOptions.password = password
|
||||
|
|
|
@ -5,6 +5,7 @@ import * as db from "../../db"
|
|||
import { Header } from "../../constants"
|
||||
import { newid } from "../../utils"
|
||||
import env from "../../environment"
|
||||
import { BBContext } from "@budibase/types"
|
||||
|
||||
describe("utils", () => {
|
||||
const config = new DBTestConfiguration()
|
||||
|
@ -106,4 +107,85 @@ describe("utils", () => {
|
|||
expect(actual).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isServingBuilder", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isServingBuilder(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path is in builder", async () => {
|
||||
ctx.path = "/builder/app/app_"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path doesn't have '/' suffix", async () => {
|
||||
ctx.path = "/builder/app"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isServingBuilderPreview", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isServingBuilderPreview(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path is in builder preview", async () => {
|
||||
ctx.path = "/app/preview/xx"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path is not in builder preview", async () => {
|
||||
ctx.path = "/builder"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPublicAPIRequest", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isPublicApiRequest(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path remains to public API", async () => {
|
||||
ctx.path = "/api/public/v1/invoices"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v1"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v2"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v21"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path doesn't remain to public API", async () => {
|
||||
ctx.path = "/api/public"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import { getAllApps, queryGlobalView } from "../db"
|
||||
import {
|
||||
Header,
|
||||
MAX_VALID_DATE,
|
||||
DocumentType,
|
||||
SEPARATOR,
|
||||
ViewName,
|
||||
} from "../constants"
|
||||
import { getAllApps } from "../db"
|
||||
import { Header, MAX_VALID_DATE, DocumentType, SEPARATOR } from "../constants"
|
||||
import env from "../environment"
|
||||
import * as tenancy from "../tenancy"
|
||||
import * as context from "../context"
|
||||
|
@ -23,7 +17,9 @@ const APP_PREFIX = DocumentType.APP + SEPARATOR
|
|||
const PROD_APP_PREFIX = "/app/"
|
||||
|
||||
const BUILDER_PREVIEW_PATH = "/app/preview"
|
||||
const BUILDER_REFERER_PREFIX = "/builder/app/"
|
||||
const BUILDER_PREFIX = "/builder"
|
||||
const BUILDER_APP_PREFIX = `${BUILDER_PREFIX}/app/`
|
||||
const PUBLIC_API_PREFIX = "/api/public/v"
|
||||
|
||||
function confirmAppId(possibleAppId: string | undefined) {
|
||||
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
|
||||
|
@ -69,6 +65,18 @@ export function isServingApp(ctx: Ctx) {
|
|||
return false
|
||||
}
|
||||
|
||||
export function isServingBuilder(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(BUILDER_APP_PREFIX)
|
||||
}
|
||||
|
||||
export function isServingBuilderPreview(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(BUILDER_PREVIEW_PATH)
|
||||
}
|
||||
|
||||
export function isPublicApiRequest(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(PUBLIC_API_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request tries to find the appId, which can be located in various places
|
||||
* @param {object} ctx The main request body to look through.
|
||||
|
@ -110,7 +118,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
|
|||
// make sure this is performed after prod app url resolution, in case the
|
||||
// referer header is present from a builder redirect
|
||||
const referer = ctx.request.headers.referer
|
||||
if (!appId && referer?.includes(BUILDER_REFERER_PREFIX)) {
|
||||
if (!appId && referer?.includes(BUILDER_APP_PREFIX)) {
|
||||
const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
|
||||
appId = confirmAppId(refererId)
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
"@spectrum-css/vars": "3.0.1",
|
||||
"dayjs": "^1.10.4",
|
||||
"easymde": "^2.16.1",
|
||||
"svelte-flatpickr": "^3.3.2",
|
||||
"svelte-flatpickr": "3.2.3",
|
||||
"svelte-portal": "^1.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
const ignoredClasses = [".flatpickr-calendar", ".spectrum-Popover"]
|
||||
const ignoredClasses = [
|
||||
".flatpickr-calendar",
|
||||
".spectrum-Popover",
|
||||
".download-js-link",
|
||||
]
|
||||
let clickHandlers = []
|
||||
|
||||
/**
|
||||
|
@ -22,8 +26,8 @@ const handleClick = event => {
|
|||
}
|
||||
|
||||
// Ignore clicks for modals, unless the handler is registered from a modal
|
||||
const sourceInModal = handler.anchor.closest(".spectrum-Modal") != null
|
||||
const clickInModal = event.target.closest(".spectrum-Modal") != null
|
||||
const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null
|
||||
const clickInModal = event.target.closest(".spectrum-Underlay") != null
|
||||
if (clickInModal && !sourceInModal) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import "@spectrum-css/button/dist/index-vars.css"
|
||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||
|
||||
export let type
|
||||
export let disabled = false
|
||||
export let size = "M"
|
||||
export let cta = false
|
||||
|
@ -21,6 +22,7 @@
|
|||
|
||||
<button
|
||||
{id}
|
||||
{type}
|
||||
class:spectrum-Button--cta={cta}
|
||||
class:spectrum-Button--primary={primary}
|
||||
class:spectrum-Button--secondary={secondary}
|
||||
|
@ -73,6 +75,7 @@
|
|||
button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spectrum-Button-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<script>
|
||||
import { slide } from "svelte/transition"
|
||||
|
||||
export let error = null
|
||||
</script>
|
||||
|
||||
<div transition:slide|local={{ duration: 130 }} class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-message {
|
||||
background: var(--spectrum-global-color-red-400);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { slide } from "svelte/transition"
|
||||
import ErrorMessage from "./ErrorMessage.svelte"
|
||||
|
||||
export let disabled = false
|
||||
export let error = null
|
||||
|
@ -55,9 +55,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if error}
|
||||
<div transition:slide|local={{ duration: 130 }} class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
<ErrorMessage {error} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
@ -110,13 +108,6 @@
|
|||
.field {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.error-message {
|
||||
background: var(--spectrum-global-color-red-400);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.error-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
|
|
@ -4,3 +4,4 @@ export { default as FancySelect } from "./FancySelect.svelte"
|
|||
export { default as FancyButton } from "./FancyButton.svelte"
|
||||
export { default as FancyForm } from "./FancyForm.svelte"
|
||||
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
|
||||
export { default as ErrorMessage } from "./ErrorMessage.svelte"
|
||||
|
|
|
@ -18,10 +18,14 @@
|
|||
export let ignoreTimezones = false
|
||||
export let time24hr = false
|
||||
export let range = false
|
||||
export let flatpickr
|
||||
export let useKeyboardShortcuts = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const flatpickrId = `${uuid()}-wrapper`
|
||||
|
||||
let open = false
|
||||
let flatpickr, flatpickrOptions
|
||||
let flatpickrOptions
|
||||
|
||||
// Another classic flatpickr issue. Errors were randomly being thrown due to
|
||||
// flatpickr internal code. Making sure that "destroy" is a valid function
|
||||
|
@ -59,6 +63,8 @@
|
|||
dispatch("change", timestamp.toISOString())
|
||||
}
|
||||
},
|
||||
onOpen: () => dispatch("open"),
|
||||
onClose: () => dispatch("close"),
|
||||
}
|
||||
|
||||
$: redrawOptions = {
|
||||
|
@ -113,12 +119,16 @@
|
|||
|
||||
const onOpen = () => {
|
||||
open = true
|
||||
document.addEventListener("keyup", clearDateOnBackspace)
|
||||
if (useKeyboardShortcuts) {
|
||||
document.addEventListener("keyup", clearDateOnBackspace)
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
open = false
|
||||
document.removeEventListener("keyup", clearDateOnBackspace)
|
||||
if (useKeyboardShortcuts) {
|
||||
document.removeEventListener("keyup", clearDateOnBackspace)
|
||||
}
|
||||
|
||||
// Manually blur all input fields since flatpickr creates a second
|
||||
// duplicate input field.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 26 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <g id="Make-App-Icon-Circle" transform="translate(3757 -1767)"> <circle id="Ellipse_10" data-name="Ellipse 10" cx="256" cy="256" r="256" transform="translate(-3757 1767)" fill="#6d00cc"/> <path id="Path_141560" data-name="Path 141560" d="M244.78,14.544a7.187,7.187,0,0,0-7.186,7.192V213.927a7.19,7.19,0,0,0,7.186,7.192h52.063a7.187,7.187,0,0,0,7.186-7.192V21.736a7.183,7.183,0,0,0-7.186-7.192ZM92.066,17.083,5.77,188.795a7.191,7.191,0,0,0,3.192,9.654l46.514,23.379a7.184,7.184,0,0,0,9.654-3.2l86.3-171.711a7.184,7.184,0,0,0-3.2-9.654L101.719,13.886a7.2,7.2,0,0,0-9.654,3.2m72.592.614L127.731,204.876a7.189,7.189,0,0,0,5.632,8.442l51.028,10.306a7.2,7.2,0,0,0,8.481-5.665L229.8,30.786a7.19,7.19,0,0,0-5.637-8.442L173.133,12.038a7.391,7.391,0,0,0-1.427-.144,7.194,7.194,0,0,0-7.048,5.8" transform="translate(-3676.356 1905.425)" fill="#fff"/> </g> </svg>
|
After Width: | Height: | Size: 951 B |
|
@ -62,6 +62,7 @@
|
|||
"@budibase/frontend-core": "0.0.1",
|
||||
"@budibase/shared-core": "0.0.1",
|
||||
"@budibase/string-templates": "0.0.1",
|
||||
"@budibase/types": "0.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
|
|
|
@ -147,6 +147,9 @@ const automationActions = store => ({
|
|||
testData,
|
||||
})
|
||||
if (!result?.trigger && !result?.steps?.length) {
|
||||
if (result?.err?.code === "usage_limit_exceeded") {
|
||||
throw "You have exceeded your automation quota"
|
||||
}
|
||||
throw "Something went wrong testing your automation"
|
||||
}
|
||||
store.update(state => {
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
/>
|
||||
<span class="icon-spacing">
|
||||
<Body size="XS">
|
||||
{idx.charAt(0).toUpperCase() + idx.slice(1)}
|
||||
{action.stepTitle || idx.charAt(0).toUpperCase() + idx.slice(1)}
|
||||
</Body>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import DiscordLogo from "assets/discord.svg"
|
||||
import ZapierLogo from "assets/zapier.png"
|
||||
import IntegromatLogo from "assets/integromat.png"
|
||||
import MakeLogo from "assets/make.svg"
|
||||
import SlackLogo from "assets/slack.svg"
|
||||
|
||||
export const externalActions = {
|
||||
zapier: { name: "zapier", icon: ZapierLogo },
|
||||
discord: { name: "discord", icon: DiscordLogo },
|
||||
slack: { name: "slack", icon: SlackLogo },
|
||||
integromat: { name: "integromat", icon: IntegromatLogo },
|
||||
integromat: { name: "integromat", icon: MakeLogo },
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
await automationStore.actions.test($selectedAutomation, testData)
|
||||
$automationStore.showTestPanel = true
|
||||
} catch (error) {
|
||||
notifications.error("Error testing automation")
|
||||
notifications.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -61,11 +61,63 @@
|
|||
$: isTrigger = block?.type === "TRIGGER"
|
||||
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
|
||||
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
let deprecatedSchemaProperties
|
||||
$: {
|
||||
if (block?.stepId === "integromat" || block?.stepId === "zapier") {
|
||||
deprecatedSchemaProperties = schemaProperties.filter(
|
||||
prop => !prop[0].startsWith("value")
|
||||
)
|
||||
if (!deprecatedSchemaProperties.map(entry => entry[0]).includes("body")) {
|
||||
deprecatedSchemaProperties.push([
|
||||
"body",
|
||||
{
|
||||
title: "Payload",
|
||||
type: "json",
|
||||
},
|
||||
])
|
||||
}
|
||||
} else {
|
||||
deprecatedSchemaProperties = schemaProperties
|
||||
}
|
||||
}
|
||||
/****************************************************/
|
||||
|
||||
const getInputData = (testData, blockInputs) => {
|
||||
let newInputData = testData || blockInputs
|
||||
if (block.event === "app:trigger" && !newInputData?.fields) {
|
||||
newInputData = cloneDeep(blockInputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
if (
|
||||
(block?.stepId === "integromat" || block?.stepId === "zapier") &&
|
||||
!newInputData?.body?.value
|
||||
) {
|
||||
let deprecatedValues = {
|
||||
...newInputData,
|
||||
}
|
||||
delete deprecatedValues.url
|
||||
delete deprecatedValues.body
|
||||
newInputData = {
|
||||
url: newInputData.url,
|
||||
body: {
|
||||
value: JSON.stringify(deprecatedValues),
|
||||
},
|
||||
}
|
||||
}
|
||||
/**********************************/
|
||||
|
||||
inputData = newInputData
|
||||
setDefaultEnumValues()
|
||||
}
|
||||
|
@ -239,7 +291,7 @@
|
|||
</script>
|
||||
|
||||
<div class="fields">
|
||||
{#each schemaProperties as [key, value]}
|
||||
{#each deprecatedSchemaProperties as [key, value]}
|
||||
<div class="block-field">
|
||||
{#if key !== "fields"}
|
||||
<Label
|
||||
|
@ -256,6 +308,28 @@
|
|||
options={value.enum}
|
||||
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
|
||||
/>
|
||||
{:else if value.type === "json"}
|
||||
<Editor
|
||||
editorHeight="250"
|
||||
editorWidth="448"
|
||||
mode="json"
|
||||
value={inputData[key]?.value}
|
||||
on:change={e => {
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
delete inputData.value1
|
||||
delete inputData.value2
|
||||
delete inputData.value3
|
||||
delete inputData.value4
|
||||
delete inputData.value5
|
||||
/***********************/
|
||||
onChange(e, key)
|
||||
}}
|
||||
/>
|
||||
{:else if value.customType === "column"}
|
||||
<Select
|
||||
on:change={e => onChange(e, key)}
|
||||
|
|
|
@ -22,6 +22,8 @@
|
|||
export let rowCount
|
||||
export let disableSorting = false
|
||||
export let customPlaceholder = false
|
||||
export let allowClickRows
|
||||
export let allowEditing = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -109,7 +111,9 @@
|
|||
{rowCount}
|
||||
{disableSorting}
|
||||
{customPlaceholder}
|
||||
allowEditRows={allowEditing}
|
||||
showAutoColumns={!hideAutocolumns}
|
||||
{allowClickRows}
|
||||
on:clickrelationship={e => selectRelationship(e.detail)}
|
||||
on:sort
|
||||
>
|
||||
|
|
|
@ -58,6 +58,7 @@
|
|||
{loading}
|
||||
{type}
|
||||
rowCount={10}
|
||||
allowEditing={false}
|
||||
bind:hideAutocolumns
|
||||
>
|
||||
<ViewFilterButton {view} />
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
{#if datasource}
|
||||
<div>
|
||||
<ActionButton icon="DataCorrelated" primary quiet on:click={modal.show}>
|
||||
Define existing relationship
|
||||
Define relationship
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -9,13 +9,21 @@
|
|||
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
|
||||
</script>
|
||||
|
||||
<ExportButton
|
||||
{disabled}
|
||||
view={$tableId}
|
||||
filters={$filter}
|
||||
sorting={{
|
||||
sortColumn: $sort.column,
|
||||
sortOrder: $sort.order,
|
||||
}}
|
||||
selectedRows={selectedRowArray}
|
||||
/>
|
||||
<span data-ignore-click-outside="true">
|
||||
<ExportButton
|
||||
{disabled}
|
||||
view={$tableId}
|
||||
filters={$filter}
|
||||
sorting={{
|
||||
sortColumn: $sort.column,
|
||||
sortOrder: $sort.order,
|
||||
}}
|
||||
selectedRows={selectedRowArray}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,19 +2,19 @@
|
|||
import TableFilterButton from "../TableFilterButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { columns, config, filter, table } = getContext("grid")
|
||||
const { columns, tableId, filter, table } = getContext("grid")
|
||||
|
||||
const onFilter = e => {
|
||||
filter.set(e.detail || [])
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key $config.tableId}
|
||||
{#key $tableId}
|
||||
<TableFilterButton
|
||||
schema={$table?.schema}
|
||||
filters={$filter}
|
||||
on:change={onFilter}
|
||||
disabled={!$columns.length}
|
||||
tableId={$config.tableId}
|
||||
tableId={$tableId}
|
||||
/>
|
||||
{/key}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import ManageAccessButton from "../ManageAccessButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { config } = getContext("grid")
|
||||
const { tableId } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<ManageAccessButton resourceId={$config.tableId} />
|
||||
<ManageAccessButton resourceId={$tableId} />
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
import NavItem from "components/common/NavItem.svelte"
|
||||
import { goto, isActive } from "@roxi/routify"
|
||||
|
||||
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
|
||||
const alphabetical = (a, b) =>
|
||||
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
|
||||
|
||||
export let sourceId
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
export let highlighted = false
|
||||
export let rightAlignIcon = false
|
||||
export let id
|
||||
export let showTooltip = false
|
||||
|
||||
const scrollApi = getContext("scroll")
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -84,7 +85,7 @@
|
|||
<Icon color={iconColor} size="S" name={icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text">{text}</div>
|
||||
<div class="text" title={showTooltip ? text : null}>{text}</div>
|
||||
{#if withActions}
|
||||
<div class="actions">
|
||||
<slot />
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
export let tab = true
|
||||
export let mode
|
||||
export let editorHeight = 500
|
||||
export let editorWidth = 640
|
||||
// export let parameters = []
|
||||
|
||||
let width
|
||||
|
@ -169,7 +170,9 @@
|
|||
{#if label}
|
||||
<Label small>{label}</Label>
|
||||
{/if}
|
||||
<div style={`--code-mirror-height: ${editorHeight}px`}>
|
||||
<div
|
||||
style={`--code-mirror-height: ${editorHeight}px; --code-mirror-width: ${editorWidth}px;`}
|
||||
>
|
||||
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
|
||||
</div>
|
||||
|
||||
|
@ -183,6 +186,7 @@
|
|||
}
|
||||
|
||||
div :global(.CodeMirror) {
|
||||
width: var(--code-mirror-width) !important;
|
||||
height: var(--code-mirror-height) !important;
|
||||
border-radius: var(--border-radius-s);
|
||||
font-family: var(--font-mono);
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<script>
|
||||
import { Modal, ModalContent, Body } from "@budibase/bbui"
|
||||
|
||||
let modal
|
||||
|
||||
export let onConfirm
|
||||
|
||||
export function show() {
|
||||
modal.show()
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
modal.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} on:hide={modal}>
|
||||
<ModalContent
|
||||
title="Your account is currently de-activated"
|
||||
size="S"
|
||||
showCancelButton={true}
|
||||
showCloseIcon={false}
|
||||
confirmText={"View plans"}
|
||||
{onConfirm}
|
||||
>
|
||||
<Body size="S"
|
||||
>Due to the free plan user limit being exceeded, your account has been
|
||||
de-activated. Upgrade your plan to re-activate your account.</Body
|
||||
>
|
||||
</ModalContent>
|
||||
</Modal>
|
|
@ -3,7 +3,6 @@ import { temporalStore } from "builderStore"
|
|||
import { admin, auth, licensing } from "stores/portal"
|
||||
import { get } from "svelte/store"
|
||||
import { BANNER_TYPES } from "@budibase/bbui"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
const oneDayInSeconds = 86400
|
||||
|
||||
|
@ -146,20 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
|
|||
const userLicensing = get(licensing)
|
||||
return {
|
||||
key: EXPIRY_KEY,
|
||||
type: BANNER_TYPES.WARNING,
|
||||
criteria: () => {
|
||||
return userLicensing.warnUserLimit
|
||||
type: BANNER_TYPES.NEGATIVE,
|
||||
onChange: () => {
|
||||
defaultCacheFn(EXPIRY_KEY)
|
||||
},
|
||||
message: `${capitalise(
|
||||
userLicensing.license.plan.type
|
||||
)} plan changes - Users will be limited to ${
|
||||
userLicensing.userLimit
|
||||
} users in ${userLicensing.userLimitDays}`,
|
||||
criteria: () => {
|
||||
return userLicensing.errUserLimit
|
||||
},
|
||||
message: "Your Budibase account is de-activated. Upgrade your plan",
|
||||
...{
|
||||
extraButtonText: "Find out more",
|
||||
extraButtonText: "View plans",
|
||||
extraButtonAction: () => {
|
||||
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
|
||||
window.location.href = "/builder/portal/users/users"
|
||||
window.location.href = "https://budibase.com/pricing/"
|
||||
},
|
||||
},
|
||||
showCloseButton: true,
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
export let app
|
||||
|
||||
export let lockedAction
|
||||
|
||||
const handleDefaultClick = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
goToOverview()
|
||||
|
@ -29,7 +31,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="app-row" on:click={handleDefaultClick}>
|
||||
<div class="app-row" on:click={lockedAction || handleDefaultClick}>
|
||||
<div class="title">
|
||||
<div class="app-icon">
|
||||
<Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} />
|
||||
|
@ -58,8 +60,11 @@
|
|||
|
||||
<div class="app-row-actions">
|
||||
<AppLockModal {app} buttonSize="M" />
|
||||
<Button size="S" secondary on:click={goToOverview}>Manage</Button>
|
||||
<Button size="S" primary on:click={goToBuilder}>Edit</Button>
|
||||
<Button size="S" secondary on:click={lockedAction || goToOverview}
|
||||
>Manage</Button
|
||||
>
|
||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,13 +28,16 @@
|
|||
let inviting = false
|
||||
let searchFocus = false
|
||||
|
||||
// Initially filter entities without app access
|
||||
// Show all when false
|
||||
let filterByAppAccess = true
|
||||
|
||||
let appInvites = []
|
||||
let filteredInvites = []
|
||||
let filteredUsers = []
|
||||
let filteredGroups = []
|
||||
let selectedGroup
|
||||
let userOnboardResponse = null
|
||||
|
||||
let userLimitReachedModal
|
||||
|
||||
$: queryIsEmail = emailValidator(query) === true
|
||||
|
@ -52,15 +55,32 @@
|
|||
}
|
||||
|
||||
const filterInvites = async query => {
|
||||
appInvites = await getInvites()
|
||||
if (!query || query == "") {
|
||||
filteredInvites = appInvites
|
||||
if (!prodAppId) {
|
||||
return
|
||||
}
|
||||
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
|
||||
|
||||
appInvites = await getInvites()
|
||||
|
||||
//On Focus behaviour
|
||||
if (!filterByAppAccess && !query) {
|
||||
filteredInvites =
|
||||
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
|
||||
return
|
||||
}
|
||||
|
||||
filteredInvites = appInvites.filter(invite => {
|
||||
const inviteInfo = invite.info?.apps
|
||||
if (!query && inviteInfo && prodAppId) {
|
||||
return Object.keys(inviteInfo).includes(prodAppId)
|
||||
}
|
||||
return invite.email.includes(query)
|
||||
})
|
||||
}
|
||||
|
||||
$: filterInvites(query)
|
||||
$: filterByAppAccess, prodAppId, filterInvites(query)
|
||||
$: if (searchFocus === true) {
|
||||
filterByAppAccess = false
|
||||
}
|
||||
|
||||
const usersFetch = fetchData({
|
||||
API,
|
||||
|
@ -79,9 +99,9 @@
|
|||
}
|
||||
await usersFetch.update({
|
||||
query: {
|
||||
appId: query ? null : prodAppId,
|
||||
appId: query || !filterByAppAccess ? null : prodAppId,
|
||||
email: query,
|
||||
paginated: query ? null : false,
|
||||
paginated: query || !filterByAppAccess ? null : false,
|
||||
},
|
||||
})
|
||||
await usersFetch.refresh()
|
||||
|
@ -107,7 +127,12 @@
|
|||
}
|
||||
|
||||
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
||||
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
|
||||
$: debouncedUpdateFetch(
|
||||
query,
|
||||
$store.builderSidePanel,
|
||||
loaded,
|
||||
filterByAppAccess
|
||||
)
|
||||
|
||||
const updateAppUser = async (user, role) => {
|
||||
if (!prodAppId) {
|
||||
|
@ -182,9 +207,10 @@
|
|||
}
|
||||
|
||||
const searchGroups = (userGroups, query) => {
|
||||
let filterGroups = query?.length
|
||||
? userGroups
|
||||
: getAppGroups(userGroups, prodAppId)
|
||||
let filterGroups =
|
||||
query?.length || !filterByAppAccess
|
||||
? userGroups
|
||||
: getAppGroups(userGroups, prodAppId)
|
||||
return filterGroups
|
||||
.filter(group => {
|
||||
if (!query?.length) {
|
||||
|
@ -214,7 +240,7 @@
|
|||
}
|
||||
|
||||
// Adds the 'role' attribute and sets it to the current app.
|
||||
$: enrichedGroups = getEnrichedGroups($groups)
|
||||
$: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
|
||||
$: filteredGroups = searchGroups(enrichedGroups, query)
|
||||
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
||||
$: allUsers = [...filteredUsers, ...groupUsers]
|
||||
|
@ -226,7 +252,7 @@
|
|||
specific roles for the app.
|
||||
*/
|
||||
const buildGroupUsers = (userGroups, filteredUsers) => {
|
||||
if (query) {
|
||||
if (query || !filterByAppAccess) {
|
||||
return []
|
||||
}
|
||||
// Must exclude users who have explicit privileges
|
||||
|
@ -321,12 +347,12 @@
|
|||
[prodAppId]: role,
|
||||
},
|
||||
})
|
||||
await filterInvites()
|
||||
await filterInvites(query)
|
||||
}
|
||||
|
||||
const onUninviteAppUser = async invite => {
|
||||
await uninviteAppUser(invite)
|
||||
await filterInvites()
|
||||
await filterInvites(query)
|
||||
}
|
||||
|
||||
// Purge only the app from the invite or recind the invite if only 1 app remains?
|
||||
|
@ -351,7 +377,6 @@
|
|||
|
||||
onMount(() => {
|
||||
rendered = true
|
||||
searchFocus = true
|
||||
})
|
||||
|
||||
function handleKeyDown(evt) {
|
||||
|
@ -417,7 +442,6 @@
|
|||
autocomplete="off"
|
||||
disabled={inviting}
|
||||
value={query}
|
||||
autofocus
|
||||
on:input={e => {
|
||||
query = e.target.value.trim()
|
||||
}}
|
||||
|
@ -428,16 +452,20 @@
|
|||
|
||||
<span
|
||||
class="search-input-icon"
|
||||
class:searching={query}
|
||||
class:searching={query || !filterByAppAccess}
|
||||
on:click={() => {
|
||||
if (!filterByAppAccess) {
|
||||
filterByAppAccess = true
|
||||
}
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
query = null
|
||||
userOnboardResponse = null
|
||||
filterByAppAccess = true
|
||||
}}
|
||||
>
|
||||
<Icon name={query ? "Close" : "Search"} />
|
||||
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
@ -555,7 +583,7 @@
|
|||
|
||||
{#if filteredUsers?.length}
|
||||
<div class="auth-entity-section">
|
||||
<div class="auth-entity-header ">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Users</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
|
@ -696,7 +724,7 @@
|
|||
max-width: calc(100vw - 40px);
|
||||
background: var(--background);
|
||||
border-left: var(--border-light);
|
||||
z-index: 3;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
text={screen.routing.route}
|
||||
on:click={() => store.actions.screens.select(screen._id)}
|
||||
rightAlignIcon
|
||||
showTooltip
|
||||
>
|
||||
<ScreenDropdownMenu screenId={screen._id} />
|
||||
<RoleIndicator slot="right" roleId={screen.routing.roleId} />
|
||||
|
|
|
@ -133,7 +133,7 @@
|
|||
</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
{#if $licensing.usageMetrics?.dayPasses >= 100}
|
||||
{#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit}
|
||||
<div>
|
||||
<Layout gap="S" justifyItems="center">
|
||||
<img class="spaceman" alt="spaceman" src={Spaceman} />
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
import Spinner from "components/common/Spinner.svelte"
|
||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
|
||||
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { API } from "api"
|
||||
|
@ -28,6 +29,7 @@
|
|||
let template
|
||||
let creationModal
|
||||
let appLimitModal
|
||||
let accountLockedModal
|
||||
let creatingApp = false
|
||||
let searchTerm = ""
|
||||
let creatingFromTemplate = false
|
||||
|
@ -48,6 +50,11 @@
|
|||
: true)
|
||||
)
|
||||
$: automationErrors = getAutomationErrors(enrichedApps)
|
||||
$: isOwner = $auth.accountPortalAccess && $admin.cloud
|
||||
|
||||
const usersLimitLockAction = $licensing?.errUserLimit
|
||||
? () => accountLockedModal.show()
|
||||
: null
|
||||
|
||||
const enrichApps = (apps, user, sortBy) => {
|
||||
const enrichedApps = apps.map(app => ({
|
||||
|
@ -189,6 +196,9 @@
|
|||
creatingFromTemplate = true
|
||||
createAppFromTemplateUrl(initInfo.init_template)
|
||||
}
|
||||
if (usersLimitLockAction) {
|
||||
usersLimitLockAction()
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error("Error getting init info")
|
||||
}
|
||||
|
@ -230,20 +240,30 @@
|
|||
<Layout noPadding gap="L">
|
||||
<div class="title">
|
||||
<div class="buttons">
|
||||
<Button size="M" cta on:click={initiateAppCreation}>
|
||||
<Button
|
||||
size="M"
|
||||
cta
|
||||
on:click={usersLimitLockAction || initiateAppCreation}
|
||||
>
|
||||
Create new app
|
||||
</Button>
|
||||
{#if $apps?.length > 0}
|
||||
<Button
|
||||
size="M"
|
||||
secondary
|
||||
on:click={$goto("/builder/portal/apps/templates")}
|
||||
on:click={usersLimitLockAction ||
|
||||
$goto("/builder/portal/apps/templates")}
|
||||
>
|
||||
View templates
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !$apps?.length}
|
||||
<Button size="L" quiet secondary on:click={initiateAppImport}>
|
||||
<Button
|
||||
size="L"
|
||||
quiet
|
||||
secondary
|
||||
on:click={usersLimitLockAction || initiateAppImport}
|
||||
>
|
||||
Import app
|
||||
</Button>
|
||||
{/if}
|
||||
|
@ -267,7 +287,7 @@
|
|||
|
||||
<div class="app-table">
|
||||
{#each filteredApps as app (app.appId)}
|
||||
<AppRow {app} />
|
||||
<AppRow {app} lockedAction={usersLimitLockAction} />
|
||||
{/each}
|
||||
</div>
|
||||
</Layout>
|
||||
|
@ -294,6 +314,11 @@
|
|||
</Modal>
|
||||
|
||||
<AppLimitModal bind:this={appLimitModal} />
|
||||
<AccountLockedModal
|
||||
bind:this={accountLockedModal}
|
||||
onConfirm={() =>
|
||||
isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
|
|
|
@ -107,8 +107,9 @@
|
|||
useSampleData,
|
||||
isGoogle,
|
||||
}) => {
|
||||
let app
|
||||
try {
|
||||
const app = await createApp(useSampleData)
|
||||
app = await createApp(useSampleData)
|
||||
|
||||
let datasource
|
||||
if (datasourceConfig) {
|
||||
|
@ -134,6 +135,17 @@
|
|||
console.log(e)
|
||||
creationLoading = false
|
||||
notifications.error("There was a problem creating your app")
|
||||
|
||||
// Reset the store so that we don't send up stale headers
|
||||
store.actions.reset()
|
||||
|
||||
// If we successfully created an app, delete it again so that we
|
||||
// can try again once the error has been corrected.
|
||||
// This also ensures onboarding can't be skipped by entering invalid
|
||||
// data credentials.
|
||||
if (app?.appId) {
|
||||
await API.deleteApp(app.appId)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -146,80 +158,87 @@
|
|||
/>
|
||||
</Modal>
|
||||
|
||||
<SplitPage>
|
||||
{#if stage === "name"}
|
||||
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
||||
{:else if googleComplete}
|
||||
<div class="centered">
|
||||
<Body
|
||||
>Please login to your Google account in the new tab which as opened to
|
||||
continue.</Body
|
||||
>
|
||||
</div>
|
||||
{:else if integrationsLoading || creationLoading}
|
||||
<div class="centered">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if stage === "data"}
|
||||
<DataPanel onBack={() => (stage = "name")}>
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={() => handleCreateApp({ useSampleData: true })}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<img
|
||||
alt="Budibase Logo"
|
||||
class="budibaseLogo"
|
||||
src={"https://i.imgur.com/Xhdt1YP.png"}
|
||||
/>
|
||||
</div>
|
||||
Budibase Sample data
|
||||
</div>
|
||||
</FancyButton>
|
||||
<div class="full-width">
|
||||
<SplitPage>
|
||||
{#if stage === "name"}
|
||||
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
||||
{:else if googleComplete}
|
||||
<div class="centered">
|
||||
<Body
|
||||
>Please login to your Google account in the new tab which as opened to
|
||||
continue.</Body
|
||||
>
|
||||
</div>
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={uploadModal.show}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
|
||||
</div>
|
||||
Upload data (CSV or JSON)
|
||||
</div>
|
||||
</FancyButton>
|
||||
{:else if integrationsLoading || creationLoading}
|
||||
<div class="centered">
|
||||
<Spinner />
|
||||
</div>
|
||||
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
|
||||
{:else if stage === "data"}
|
||||
<DataPanel onBack={() => (stage = "name")}>
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={() => (stage = integrationType)}>
|
||||
<FancyButton
|
||||
on:click={() => handleCreateApp({ useSampleData: true })}
|
||||
>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<IntegrationIcon {integrationType} {schema} />
|
||||
<img
|
||||
alt="Budibase Logo"
|
||||
class="budibaseLogo"
|
||||
src={"https://i.imgur.com/Xhdt1YP.png"}
|
||||
/>
|
||||
</div>
|
||||
{schema.friendlyName}
|
||||
Budibase Sample data
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
{/each}
|
||||
</DataPanel>
|
||||
{:else if stage in plusIntegrations}
|
||||
<DatasourceConfigPanel
|
||||
title={plusIntegrations[stage].friendlyName}
|
||||
fields={plusIntegrations[stage].datasource}
|
||||
type={stage}
|
||||
onBack={() => (stage = "data")}
|
||||
onNext={data => {
|
||||
const isGoogle = data.isGoogle
|
||||
delete data.isGoogle
|
||||
return handleCreateApp({ datasourceConfig: data, isGoogle })
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<p>There was an problem. Please refresh the page and try again.</p>
|
||||
{/if}
|
||||
<div slot="right">
|
||||
<ExampleApp {name} showData={stage !== "name"} />
|
||||
</div>
|
||||
</SplitPage>
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={uploadModal.show}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
|
||||
</div>
|
||||
Upload data (CSV or JSON)
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={() => (stage = integrationType)}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<IntegrationIcon {integrationType} {schema} />
|
||||
</div>
|
||||
{schema.friendlyName}
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
{/each}
|
||||
</DataPanel>
|
||||
{:else if stage in plusIntegrations}
|
||||
<DatasourceConfigPanel
|
||||
title={plusIntegrations[stage].friendlyName}
|
||||
fields={plusIntegrations[stage].datasource}
|
||||
type={stage}
|
||||
onBack={() => (stage = "data")}
|
||||
onNext={data => {
|
||||
const isGoogle = data.isGoogle
|
||||
delete data.isGoogle
|
||||
return handleCreateApp({ datasourceConfig: data, isGoogle })
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<p>There was an problem. Please refresh the page and try again.</p>
|
||||
{/if}
|
||||
<div slot="right">
|
||||
<ExampleApp {name} showData={stage !== "name"} />
|
||||
</div>
|
||||
</SplitPage>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -1,47 +1,28 @@
|
|||
<script>
|
||||
import { url, goto } from "@roxi/routify"
|
||||
import {
|
||||
Button,
|
||||
Layout,
|
||||
ActionMenu,
|
||||
Heading,
|
||||
Icon,
|
||||
Popover,
|
||||
notifications,
|
||||
Table,
|
||||
ActionMenu,
|
||||
Layout,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Table,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { users, apps, groups, auth, features } from "stores/portal"
|
||||
import { onMount, setContext } from "svelte"
|
||||
import { roles } from "stores/backend"
|
||||
import { goto, url } from "@roxi/routify"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { Breadcrumb, Breadcrumbs } from "components/portal/page"
|
||||
import { roles } from "stores/backend"
|
||||
import { apps, auth, features, groups } from "stores/portal"
|
||||
import { onMount, setContext } from "svelte"
|
||||
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
||||
import GroupIcon from "./_components/GroupIcon.svelte"
|
||||
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
|
||||
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
import GroupUsers from "./_components/GroupUsers.svelte"
|
||||
|
||||
export let groupId
|
||||
|
||||
$: userSchema = {
|
||||
email: {
|
||||
width: "1fr",
|
||||
},
|
||||
...(readonly
|
||||
? {}
|
||||
: {
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
const appSchema = {
|
||||
name: {
|
||||
width: "2fr",
|
||||
|
@ -50,12 +31,6 @@
|
|||
width: "1fr",
|
||||
},
|
||||
}
|
||||
const customUserTableRenderers = [
|
||||
{
|
||||
column: "_id",
|
||||
component: RemoveUserTableRenderer,
|
||||
},
|
||||
]
|
||||
const customAppTableRenderers = [
|
||||
{
|
||||
column: "name",
|
||||
|
@ -67,20 +42,12 @@
|
|||
},
|
||||
]
|
||||
|
||||
let popoverAnchor
|
||||
let popover
|
||||
let searchTerm = ""
|
||||
let prevSearch = undefined
|
||||
let pageInfo = createPaginationStore()
|
||||
let loaded = false
|
||||
let editModal, deleteModal
|
||||
|
||||
$: scimEnabled = $features.isScimEnabled
|
||||
$: readonly = !$auth.isAdmin || scimEnabled
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, searchTerm)
|
||||
$: group = $groups.find(x => x._id === groupId)
|
||||
$: filtered = $users.data
|
||||
$: groupApps = $apps
|
||||
.filter(app =>
|
||||
groups.actions
|
||||
|
@ -97,25 +64,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(page, search) {
|
||||
if ($pageInfo.loading) {
|
||||
return
|
||||
}
|
||||
// need to remove the page if they've started searching
|
||||
if (search && !prevSearch) {
|
||||
pageInfo.reset()
|
||||
page = undefined
|
||||
}
|
||||
prevSearch = search
|
||||
try {
|
||||
pageInfo.loading()
|
||||
await users.search({ page, email: search })
|
||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup() {
|
||||
try {
|
||||
await groups.actions.delete(group)
|
||||
|
@ -130,21 +78,17 @@
|
|||
try {
|
||||
await groups.actions.save(group)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save user group`)
|
||||
if (error.message) {
|
||||
notifications.error(error.message)
|
||||
} else {
|
||||
notifications.error(`Failed to save user group`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeUser = async id => {
|
||||
await groups.actions.removeUser(groupId, id)
|
||||
}
|
||||
|
||||
const removeApp = async app => {
|
||||
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
|
||||
}
|
||||
|
||||
setContext("users", {
|
||||
removeUser,
|
||||
})
|
||||
setContext("roles", {
|
||||
updateRole: () => {},
|
||||
removeRole: removeApp,
|
||||
|
@ -186,41 +130,7 @@
|
|||
</div>
|
||||
|
||||
<Layout noPadding gap="S">
|
||||
<div class="header">
|
||||
<Heading size="S">Users</Heading>
|
||||
{#if !scimEnabled}
|
||||
<div bind:this={popoverAnchor}>
|
||||
<Button disabled={readonly} on:click={popover.show()} cta
|
||||
>Add user</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||
<UserGroupPicker
|
||||
bind:searchTerm
|
||||
labelKey="email"
|
||||
selected={group.users?.map(user => user._id)}
|
||||
list={$users.data}
|
||||
on:select={e => groups.actions.addUser(groupId, e.detail)}
|
||||
on:deselect={e => groups.actions.removeUser(groupId, e.detail)}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
schema={userSchema}
|
||||
data={group?.users}
|
||||
allowEditRows={false}
|
||||
customPlaceholder
|
||||
customRenderers={customUserTableRenderers}
|
||||
on:click={e => $goto(`../users/${e.detail._id}`)}
|
||||
>
|
||||
<div class="placeholder" slot="placeholder">
|
||||
<Heading size="S">This user group doesn't have any users</Heading>
|
||||
</div>
|
||||
</Table>
|
||||
<GroupUsers {groupId} />
|
||||
</Layout>
|
||||
|
||||
<Layout noPadding gap="S">
|
||||
|
|
|
@ -9,15 +9,23 @@
|
|||
|
||||
export let group
|
||||
export let saveGroup
|
||||
|
||||
let nameError
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={() => saveGroup(group)}
|
||||
onConfirm={() => {
|
||||
if (!group.name?.trim()) {
|
||||
nameError = "Group name cannot be empty"
|
||||
return false
|
||||
}
|
||||
saveGroup(group)
|
||||
}}
|
||||
size="M"
|
||||
title={group?._rev ? "Edit group" : "Create group"}
|
||||
confirmText="Save"
|
||||
>
|
||||
<Input bind:value={group.name} label="Name" />
|
||||
<Input bind:value={group.name} label="Name" error={nameError} />
|
||||
<div class="modal-format">
|
||||
<div class="modal-inner">
|
||||
<Body size="XS">Icon</Body>
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<script>
|
||||
import { Button, Popover, notifications } from "@budibase/bbui"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { auth, groups, users } from "stores/portal"
|
||||
|
||||
export let groupId
|
||||
export let onUsersUpdated
|
||||
|
||||
let popoverAnchor
|
||||
let popover
|
||||
let searchTerm = ""
|
||||
let prevSearch = undefined
|
||||
let pageInfo = createPaginationStore()
|
||||
|
||||
$: readonly = !$auth.isAdmin
|
||||
$: page = $pageInfo.page
|
||||
$: searchUsers(page, searchTerm)
|
||||
$: group = $groups.find(x => x._id === groupId)
|
||||
|
||||
async function searchUsers(page, search) {
|
||||
if ($pageInfo.loading) {
|
||||
return
|
||||
}
|
||||
// need to remove the page if they've started searching
|
||||
if (search && !prevSearch) {
|
||||
pageInfo.reset()
|
||||
page = undefined
|
||||
}
|
||||
prevSearch = search
|
||||
try {
|
||||
pageInfo.loading()
|
||||
await users.search({ page, email: search })
|
||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||
} catch (error) {
|
||||
notifications.error("Error getting user list")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={popoverAnchor}>
|
||||
<Button disabled={readonly} on:click={popover.show()} cta>Add user</Button>
|
||||
</div>
|
||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||
<UserGroupPicker
|
||||
bind:searchTerm
|
||||
labelKey="email"
|
||||
selected={group.users?.map(user => user._id)}
|
||||
list={$users.data}
|
||||
on:select={async e => {
|
||||
await groups.actions.addUser(groupId, e.detail)
|
||||
onUsersUpdated()
|
||||
}}
|
||||
on:deselect={async e => {
|
||||
await groups.actions.removeUser(groupId, e.detail)
|
||||
onUsersUpdated()
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
|
@ -0,0 +1,133 @@
|
|||
<script>
|
||||
import EditUserPicker from "./EditUserPicker.svelte"
|
||||
|
||||
import { Heading, Pagination, Table, Search } from "@budibase/bbui"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { API } from "api"
|
||||
import { auth, features, groups } from "stores/portal"
|
||||
import { setContext } from "svelte"
|
||||
import ScimBanner from "../../_components/SCIMBanner.svelte"
|
||||
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
|
||||
|
||||
export let groupId
|
||||
|
||||
let emailSearch
|
||||
let fetchGroupUsers
|
||||
$: fetchGroupUsers = fetchData({
|
||||
API,
|
||||
datasource: {
|
||||
type: "groupUser",
|
||||
},
|
||||
options: {
|
||||
query: {
|
||||
groupId,
|
||||
emailSearch,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
$: userSchema = {
|
||||
email: {
|
||||
width: "1fr",
|
||||
},
|
||||
...(readonly
|
||||
? {}
|
||||
: {
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
const customUserTableRenderers = [
|
||||
{
|
||||
column: "_id",
|
||||
component: RemoveUserTableRenderer,
|
||||
},
|
||||
]
|
||||
|
||||
$: scimEnabled = $features.isScimEnabled
|
||||
$: readonly = !$auth.isAdmin || scimEnabled
|
||||
|
||||
const removeUser = async id => {
|
||||
await groups.actions.removeUser(groupId, id)
|
||||
fetchGroupUsers.refresh()
|
||||
}
|
||||
|
||||
setContext("users", {
|
||||
removeUser,
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
{#if !scimEnabled}
|
||||
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
|
||||
<div class="controls-right">
|
||||
<Search bind:value={emailSearch} placeholder="Search email" />
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
schema={userSchema}
|
||||
data={$fetchGroupUsers?.rows}
|
||||
loading={$fetchGroupUsers.loading}
|
||||
allowEditRows={false}
|
||||
customPlaceholder
|
||||
customRenderers={customUserTableRenderers}
|
||||
on:click={e => $goto(`../users/${e.detail._id}`)}
|
||||
>
|
||||
<div class="placeholder" slot="placeholder">
|
||||
<Heading size="S"
|
||||
>{emailSearch
|
||||
? `No users found matching the email "${emailSearch}"`
|
||||
: "This user group doesn't have any users"}</Heading
|
||||
>
|
||||
</div>
|
||||
</Table>
|
||||
|
||||
<div class="pagination">
|
||||
<Pagination
|
||||
page={$fetchGroupUsers.pageNumber + 1}
|
||||
hasPrevPage={$fetchGroupUsers.loading
|
||||
? false
|
||||
: $fetchGroupUsers.hasPrevPage}
|
||||
hasNextPage={$fetchGroupUsers.loading
|
||||
? false
|
||||
: $fetchGroupUsers.hasNextPage}
|
||||
goToPrevPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.prevPage}
|
||||
goToNextPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.nextPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
.header :global(.spectrum-Heading) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.controls-right :global(.spectrum-Search) {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
|
@ -66,6 +66,8 @@
|
|||
} catch (error) {
|
||||
if (error.status === 400) {
|
||||
notifications.error(error.message)
|
||||
} else if (error.message) {
|
||||
notifications.error(error.message)
|
||||
} else {
|
||||
notifications.error(`Failed to save group`)
|
||||
}
|
||||
|
|
|
@ -30,8 +30,8 @@
|
|||
$: hasError = userData.find(x => x.error != null)
|
||||
|
||||
$: userCount = $licensing.userCount + userData.length
|
||||
$: willReach = licensing.willReachUserLimit(userCount)
|
||||
$: willExceed = licensing.willExceedUserLimit(userCount)
|
||||
$: reached = licensing.usersLimitReached(userCount)
|
||||
$: exceeded = licensing.usersLimitExceeded(userCount)
|
||||
|
||||
function removeInput(idx) {
|
||||
userData = userData.filter((e, i) => i !== idx)
|
||||
|
@ -87,7 +87,7 @@
|
|||
confirmDisabled={disabled}
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
disabled={hasError || !userData.length || willExceed}
|
||||
disabled={hasError || !userData.length || exceeded}
|
||||
>
|
||||
<Layout noPadding gap="XS">
|
||||
<Label>Email address</Label>
|
||||
|
@ -118,7 +118,7 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
{#if willReach}
|
||||
{#if reached}
|
||||
<div class="user-notification">
|
||||
<Icon name="Info" />
|
||||
<span>
|
||||
|
|
|
@ -25,10 +25,10 @@
|
|||
$: invalidEmails = []
|
||||
|
||||
$: userCount = $licensing.userCount + userEmails.length
|
||||
$: willExceed = licensing.willExceedUserLimit(userCount)
|
||||
$: exceed = licensing.usersLimitExceeded(userCount)
|
||||
|
||||
$: importDisabled =
|
||||
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed
|
||||
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed
|
||||
|
||||
const validEmails = userEmails => {
|
||||
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
||||
|
@ -93,7 +93,7 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{#if willExceed}
|
||||
{#if exceed}
|
||||
<div class="user-notification">
|
||||
<Icon name="Info" />
|
||||
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
|
||||
|
|
|
@ -88,6 +88,16 @@
|
|||
},
|
||||
}
|
||||
|
||||
const getPendingSchema = tblSchema => {
|
||||
if (!tblSchema) {
|
||||
return {}
|
||||
}
|
||||
let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
|
||||
pendingSchema.email.displayName = "Pending Invites"
|
||||
return pendingSchema
|
||||
}
|
||||
|
||||
$: pendingSchema = getPendingSchema(schema)
|
||||
$: userData = []
|
||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: {
|
||||
|
@ -110,6 +120,24 @@
|
|||
}
|
||||
})
|
||||
}
|
||||
let invitesLoaded = false
|
||||
let pendingInvites = []
|
||||
let parsedInvites = []
|
||||
|
||||
const invitesToSchema = invites => {
|
||||
return invites.map(invite => {
|
||||
const { admin, builder, userGroups, apps } = invite.info
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
builder,
|
||||
admin,
|
||||
userGroups: userGroups,
|
||||
apps: apps ? [...new Set(Object.keys(apps))] : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
$: parsedInvites = invitesToSchema(pendingInvites)
|
||||
|
||||
const updateFetch = email => {
|
||||
fetch.update({
|
||||
|
@ -144,6 +172,7 @@
|
|||
}))
|
||||
try {
|
||||
inviteUsersResponse = await users.invite(payload)
|
||||
pendingInvites = await users.getInvites()
|
||||
inviteConfirmationModal.show()
|
||||
} catch (error) {
|
||||
notifications.error("Error inviting user")
|
||||
|
@ -232,12 +261,13 @@
|
|||
try {
|
||||
await groups.actions.init()
|
||||
groupsLoaded = true
|
||||
|
||||
pendingInvites = await users.getInvites()
|
||||
invitesLoaded = true
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching user group data")
|
||||
}
|
||||
})
|
||||
|
||||
let staticUserLimit = $licensing.license.quotas.usage.static.users.value
|
||||
</script>
|
||||
|
||||
<Layout noPadding gap="M">
|
||||
|
@ -246,7 +276,7 @@
|
|||
<Body>Add users and control who gets access to your published apps</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
{#if $licensing.warnUserLimit}
|
||||
{#if $licensing.errUserLimit}
|
||||
<InlineAlert
|
||||
type="error"
|
||||
onConfirm={() => {
|
||||
|
@ -258,13 +288,9 @@
|
|||
}}
|
||||
buttonText={isOwner ? "Upgrade" : "View plans"}
|
||||
cta
|
||||
header={`Users will soon be limited to ${staticUserLimit}`}
|
||||
message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}.
|
||||
|
||||
This means any users exceeding the limit have been de-activated.
|
||||
|
||||
De-activated users will not able to access the builder or any published apps until you upgrade to one of our paid plans.
|
||||
`}
|
||||
header="Account de-activated"
|
||||
message="Due to the free plan user limit being exceeded, your account has been de-activated.
|
||||
Upgrade your plan to re-activate your account."
|
||||
/>
|
||||
{/if}
|
||||
<div class="controls">
|
||||
|
@ -324,6 +350,15 @@
|
|||
goToNextPage={fetch.nextPage}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
schema={pendingSchema}
|
||||
data={parsedInvites}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
{customRenderers}
|
||||
loading={!invitesLoaded}
|
||||
allowClickRows={false}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={createUserModal}>
|
||||
|
|
|
@ -28,7 +28,7 @@ export function createGroupsStore() {
|
|||
// on the backend anyway
|
||||
if (get(licensing).groupsEnabled) {
|
||||
const groups = await API.getGroups()
|
||||
store.set(groups)
|
||||
store.set(groups.data)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { auth, admin } from "stores/portal"
|
|||
import { Constants } from "@budibase/frontend-core"
|
||||
import { StripeStatus } from "components/portal/licensing/constants"
|
||||
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
||||
import dayjs from "dayjs"
|
||||
import { PlanModel } from "@budibase/types"
|
||||
|
||||
const UNLIMITED = -1
|
||||
|
||||
|
@ -12,6 +12,7 @@ export const createLicensingStore = () => {
|
|||
const DEFAULT = {
|
||||
// navigation
|
||||
goToUpgradePage: () => {},
|
||||
goToPricingPage: () => {},
|
||||
// the top level license
|
||||
license: undefined,
|
||||
isFreePlan: true,
|
||||
|
@ -37,29 +38,37 @@ export const createLicensingStore = () => {
|
|||
// user limits
|
||||
userCount: undefined,
|
||||
userLimit: undefined,
|
||||
userLimitDays: undefined,
|
||||
userLimitReached: false,
|
||||
warnUserLimit: false,
|
||||
errUserLimit: false,
|
||||
}
|
||||
|
||||
const oneDayInMilliseconds = 86400000
|
||||
|
||||
const store = writable(DEFAULT)
|
||||
|
||||
function willReachUserLimit(userCount, userLimit) {
|
||||
function usersLimitReached(userCount, userLimit) {
|
||||
if (userLimit === UNLIMITED) {
|
||||
return false
|
||||
}
|
||||
return userCount >= userLimit
|
||||
}
|
||||
|
||||
function willExceedUserLimit(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()
|
||||
|
@ -71,10 +80,14 @@ export const createLicensingStore = () => {
|
|||
const goToUpgradePage = () => {
|
||||
window.location.href = upgradeUrl
|
||||
}
|
||||
const goToPricingPage = () => {
|
||||
window.open("https://budibase.com/pricing/", "_blank")
|
||||
}
|
||||
store.update(state => {
|
||||
return {
|
||||
...state,
|
||||
goToUpgradePage,
|
||||
goToPricingPage,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -128,15 +141,15 @@ export const createLicensingStore = () => {
|
|||
quotaUsage,
|
||||
}
|
||||
})
|
||||
actions.setUsageMetrics()
|
||||
await actions.setUsageMetrics()
|
||||
},
|
||||
willReachUserLimit: userCount => {
|
||||
return willReachUserLimit(userCount, get(store).userLimit)
|
||||
usersLimitReached: userCount => {
|
||||
return usersLimitReached(userCount, get(store).userLimit)
|
||||
},
|
||||
willExceedUserLimit(userCount) {
|
||||
return willExceedUserLimit(userCount, get(store).userLimit)
|
||||
usersLimitExceeded(userCount) {
|
||||
return usersLimitExceeded(userCount, get(store).userLimit)
|
||||
},
|
||||
setUsageMetrics: () => {
|
||||
setUsageMetrics: async () => {
|
||||
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
|
||||
const usage = get(store).quotaUsage
|
||||
const license = get(auth).user.license
|
||||
|
@ -198,11 +211,13 @@ export const createLicensingStore = () => {
|
|||
const userQuota = license.quotas.usage.static.users
|
||||
const userLimit = userQuota?.value
|
||||
const userCount = usage.usageQuota.users
|
||||
const userLimitReached = willReachUserLimit(userCount, userLimit)
|
||||
const userLimitExceeded = willExceedUserLimit(userCount, userLimit)
|
||||
const days = dayjs(userQuota?.startDate).diff(dayjs(), "day")
|
||||
const userLimitDays = days > 1 ? `${days} days` : "1 day"
|
||||
const warnUserLimit = userQuota?.startDate && userLimitExceeded
|
||||
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 {
|
||||
|
@ -217,9 +232,8 @@ export const createLicensingStore = () => {
|
|||
// user limits
|
||||
userCount,
|
||||
userLimit,
|
||||
userLimitDays,
|
||||
userLimitReached,
|
||||
warnUserLimit,
|
||||
errUserLimit,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
"name": "@budibase/cli",
|
||||
"version": "0.0.1",
|
||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||
"main": "dist/index.js",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
"budi": "dist/index.js"
|
||||
"budi": "dist/src/index.js"
|
||||
},
|
||||
"author": "Budibase",
|
||||
"license": "GPL-3.0",
|
||||
|
|
|
@ -2,17 +2,17 @@
|
|||
process.env.DISABLE_PINO_LOGGER = "1"
|
||||
import "./prebuilds"
|
||||
import "./environment"
|
||||
import { env } from "@budibase/backend-core"
|
||||
import { getCommands } from "./options"
|
||||
import { Command } from "commander"
|
||||
import { getHelpDescription } from "./utils"
|
||||
import { version } from "../package.json"
|
||||
|
||||
// add hosting config
|
||||
async function init() {
|
||||
const program = new Command()
|
||||
.addHelpCommand("help", getHelpDescription("Help with Budibase commands."))
|
||||
.helpOption(false)
|
||||
.version(env.VERSION)
|
||||
.version(version)
|
||||
// add commands
|
||||
for (let command of getCommands()) {
|
||||
command.configure(program)
|
||||
|
|
|
@ -13,7 +13,7 @@ if (!process.argv[0].includes("node")) {
|
|||
}
|
||||
|
||||
function checkForBinaries() {
|
||||
const readDir = join(__filename, "..", "..", PREBUILDS, ARCH)
|
||||
const readDir = join(__filename, "..", "..", "..", PREBUILDS, ARCH)
|
||||
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@budibase/types": ["../types/src"],
|
||||
"@budibase/backend-core": ["../backend-core/src"],
|
||||
|
@ -16,6 +17,6 @@
|
|||
"swc": true
|
||||
},
|
||||
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "package.json"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
const { styleable, builderStore } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
|
||||
let handlingOnClick = false
|
||||
|
||||
export let disabled = false
|
||||
export let text = ""
|
||||
export let onClick
|
||||
|
@ -16,6 +18,16 @@
|
|||
export let icon = null
|
||||
export let active = false
|
||||
|
||||
const handleOnClick = async () => {
|
||||
handlingOnClick = true
|
||||
|
||||
if (onClick) {
|
||||
await onClick()
|
||||
}
|
||||
|
||||
handlingOnClick = false
|
||||
}
|
||||
|
||||
let node
|
||||
|
||||
$: $component.editing && node?.focus()
|
||||
|
@ -37,9 +49,9 @@
|
|||
<button
|
||||
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`}
|
||||
class:spectrum-Button--quiet={quiet}
|
||||
{disabled}
|
||||
disabled={disabled || handlingOnClick}
|
||||
use:styleable={$component.styles}
|
||||
on:click={onClick}
|
||||
on:click={handleOnClick}
|
||||
contenteditable={$component.editing && !icon}
|
||||
on:blur={$component.editing ? updateText : null}
|
||||
bind:this={node}
|
||||
|
|
|
@ -384,7 +384,7 @@ const confirmTextMap = {
|
|||
["Save Row"]: "Are you sure you want to save this row?",
|
||||
["Execute Query"]: "Are you sure you want to execute this query?",
|
||||
["Trigger Automation"]: "Are you sure you want to trigger this automation?",
|
||||
["Prompt User"]: "Are you sure you want to contiune?",
|
||||
["Prompt User"]: "Are you sure you want to continue?",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -52,6 +52,23 @@ export const buildGroupsEndpoints = API => {
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a group users by the group id
|
||||
*/
|
||||
getGroupUsers: async ({ id, bookmark, emailSearch }) => {
|
||||
let url = `/api/global/groups/${id}/users?`
|
||||
if (bookmark) {
|
||||
url += `bookmark=${bookmark}&`
|
||||
}
|
||||
if (emailSearch) {
|
||||
url += `emailSearch=${emailSearch}&`
|
||||
}
|
||||
|
||||
return await API.get({
|
||||
url,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds users to a group
|
||||
* @param groupId The group to update
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
<script>
|
||||
import { Layout } from "@budibase/bbui"
|
||||
import Bulgaria from "../../assets/bulgaria.png"
|
||||
import Covanta from "../../assets/covanta.png"
|
||||
import Schnellecke from "../../assets/schnellecke.png"
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "Budibase was the only solution that checked all the boxes for Covanta. Covanta expects to realize $3.2MM in savings due to the elimination of redundant data entry.",
|
||||
name: "Charles Link",
|
||||
role: "Senior Director, Data and Analytics",
|
||||
image: Covanta,
|
||||
imageSize: 105,
|
||||
},
|
||||
{
|
||||
text: "Budibase was mission-critical for us and went a long way in preventing what could have become a humanitarian crisis here in Bulgaria.",
|
||||
name: "Bozhidar Bozhanov",
|
||||
role: "Government of Bulgaria",
|
||||
image: Bulgaria,
|
||||
imageSize: 49,
|
||||
},
|
||||
{
|
||||
text: "Centralization of authentication, quick turnaround time for requests, integration with different database systems has given it the edge and it’s now used daily for internal development for those apps that you know you need but don’t feel value in losing days of development to reinvent the wheel.",
|
||||
name: "Davide Lenzarini",
|
||||
role: "IT manager",
|
||||
image: Schnellecke,
|
||||
imageSize: 141,
|
||||
},
|
||||
]
|
||||
const testimonial = testimonials[Math.floor(Math.random() * 3)]
|
||||
</script>
|
||||
|
||||
<div class="testimonial">
|
||||
<Layout noPadding gap="S">
|
||||
<img
|
||||
width={testimonial.imageSize}
|
||||
alt="a-happy-budibase-user"
|
||||
src={testimonial.image}
|
||||
/>
|
||||
<div class="text">
|
||||
"{testimonial.text}"
|
||||
</div>
|
||||
<div class="author">
|
||||
<div class="name">{testimonial.name}</div>
|
||||
<div class="company">{testimonial.role}</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.testimonial {
|
||||
width: 380px;
|
||||
padding: 40px;
|
||||
}
|
||||
.text {
|
||||
font-size: var(--font-size-l);
|
||||
font-style: italic;
|
||||
}
|
||||
.name {
|
||||
font-weight: bold;
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
font-size: var(--font-size-l);
|
||||
}
|
||||
.company {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
</style>
|
|
@ -1,58 +1,15 @@
|
|||
<script>
|
||||
import SplitPage from "./SplitPage.svelte"
|
||||
import { Layout } from "@budibase/bbui"
|
||||
import Bulgaria from "../../assets/bulgaria.png"
|
||||
import Covanta from "../../assets/covanta.png"
|
||||
import Schnellecke from "../../assets/schnellecke.png"
|
||||
import Testimonial from "./Testimonial.svelte"
|
||||
|
||||
export let enabled = true
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "Budibase was the only solution that checked all the boxes for Covanta. Covanta expects to realize $3.2MM in savings due to the elimination of redundant data entry.",
|
||||
name: "Charles Link",
|
||||
role: "Senior Director, Data and Analytics",
|
||||
image: Covanta,
|
||||
imageSize: 105,
|
||||
},
|
||||
{
|
||||
text: "Budibase was mission-critical for us and went a long way in preventing what could have become a humanitarian crisis here in Bulgaria.",
|
||||
name: "Bozhidar Bozhanov",
|
||||
role: "Government of Bulgaria",
|
||||
image: Bulgaria,
|
||||
imageSize: 49,
|
||||
},
|
||||
{
|
||||
text: "Centralization of authentication, quick turnaround time for requests, integration with different database systems has given it the edge and it’s now used daily for internal development for those apps that you know you need but don’t feel value in losing days of development to reinvent the wheel.",
|
||||
name: "Davide Lenzarini",
|
||||
role: "IT manager",
|
||||
image: Schnellecke,
|
||||
imageSize: 141,
|
||||
},
|
||||
]
|
||||
const testimonial = testimonials[Math.floor(Math.random() * 3)]
|
||||
</script>
|
||||
|
||||
<SplitPage>
|
||||
<slot />
|
||||
<div class:wrapper={enabled} slot="right">
|
||||
{#if enabled}
|
||||
<div class="testimonial">
|
||||
<Layout noPadding gap="S">
|
||||
<img
|
||||
width={testimonial.imageSize}
|
||||
alt="a-happy-budibase-user"
|
||||
src={testimonial.image}
|
||||
/>
|
||||
<div class="text">
|
||||
"{testimonial.text}"
|
||||
</div>
|
||||
<div class="author">
|
||||
<div class="name">{testimonial.name}</div>
|
||||
<div class="company">{testimonial.role}</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
<Testimonial />
|
||||
{/if}
|
||||
</div>
|
||||
</SplitPage>
|
||||
|
@ -64,20 +21,4 @@
|
|||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.testimonial {
|
||||
width: 380px;
|
||||
padding: 40px;
|
||||
}
|
||||
.text {
|
||||
font-size: var(--font-size-l);
|
||||
font-style: italic;
|
||||
}
|
||||
.name {
|
||||
font-weight: bold;
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
font-size: var(--font-size-l);
|
||||
}
|
||||
.company {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
$: readonly =
|
||||
column.schema.autocolumn ||
|
||||
column.schema.disabled ||
|
||||
column.schema.type === "formula" ||
|
||||
(!$config.allowEditRows && row._id)
|
||||
|
||||
// Register this cell API if the row is focused
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
<script>
|
||||
import dayjs from "dayjs"
|
||||
import { CoreDatePicker, Icon } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
export let onChange
|
||||
export let focused = false
|
||||
export let readonly = false
|
||||
export let api
|
||||
|
||||
// adding the 0- will turn a string like 00:00:00 into a valid ISO
|
||||
let flatpickr
|
||||
let isOpen
|
||||
|
||||
// Adding the 0- will turn a string like 00:00:00 into a valid ISO
|
||||
// date, but will make actual ISO dates invalid
|
||||
$: time = new Date(`0-${value}`)
|
||||
$: timeOnly = !isNaN(time) || schema?.timeOnly
|
||||
$: isTimeValue = !isNaN(new Date(`0-${value}`))
|
||||
$: timeOnly = isTimeValue || schema?.timeOnly
|
||||
$: dateOnly = schema?.dateOnly
|
||||
$: format = timeOnly
|
||||
? "HH:mm:ss"
|
||||
|
@ -19,12 +24,45 @@
|
|||
? "MMM D YYYY"
|
||||
: "MMM D YYYY, HH:mm"
|
||||
$: editable = focused && !readonly
|
||||
$: displayValue = getDisplayValue(value, format, timeOnly, isTimeValue)
|
||||
|
||||
const getDisplayValue = (value, format, timeOnly, isTimeValue) => {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
// Parse full date strings
|
||||
if (!timeOnly || !isTimeValue) {
|
||||
return dayjs(value).format(format)
|
||||
}
|
||||
// Otherwise must be a time string
|
||||
return dayjs(`0-${value}`).format(format)
|
||||
}
|
||||
|
||||
// Ensure we close flatpickr when unselected
|
||||
$: {
|
||||
if (!focused) {
|
||||
flatpickr?.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = () => {
|
||||
return isOpen
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
onKeyDown,
|
||||
focus: () => flatpickr?.open(),
|
||||
blur: () => flatpickr?.close(),
|
||||
isActive: () => isOpen,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="value">
|
||||
{#if value}
|
||||
{dayjs(timeOnly ? time : value).format(format)}
|
||||
{displayValue}
|
||||
{/if}
|
||||
</div>
|
||||
{#if editable}
|
||||
|
@ -42,6 +80,10 @@
|
|||
{timeOnly}
|
||||
time24hr
|
||||
ignoreTimezones={schema.ignoreTimezones}
|
||||
bind:flatpickr
|
||||
on:open={() => (isOpen = true)}
|
||||
on:close={() => (isOpen = false)}
|
||||
useKeyboardShortcuts={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -70,7 +70,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $config.allowExpandRows}
|
||||
{#if rowSelected && $config.allowDeleteRows}
|
||||
<div class="delete" on:click={() => dispatch("request-bulk-delete")}>
|
||||
<Icon
|
||||
name="Delete"
|
||||
size="S"
|
||||
color="var(--spectrum-global-color-red-400)"
|
||||
/>
|
||||
</div>
|
||||
{:else if $config.allowExpandRows}
|
||||
<div
|
||||
class="expand"
|
||||
class:visible={!disableExpand && (rowFocused || rowHovered)}
|
||||
|
@ -111,9 +119,12 @@
|
|||
.number.visible {
|
||||
display: flex;
|
||||
}
|
||||
.delete,
|
||||
.expand {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.expand {
|
||||
opacity: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.expand :global(.spectrum-Icon) {
|
||||
pointer-events: none;
|
||||
|
@ -124,4 +135,11 @@
|
|||
.expand.visible :global(.spectrum-Icon) {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete:hover :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-red-600) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import { Modal, ModalContent, Button, notifications } from "@budibase/bbui"
|
||||
import { Modal, ModalContent, notifications } from "@budibase/bbui"
|
||||
import { getContext, onMount } from "svelte"
|
||||
|
||||
const { selectedRows, rows, config, subscribe } = getContext("grid")
|
||||
const { selectedRows, rows, subscribe } = getContext("grid")
|
||||
|
||||
let modal
|
||||
|
||||
|
@ -27,20 +27,6 @@
|
|||
onMount(() => subscribe("request-bulk-delete", () => modal?.show()))
|
||||
</script>
|
||||
|
||||
{#if selectedRowCount}
|
||||
<div class="delete-button" data-ignore-click-outside="true">
|
||||
<Button
|
||||
icon="Delete"
|
||||
size="M"
|
||||
on:click={modal.show}
|
||||
disabled={!$config.allowEditRows}
|
||||
cta
|
||||
>
|
||||
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
title="Delete rows"
|
||||
|
@ -53,14 +39,3 @@
|
|||
row{selectedRowCount === 1 ? "" : "s"}?
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.delete-button :global(.spectrum-Button:not(:disabled)) {
|
||||
background-color: var(--spectrum-global-color-red-400);
|
||||
border-color: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
.delete-button :global(.spectrum-Button:not(:disabled):hover) {
|
||||
background-color: var(--spectrum-global-color-red-500);
|
||||
border-color: var(--spectrum-global-color-red-500);
|
||||
}
|
||||
</style>
|
|
@ -3,7 +3,7 @@
|
|||
import { ActionButton, Popover } from "@budibase/bbui"
|
||||
import { DefaultColumnWidth } from "../lib/constants"
|
||||
|
||||
const { stickyColumn, columns } = getContext("grid")
|
||||
const { stickyColumn, columns, compact } = getContext("grid")
|
||||
const smallSize = 120
|
||||
const mediumSize = DefaultColumnWidth
|
||||
const largeSize = DefaultColumnWidth * 1.5
|
||||
|
@ -59,12 +59,13 @@
|
|||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
disabled={!allCols.length}
|
||||
tooltip={$compact ? "Width" : null}
|
||||
>
|
||||
Width
|
||||
{$compact ? "" : "Width"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<Popover bind:open {anchor} align={$compact ? "right" : "left"}>
|
||||
<div class="content">
|
||||
{#each sizeOptions as option}
|
||||
<ActionButton
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover, Toggle } from "@budibase/bbui"
|
||||
import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui"
|
||||
import { getColumnIcon } from "../lib/utils"
|
||||
|
||||
const { columns, stickyColumn } = getContext("grid")
|
||||
const { columns, stickyColumn, compact } = getContext("grid")
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
|
@ -47,25 +48,32 @@
|
|||
on:click={() => (open = !open)}
|
||||
selected={open || anyHidden}
|
||||
disabled={!$columns.length}
|
||||
tooltip={$compact ? "Columns" : ""}
|
||||
>
|
||||
Columns
|
||||
{$compact ? "" : "Columns"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<Popover bind:open {anchor} align={$compact ? "right" : "left"}>
|
||||
<div class="content">
|
||||
<div class="columns">
|
||||
{#if $stickyColumn}
|
||||
<div class="column">
|
||||
<Icon size="S" name={getColumnIcon($stickyColumn)} />
|
||||
{$stickyColumn.label}
|
||||
</div>
|
||||
<Toggle disabled size="S" value={true} />
|
||||
<span>{$stickyColumn.label}</span>
|
||||
{/if}
|
||||
{#each $columns as column}
|
||||
<div class="column">
|
||||
<Icon size="S" name={getColumnIcon(column)} />
|
||||
{column.label}
|
||||
</div>
|
||||
<Toggle
|
||||
size="S"
|
||||
value={column.visible}
|
||||
on:change={e => toggleVisibility(column, e.detail)}
|
||||
/>
|
||||
<span>{column.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
|
@ -90,6 +98,13 @@
|
|||
.columns {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.columns :global(.spectrum-Switch) {
|
||||
margin-right: 0;
|
||||
}
|
||||
.column {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
SmallRowHeight,
|
||||
} from "../lib/constants"
|
||||
|
||||
const { rowHeight, columns, table } = getContext("grid")
|
||||
const { rowHeight, columns, table, compact } = getContext("grid")
|
||||
const sizeOptions = [
|
||||
{
|
||||
label: "Small",
|
||||
|
@ -41,12 +41,13 @@
|
|||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
tooltip={$compact ? "Height" : null}
|
||||
>
|
||||
Height
|
||||
{$compact ? "" : "Height"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<Popover bind:open {anchor} align={$compact ? "right" : "left"}>
|
||||
<div class="content">
|
||||
{#each sizeOptions as option}
|
||||
<ActionButton
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover, Select } from "@budibase/bbui"
|
||||
|
||||
const { sort, columns, stickyColumn } = getContext("grid")
|
||||
const { sort, columns, stickyColumn, compact } = getContext("grid")
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
|
@ -90,12 +90,13 @@
|
|||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
disabled={!columnOptions.length}
|
||||
tooltip={$compact ? "Sort" : ""}
|
||||
>
|
||||
Sort
|
||||
{$compact ? "" : "Sort"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<Popover bind:open {anchor} align={$compact ? "right" : "left"}>
|
||||
<div class="content">
|
||||
<Select
|
||||
placeholder={null}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { createEventManagers } from "../lib/events"
|
||||
import { createAPIClient } from "../../../api"
|
||||
import { attachStores } from "../stores"
|
||||
import DeleteButton from "../controls/DeleteButton.svelte"
|
||||
import BulkDeleteHandler from "../controls/BulkDeleteHandler.svelte"
|
||||
import BetaButton from "../controls/BetaButton.svelte"
|
||||
import GridBody from "./GridBody.svelte"
|
||||
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
|
||||
|
@ -112,13 +112,12 @@
|
|||
<AddRowButton />
|
||||
<AddColumnButton />
|
||||
<slot name="controls" />
|
||||
<SortButton />
|
||||
<HideColumnsButton />
|
||||
<ColumnWidthButton />
|
||||
<RowHeightButton />
|
||||
<HideColumnsButton />
|
||||
<SortButton />
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<DeleteButton />
|
||||
<UserAvatars />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -131,7 +130,9 @@
|
|||
<GridBody />
|
||||
</div>
|
||||
<BetaButton />
|
||||
<NewRow />
|
||||
{#if allowAddRows}
|
||||
<NewRow />
|
||||
{/if}
|
||||
<div class="overlays">
|
||||
<ResizeOverlay />
|
||||
<ReorderOverlay />
|
||||
|
@ -146,6 +147,9 @@
|
|||
<ProgressCircle />
|
||||
</div>
|
||||
{/if}
|
||||
{#if allowDeleteRows}
|
||||
<BulkDeleteHandler />
|
||||
{/if}
|
||||
<KeyboardManager />
|
||||
</div>
|
||||
|
||||
|
@ -214,6 +218,7 @@
|
|||
padding: var(--cell-padding);
|
||||
gap: var(--cell-spacing);
|
||||
background: var(--background);
|
||||
z-index: 2;
|
||||
}
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
|
@ -239,7 +244,7 @@
|
|||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
z-index: 100;
|
||||
}
|
||||
.grid-loading:before {
|
||||
content: "";
|
||||
|
|
|
@ -4,8 +4,14 @@
|
|||
import HeaderCell from "../cells/HeaderCell.svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
|
||||
const { renderedColumns, dispatch, scroll, hiddenColumnsWidth, width } =
|
||||
getContext("grid")
|
||||
const {
|
||||
renderedColumns,
|
||||
dispatch,
|
||||
scroll,
|
||||
hiddenColumnsWidth,
|
||||
width,
|
||||
config,
|
||||
} = getContext("grid")
|
||||
|
||||
$: columnsWidth = $renderedColumns.reduce(
|
||||
(total, col) => (total += col.width),
|
||||
|
@ -23,13 +29,15 @@
|
|||
{/each}
|
||||
</div>
|
||||
</GridScrollWrapper>
|
||||
<div
|
||||
class="add"
|
||||
style="left:{left}px"
|
||||
on:click={() => dispatch("add-column")}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
{#if $config.allowAddColumns}
|
||||
<div
|
||||
class="add"
|
||||
style="left:{left}px"
|
||||
on:click={() => dispatch("add-column")}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -38,7 +46,6 @@
|
|||
border-bottom: var(--cell-border);
|
||||
position: relative;
|
||||
height: var(--default-row-height);
|
||||
z-index: 1;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
|
@ -54,6 +61,7 @@
|
|||
border-right: var(--cell-border);
|
||||
border-bottom: var(--cell-border);
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
z-index: 20;
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
|
|
|
@ -270,7 +270,7 @@
|
|||
z-index: 3;
|
||||
position: absolute;
|
||||
top: calc(var(--row-height) + var(--offset) + 24px);
|
||||
left: var(--gutter-width);
|
||||
left: 18px;
|
||||
}
|
||||
.button-with-keys {
|
||||
display: flex;
|
||||
|
|
|
@ -21,6 +21,9 @@ const TypeIconMap = {
|
|||
}
|
||||
|
||||
export const getColumnIcon = column => {
|
||||
if (column.schema.autocolumn) {
|
||||
return "MagicWand"
|
||||
}
|
||||
const type = column.schema.type
|
||||
return TypeIconMap[type] || "Text"
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
clipboard,
|
||||
dispatch,
|
||||
selectedRows,
|
||||
config,
|
||||
} = getContext("grid")
|
||||
|
||||
const ignoredOriginSelectors = [
|
||||
|
@ -37,10 +38,12 @@
|
|||
e.preventDefault()
|
||||
focusFirstCell()
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
dispatch("add-row-inline")
|
||||
if ($config.allowAddRows) {
|
||||
e.preventDefault()
|
||||
dispatch("add-row-inline")
|
||||
}
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (Object.keys($selectedRows).length) {
|
||||
if (Object.keys($selectedRows).length && $config.allowDeleteRows) {
|
||||
dispatch("request-bulk-delete")
|
||||
}
|
||||
}
|
||||
|
@ -88,7 +91,9 @@
|
|||
}
|
||||
break
|
||||
case "Enter":
|
||||
dispatch("add-row-inline")
|
||||
if ($config.allowAddRows) {
|
||||
dispatch("add-row-inline")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (e.key) {
|
||||
|
@ -106,7 +111,7 @@
|
|||
break
|
||||
case "Delete":
|
||||
case "Backspace":
|
||||
if (Object.keys($selectedRows).length) {
|
||||
if (Object.keys($selectedRows).length && $config.allowDeleteRows) {
|
||||
dispatch("request-bulk-delete")
|
||||
} else {
|
||||
deleteSelectedCell()
|
||||
|
@ -117,7 +122,9 @@
|
|||
break
|
||||
case " ":
|
||||
case "Space":
|
||||
toggleSelectRow()
|
||||
if ($config.allowDeleteRows) {
|
||||
toggleSelectRow()
|
||||
}
|
||||
break
|
||||
default:
|
||||
startEnteringValue(e.key, e.which)
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
<script>
|
||||
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui"
|
||||
import {
|
||||
clickOutside,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Helpers,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
import { NewRowID } from "../lib/constants"
|
||||
|
||||
const {
|
||||
focusedRow,
|
||||
|
@ -14,9 +21,11 @@
|
|||
clipboard,
|
||||
dispatch,
|
||||
focusedCellAPI,
|
||||
focusedRowId,
|
||||
} = getContext("grid")
|
||||
|
||||
$: style = makeStyle($menu)
|
||||
$: isNewRow = $focusedRowId === NewRowID
|
||||
|
||||
const makeStyle = menu => {
|
||||
return `left:${menu.left}px; top:${menu.top}px;`
|
||||
|
@ -36,6 +45,11 @@
|
|||
$focusedCellId = `${newRow._id}-${column}`
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async value => {
|
||||
await Helpers.copyToClipboard(value)
|
||||
notifications.success("Copied to clipboard")
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $menu.visible}
|
||||
|
@ -58,22 +72,38 @@
|
|||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Maximize"
|
||||
disabled={!$config.allowEditRows}
|
||||
disabled={isNewRow || !$config.allowEditRows}
|
||||
on:click={() => dispatch("edit-row", $focusedRow)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Edit row in modal
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
disabled={isNewRow || !$focusedRow?._id}
|
||||
on:click={() => copyToClipboard($focusedRow?._id)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Copy row _id
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
disabled={isNewRow || !$focusedRow?._rev}
|
||||
on:click={() => copyToClipboard($focusedRow?._rev)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Copy row _rev
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Duplicate"
|
||||
disabled={!$config.allowAddRows}
|
||||
disabled={isNewRow || !$config.allowAddRows}
|
||||
on:click={duplicate}
|
||||
>
|
||||
Duplicate row
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Delete"
|
||||
disabled={!$config.allowDeleteRows}
|
||||
disabled={isNewRow || !$config.allowDeleteRows}
|
||||
on:click={deleteRow}
|
||||
>
|
||||
Delete row
|
||||
|
|
|
@ -338,15 +338,11 @@ export const deriveStores = context => {
|
|||
...state,
|
||||
[rowId]: true,
|
||||
}))
|
||||
const newRow = { ...row, ...get(rowChangeCache)[rowId] }
|
||||
const saved = await API.saveRow(newRow)
|
||||
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
|
||||
|
||||
// Update state after a successful change
|
||||
rows.update(state => {
|
||||
state[index] = {
|
||||
...newRow,
|
||||
_rev: saved._rev,
|
||||
}
|
||||
state[index] = saved
|
||||
return state.slice()
|
||||
})
|
||||
rowChangeCache.update(state => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { writable, get, derived } from "svelte/store"
|
|||
import { tick } from "svelte"
|
||||
import {
|
||||
DefaultRowHeight,
|
||||
GutterWidth,
|
||||
LargeRowHeight,
|
||||
MediumRowHeight,
|
||||
NewRowID,
|
||||
|
@ -43,6 +44,8 @@ export const deriveStores = context => {
|
|||
enrichedRows,
|
||||
rowLookupMap,
|
||||
rowHeight,
|
||||
stickyColumn,
|
||||
width,
|
||||
} = context
|
||||
|
||||
// Derive the row that contains the selected cell
|
||||
|
@ -70,6 +73,7 @@ export const deriveStores = context => {
|
|||
hoveredRowId.set(null)
|
||||
}
|
||||
|
||||
// Derive the amount of content lines to show in cells depending on row height
|
||||
const contentLines = derived(rowHeight, $rowHeight => {
|
||||
if ($rowHeight === LargeRowHeight) {
|
||||
return 3
|
||||
|
@ -79,9 +83,15 @@ export const deriveStores = context => {
|
|||
return 1
|
||||
})
|
||||
|
||||
// Derive whether we should use the compact UI, depending on width
|
||||
const compact = derived([stickyColumn, width], ([$stickyColumn, $width]) => {
|
||||
return ($stickyColumn?.width || 0) + $width + GutterWidth < 1100
|
||||
})
|
||||
|
||||
return {
|
||||
focusedRow,
|
||||
contentLines,
|
||||
compact,
|
||||
ui: {
|
||||
actions: {
|
||||
blur,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export { default as SplitPage } from "./SplitPage.svelte"
|
||||
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
||||
export { default as Testimonial } from "./Testimonial.svelte"
|
||||
export { Grid } from "./grid"
|
||||
|
|
|
@ -362,13 +362,35 @@ export default class DataFetch {
|
|||
return
|
||||
}
|
||||
this.store.update($store => ({ ...$store, loading: true }))
|
||||
const { rows, info, error } = await this.getPage()
|
||||
const { rows, info, error, cursor } = await this.getPage()
|
||||
|
||||
let { cursors } = get(this.store)
|
||||
const { pageNumber } = get(this.store)
|
||||
|
||||
if (!rows.length && pageNumber > 0) {
|
||||
// If the full page is gone but we have previous pages, navigate to the previous page
|
||||
this.store.update($store => ({
|
||||
...$store,
|
||||
loading: false,
|
||||
cursors: cursors.slice(0, pageNumber),
|
||||
}))
|
||||
return await this.prevPage()
|
||||
}
|
||||
|
||||
const currentNextCursor = cursors[pageNumber + 1]
|
||||
if (currentNextCursor != cursor) {
|
||||
// If the current cursor changed, all the next pages need to be updated, so we mark them as stale
|
||||
cursors = cursors.slice(0, pageNumber + 1)
|
||||
cursors[pageNumber + 1] = cursor
|
||||
}
|
||||
|
||||
this.store.update($store => ({
|
||||
...$store,
|
||||
rows,
|
||||
info,
|
||||
loading: false,
|
||||
error,
|
||||
cursors,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import { get } from "svelte/store"
|
||||
import DataFetch from "./DataFetch.js"
|
||||
import { TableNames } from "../constants"
|
||||
|
||||
export default class GroupUserFetch extends DataFetch {
|
||||
constructor(opts) {
|
||||
super({
|
||||
...opts,
|
||||
datasource: {
|
||||
tableId: TableNames.USERS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
determineFeatureFlags() {
|
||||
return {
|
||||
supportsSearch: true,
|
||||
supportsSort: false,
|
||||
supportsPagination: true,
|
||||
}
|
||||
}
|
||||
|
||||
async getDefinition() {
|
||||
return {
|
||||
schema: {},
|
||||
}
|
||||
}
|
||||
|
||||
async getData() {
|
||||
const { query, cursor } = get(this.store)
|
||||
try {
|
||||
const res = await this.API.getGroupUsers({
|
||||
id: query.groupId,
|
||||
emailSearch: query.emailSearch,
|
||||
bookmark: cursor,
|
||||
})
|
||||
|
||||
return {
|
||||
rows: res?.users || [],
|
||||
hasNextPage: res?.hasNextPage || false,
|
||||
cursor: res?.bookmark || null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
rows: [],
|
||||
hasNextPage: false,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import NestedProviderFetch from "./NestedProviderFetch.js"
|
|||
import FieldFetch from "./FieldFetch.js"
|
||||
import JSONArrayFetch from "./JSONArrayFetch.js"
|
||||
import UserFetch from "./UserFetch.js"
|
||||
import GroupUserFetch from "./GroupUserFetch.js"
|
||||
|
||||
const DataFetchMap = {
|
||||
table: TableFetch,
|
||||
|
@ -13,6 +14,7 @@ const DataFetchMap = {
|
|||
query: QueryFetch,
|
||||
link: RelationshipFetch,
|
||||
user: UserFetch,
|
||||
groupUser: GroupUserFetch,
|
||||
|
||||
// Client specific datasource types
|
||||
provider: NestedProviderFetch,
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 426d70a73520406a1178f997b4fa1c8bd7c7b186
|
||||
Subproject commit e134276fc2f3356a16a68a450a8ddea5a0c050d4
|
|
@ -37,7 +37,7 @@ import {
|
|||
Table,
|
||||
} from "@budibase/types"
|
||||
|
||||
const { cleanExportRows } = require("./utils")
|
||||
import { cleanExportRows } from "./utils"
|
||||
|
||||
const CALCULATION_TYPES = {
|
||||
SUM: "sum",
|
||||
|
@ -118,8 +118,11 @@ export async function patch(ctx: UserCtx) {
|
|||
combinedRow[key] = inputs[key]
|
||||
}
|
||||
|
||||
// need to copy the table so it can be differenced on way out
|
||||
const tableClone = cloneDeep(dbTable)
|
||||
|
||||
// this returns the table and row incase they have been updated
|
||||
let { table, row } = inputProcessing(ctx.user, dbTable, combinedRow)
|
||||
let { table, row } = inputProcessing(ctx.user, tableClone, combinedRow)
|
||||
const validateResult = await utils.validate({
|
||||
row,
|
||||
table,
|
||||
|
@ -163,7 +166,12 @@ export async function save(ctx: UserCtx) {
|
|||
|
||||
// this returns the table and row incase they have been updated
|
||||
const dbTable = await db.get(inputs.tableId)
|
||||
let { table, row } = inputProcessing(ctx.user, dbTable, inputs)
|
||||
|
||||
// need to copy the table so it can be differenced on way out
|
||||
const tableClone = cloneDeep(dbTable)
|
||||
|
||||
let { table, row } = inputProcessing(ctx.user, tableClone, inputs)
|
||||
|
||||
const validateResult = await utils.validate({
|
||||
row,
|
||||
table,
|
||||
|
@ -391,6 +399,9 @@ export async function exportRows(ctx: UserCtx) {
|
|||
const table = await db.get(ctx.params.tableId)
|
||||
const rowIds = ctx.request.body.rows
|
||||
let format = ctx.query.format
|
||||
if (typeof format !== "string") {
|
||||
ctx.throw(400, "Format parameter is not valid")
|
||||
}
|
||||
const { columns, query } = ctx.request.body
|
||||
|
||||
let result
|
||||
|
|
|
@ -69,9 +69,9 @@ export async function validate({
|
|||
if (type === FieldTypes.FORMULA || column.autocolumn) {
|
||||
continue
|
||||
}
|
||||
// special case for options, need to always allow unselected (null)
|
||||
// special case for options, need to always allow unselected (empty)
|
||||
if (type === FieldTypes.OPTIONS && constraints.inclusion) {
|
||||
constraints.inclusion.push(null)
|
||||
constraints.inclusion.push(null, "")
|
||||
}
|
||||
let res
|
||||
|
||||
|
@ -137,8 +137,8 @@ export function cleanExportRows(
|
|||
delete schema[column]
|
||||
})
|
||||
|
||||
// Intended to avoid 'undefined' in export
|
||||
if (format === Format.CSV) {
|
||||
// Intended to append empty values in export
|
||||
const schemaKeys = Object.keys(schema)
|
||||
for (let key of schemaKeys) {
|
||||
if (columns?.length && columns.indexOf(key) > 0) {
|
||||
|
@ -146,7 +146,7 @@ export function cleanExportRows(
|
|||
}
|
||||
for (let row of cleanRows) {
|
||||
if (row[key] == null) {
|
||||
row[key] = ""
|
||||
row[key] = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { getDatasourceParams } from "../../../db/utils"
|
|||
import { context, events } from "@budibase/backend-core"
|
||||
import { Table, UserCtx } from "@budibase/types"
|
||||
import sdk from "../../../sdk"
|
||||
import csv from "csvtojson"
|
||||
import { jsonFromCsvString } from "../../../utilities/csv"
|
||||
|
||||
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
||||
if (table && !tableId) {
|
||||
|
@ -97,6 +97,7 @@ export async function bulkImport(ctx: UserCtx) {
|
|||
// right now we don't trigger anything for bulk import because it
|
||||
// can only be done in the builder, but in the future we may need to
|
||||
// think about events for bulk items
|
||||
|
||||
ctx.status = 200
|
||||
ctx.body = { message: `Bulk rows created.` }
|
||||
}
|
||||
|
@ -104,7 +105,7 @@ export async function bulkImport(ctx: UserCtx) {
|
|||
export async function csvToJson(ctx: UserCtx) {
|
||||
const { csvString } = ctx.request.body
|
||||
|
||||
const result = await csv().fromString(csvString)
|
||||
const result = await jsonFromCsvString(csvString)
|
||||
|
||||
ctx.status = 200
|
||||
ctx.body = result
|
||||
|
|
|
@ -184,8 +184,13 @@ export async function destroy(ctx: any) {
|
|||
}
|
||||
|
||||
export async function bulkImport(ctx: any) {
|
||||
const db = context.getAppDB()
|
||||
const table = await sdk.tables.getTable(ctx.params.tableId)
|
||||
const { rows } = ctx.request.body
|
||||
await handleDataImport(ctx.user, table, rows)
|
||||
|
||||
// Ensure auto id and other table updates are persisted
|
||||
await db.put(table)
|
||||
|
||||
return table
|
||||
}
|
||||
|
|
|
@ -129,17 +129,17 @@ export function importToRows(
|
|||
// the real schema of the table passed in, not the clone used for
|
||||
// incrementing auto IDs
|
||||
for (const [fieldName, schema] of Object.entries(originalTable.schema)) {
|
||||
const rowVal = Array.isArray(row[fieldName])
|
||||
? row[fieldName]
|
||||
: [row[fieldName]]
|
||||
if (
|
||||
(schema.type === FieldTypes.OPTIONS ||
|
||||
schema.type === FieldTypes.ARRAY) &&
|
||||
row[fieldName] &&
|
||||
(!schema.constraints!.inclusion ||
|
||||
schema.constraints!.inclusion.indexOf(row[fieldName]) === -1)
|
||||
row[fieldName]
|
||||
) {
|
||||
schema.constraints!.inclusion = [
|
||||
...schema.constraints!.inclusion!,
|
||||
row[fieldName],
|
||||
]
|
||||
let merged = [...schema.constraints!.inclusion!, ...rowVal]
|
||||
let superSet = new Set(merged)
|
||||
schema.constraints!.inclusion = Array.from(superSet)
|
||||
schema.constraints!.inclusion.sort()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,9 @@ export function csv(headers: string[], rows: Row[]) {
|
|||
val =
|
||||
typeof val === "object" && !(val instanceof Date)
|
||||
? `"${JSON.stringify(val).replace(/"/g, "'")}"`
|
||||
: `"${val}"`
|
||||
: val !== undefined
|
||||
? `"${val}"`
|
||||
: ""
|
||||
return val.trim()
|
||||
})
|
||||
.join(",")}`
|
||||
|
|
|
@ -42,8 +42,18 @@ if (!env.isTest()) {
|
|||
host: REDIS_OPTS.host,
|
||||
port: REDIS_OPTS.port,
|
||||
},
|
||||
password: REDIS_OPTS.opts.password,
|
||||
database: 1,
|
||||
}
|
||||
|
||||
if (REDIS_OPTS.opts?.password || REDIS_OPTS.opts.redisOptions?.password) {
|
||||
// @ts-ignore
|
||||
options.password =
|
||||
REDIS_OPTS.opts.password || REDIS_OPTS.opts.redisOptions.password
|
||||
}
|
||||
|
||||
if (!env.REDIS_CLUSTERED) {
|
||||
// @ts-ignore
|
||||
// Can't set direct redis db in clustered env
|
||||
options.database = 1
|
||||
}
|
||||
}
|
||||
rateLimitStore = new Stores.Redis(options)
|
||||
|
|
|
@ -73,18 +73,97 @@ describe("run misc tests", () => {
|
|||
type: "string",
|
||||
},
|
||||
},
|
||||
e: {
|
||||
name: "Auto ID",
|
||||
type: "number",
|
||||
subtype: "autoID",
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
type: "number",
|
||||
presence: false,
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
f: {
|
||||
type: "array",
|
||||
constraints: {
|
||||
type: "array",
|
||||
presence: {
|
||||
"allowEmpty": true
|
||||
},
|
||||
inclusion: [
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
]
|
||||
},
|
||||
name: "Sample Tags",
|
||||
sortable: false
|
||||
},
|
||||
g: {
|
||||
type: "options",
|
||||
constraints: {
|
||||
type: "string",
|
||||
presence: false,
|
||||
inclusion: [
|
||||
"Alpha",
|
||||
"Beta",
|
||||
"Gamma"
|
||||
]
|
||||
},
|
||||
name: "Sample Opts"
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Shift specific row tests to the row spec
|
||||
await tableUtils.handleDataImport(
|
||||
{ userId: "test" },
|
||||
table,
|
||||
[{ a: '1', b: '2', c: '3', d: '4'}]
|
||||
[
|
||||
{ a: '1', b: '2', c: '3', d: '4', f: "['One']", g: "Alpha" },
|
||||
{ a: '5', b: '6', c: '7', d: '8', f: "[]", g: undefined},
|
||||
{ a: '9', b: '10', c: '11', d: '12', f: "['Two','Four']", g: ""},
|
||||
{ a: '13', b: '14', c: '15', d: '16', g: "Omega"}
|
||||
]
|
||||
)
|
||||
|
||||
// 4 rows imported, the auto ID starts at 1
|
||||
// We expect the handleDataImport function to update the lastID
|
||||
expect(table.schema.e.lastID).toEqual(4);
|
||||
|
||||
// Array/Multi - should have added a new value to the inclusion.
|
||||
expect(table.schema.f.constraints.inclusion).toEqual(['Four','One','Three','Two']);
|
||||
|
||||
// Options - should have a new value in the inclusion
|
||||
expect(table.schema.g.constraints.inclusion).toEqual(['Alpha','Beta','Gamma','Omega']);
|
||||
|
||||
const rows = await config.getRows()
|
||||
expect(rows[0].a).toEqual("1")
|
||||
expect(rows[0].b).toEqual("2")
|
||||
expect(rows[0].c).toEqual("3")
|
||||
expect(rows.length).toEqual(4);
|
||||
|
||||
const rowOne = rows.find(row => row.e === 1)
|
||||
expect(rowOne.a).toEqual("1")
|
||||
expect(rowOne.f).toEqual(['One'])
|
||||
expect(rowOne.g).toEqual('Alpha')
|
||||
|
||||
const rowTwo = rows.find(row => row.e === 2)
|
||||
expect(rowTwo.a).toEqual("5")
|
||||
expect(rowTwo.f).toEqual([])
|
||||
expect(rowTwo.g).toEqual(undefined)
|
||||
|
||||
const rowThree = rows.find(row => row.e === 3)
|
||||
expect(rowThree.a).toEqual("9")
|
||||
expect(rowThree.f).toEqual(['Two','Four'])
|
||||
expect(rowThree.g).toEqual(null)
|
||||
|
||||
const rowFour = rows.find(row => row.e === 4)
|
||||
expect(rowFour.a).toEqual("13")
|
||||
expect(rowFour.f).toEqual(undefined)
|
||||
expect(rowFour.g).toEqual('Omega')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -34,9 +34,9 @@ describe("/rows", () => {
|
|||
row = basicRow(table._id)
|
||||
})
|
||||
|
||||
const loadRow = async (id, status = 200) =>
|
||||
const loadRow = async (id, tbl_Id, status = 200) =>
|
||||
await request
|
||||
.get(`/api/${table._id}/rows/${id}`)
|
||||
.get(`/api/${tbl_Id}/rows/${id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(status)
|
||||
|
@ -79,6 +79,60 @@ describe("/rows", () => {
|
|||
await assertQueryUsage(queryUsage + 1)
|
||||
})
|
||||
|
||||
it("Increment row autoId per create row request", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const queryUsage = await getQueryUsage()
|
||||
|
||||
const newTable = await config.createTable({
|
||||
name: "TestTableAuto",
|
||||
type: "table",
|
||||
key: "name",
|
||||
schema: {
|
||||
...table.schema,
|
||||
"Row ID": {
|
||||
name: "Row ID",
|
||||
type: "number",
|
||||
subtype: "autoID",
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
type: "number",
|
||||
presence: false,
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const ids = [1,2,3]
|
||||
|
||||
// Performing several create row requests should increment the autoID fields accordingly
|
||||
const createRow = async (id) => {
|
||||
const res = await request
|
||||
.post(`/api/${newTable._id}/rows`)
|
||||
.send({
|
||||
name: "row_" + id
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
expect(res.res.statusMessage).toEqual(`${newTable.name} saved successfully`)
|
||||
expect(res.body.name).toEqual("row_" + id)
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(res.body["Row ID"]).toEqual(id)
|
||||
}
|
||||
|
||||
for (let i=0; i<ids.length; i++ ){
|
||||
await createRow(ids[i])
|
||||
}
|
||||
|
||||
await assertRowUsage(rowUsage + ids.length)
|
||||
await assertQueryUsage(queryUsage + ids.length)
|
||||
})
|
||||
|
||||
it("updates a row successfully", async () => {
|
||||
const existing = await config.createRow()
|
||||
const rowUsage = await getRowUsage()
|
||||
|
@ -182,8 +236,32 @@ describe("/rows", () => {
|
|||
type: "string",
|
||||
presence: false,
|
||||
datetime: { earliest: "", latest: "" },
|
||||
},
|
||||
}
|
||||
}
|
||||
const arrayField = {
|
||||
type: "array",
|
||||
constraints: {
|
||||
type: "array",
|
||||
presence: false,
|
||||
inclusion: [
|
||||
"One",
|
||||
"Two",
|
||||
"Three",
|
||||
]
|
||||
},
|
||||
name: "Sample Tags",
|
||||
sortable: false
|
||||
}
|
||||
const optsField = {
|
||||
fieldName: "Sample Opts",
|
||||
name: "Sample Opts",
|
||||
type: "options",
|
||||
constraints: {
|
||||
type: "string",
|
||||
presence: false,
|
||||
inclusion: [ "Alpha", "Beta", "Gamma" ]
|
||||
},
|
||||
},
|
||||
|
||||
table = await config.createTable({
|
||||
name: "TestTable2",
|
||||
|
@ -212,6 +290,15 @@ describe("/rows", () => {
|
|||
attachmentNull: attachment,
|
||||
attachmentUndefined: attachment,
|
||||
attachmentEmpty: attachment,
|
||||
attachmentEmptyArrayStr: attachment,
|
||||
arrayFieldEmptyArrayStr: arrayField,
|
||||
arrayFieldArrayStrKnown: arrayField,
|
||||
arrayFieldNull: arrayField,
|
||||
arrayFieldUndefined: arrayField,
|
||||
optsFieldEmptyStr: optsField,
|
||||
optsFieldUndefined: optsField,
|
||||
optsFieldNull: optsField,
|
||||
optsFieldStrKnown: optsField
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -239,11 +326,21 @@ describe("/rows", () => {
|
|||
attachmentNull: null,
|
||||
attachmentUndefined: undefined,
|
||||
attachmentEmpty: "",
|
||||
attachmentEmptyArrayStr: "[]",
|
||||
arrayFieldEmptyArrayStr: "[]",
|
||||
arrayFieldUndefined: undefined,
|
||||
arrayFieldNull: null,
|
||||
arrayFieldArrayStrKnown: "['One']",
|
||||
optsFieldEmptyStr: "",
|
||||
optsFieldUndefined: undefined,
|
||||
optsFieldNull: null,
|
||||
optsFieldStrKnown: 'Alpha'
|
||||
}
|
||||
|
||||
const id = (await config.createRow(row))._id
|
||||
const createdRow = await config.createRow(row);
|
||||
const id = createdRow._id
|
||||
|
||||
const saved = (await loadRow(id)).body
|
||||
const saved = (await loadRow(id, table._id)).body
|
||||
|
||||
expect(saved.stringUndefined).toBe(undefined)
|
||||
expect(saved.stringNull).toBe("")
|
||||
|
@ -268,6 +365,15 @@ describe("/rows", () => {
|
|||
expect(saved.attachmentNull).toEqual([])
|
||||
expect(saved.attachmentUndefined).toBe(undefined)
|
||||
expect(saved.attachmentEmpty).toEqual([])
|
||||
expect(saved.attachmentEmptyArrayStr).toEqual([])
|
||||
expect(saved.arrayFieldEmptyArrayStr).toEqual([])
|
||||
expect(saved.arrayFieldNull).toEqual([])
|
||||
expect(saved.arrayFieldUndefined).toEqual(undefined)
|
||||
expect(saved.optsFieldEmptyStr).toEqual(null)
|
||||
expect(saved.optsFieldUndefined).toEqual(undefined)
|
||||
expect(saved.optsFieldNull).toEqual(null)
|
||||
expect(saved.arrayFieldArrayStrKnown).toEqual(['One'])
|
||||
expect(saved.optsFieldStrKnown).toEqual('Alpha')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -296,7 +402,7 @@ describe("/rows", () => {
|
|||
expect(res.body.name).toEqual("Updated Name")
|
||||
expect(res.body.description).toEqual(existing.description)
|
||||
|
||||
const savedRow = await loadRow(res.body._id)
|
||||
const savedRow = await loadRow(res.body._id, table._id)
|
||||
|
||||
expect(savedRow.body.description).toEqual(existing.description)
|
||||
expect(savedRow.body.name).toEqual("Updated Name")
|
||||
|
@ -398,7 +504,7 @@ describe("/rows", () => {
|
|||
.expect(200)
|
||||
|
||||
expect(res.body.length).toEqual(2)
|
||||
await loadRow(row1._id, 404)
|
||||
await loadRow(row1._id, table._id, 404)
|
||||
await assertRowUsage(rowUsage - 2)
|
||||
await assertQueryUsage(queryUsage + 1)
|
||||
})
|
||||
|
|
|
@ -167,7 +167,10 @@ describe("/tables", () => {
|
|||
|
||||
expect(events.table.created).not.toHaveBeenCalled()
|
||||
expect(events.rows.imported).toBeCalledTimes(1)
|
||||
expect(events.rows.imported).toBeCalledWith(table, 1)
|
||||
expect(events.rows.imported).toBeCalledWith(expect.objectContaining({
|
||||
name: "TestTable",
|
||||
_id: table._id
|
||||
}), 1)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue