Merge branch 'develop' into feature/datasource-conns
This commit is contained in:
commit
4f3139a47b
|
@ -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_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_object "object-src 'none'";
|
||||||
set $csp_base_uri "base-uri 'self'";
|
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_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_frame "frame-src 'self' https:";
|
||||||
set $csp_img "img-src http: https: data: blob:";
|
set $csp_img "img-src http: https: data: blob:";
|
||||||
|
@ -82,6 +82,12 @@ http {
|
||||||
set $couchdb ${COUCHDB_UPSTREAM_URL};
|
set $couchdb ${COUCHDB_UPSTREAM_URL};
|
||||||
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
|
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
add_header 'Content-Type' 'application/json';
|
||||||
|
return 200 '{ "status": "OK" }';
|
||||||
|
}
|
||||||
|
|
||||||
location /app {
|
location /app {
|
||||||
proxy_pass $apps;
|
proxy_pass $apps;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.6.8-alpha.11",
|
"version": "2.6.16-alpha.2",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/backend-core",
|
"packages/backend-core",
|
||||||
|
|
|
@ -21,7 +21,7 @@ export enum ViewName {
|
||||||
AUTOMATION_LOGS = "automation_logs",
|
AUTOMATION_LOGS = "automation_logs",
|
||||||
ACCOUNT_BY_EMAIL = "account_by_email",
|
ACCOUNT_BY_EMAIL = "account_by_email",
|
||||||
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
|
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
|
||||||
USER_BY_GROUP = "by_group_user",
|
USER_BY_GROUP = "user_by_group",
|
||||||
APP_BACKUP_BY_TRIGGER = "by_trigger",
|
APP_BACKUP_BY_TRIGGER = "by_trigger",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -69,10 +69,10 @@ function findVersion() {
|
||||||
try {
|
try {
|
||||||
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
|
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
|
||||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||||
const version = JSON.parse(content).version
|
return JSON.parse(content).version
|
||||||
return version
|
|
||||||
} catch {
|
} 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,7 @@ const environment = {
|
||||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
||||||
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
|
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,
|
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
|
||||||
MOCK_REDIS: process.env.MOCK_REDIS,
|
MOCK_REDIS: process.env.MOCK_REDIS,
|
||||||
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import "@spectrum-css/button/dist/index-vars.css"
|
import "@spectrum-css/button/dist/index-vars.css"
|
||||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||||
|
|
||||||
|
export let type
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let size = "M"
|
export let size = "M"
|
||||||
export let cta = false
|
export let cta = false
|
||||||
|
@ -21,6 +22,7 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{id}
|
{id}
|
||||||
|
{type}
|
||||||
class:spectrum-Button--cta={cta}
|
class:spectrum-Button--cta={cta}
|
||||||
class:spectrum-Button--primary={primary}
|
class:spectrum-Button--primary={primary}
|
||||||
class:spectrum-Button--secondary={secondary}
|
class:spectrum-Button--secondary={secondary}
|
||||||
|
@ -73,6 +75,7 @@
|
||||||
button {
|
button {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spectrum-Button-label {
|
.spectrum-Button-label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
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>
|
<script>
|
||||||
import Icon from "../Icon/Icon.svelte"
|
import Icon from "../Icon/Icon.svelte"
|
||||||
import { getContext, onMount } from "svelte"
|
import { getContext, onMount } from "svelte"
|
||||||
import { slide } from "svelte/transition"
|
import ErrorMessage from "./ErrorMessage.svelte"
|
||||||
|
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let error = null
|
export let error = null
|
||||||
|
@ -55,9 +55,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if error}
|
{#if error}
|
||||||
<div transition:slide|local={{ duration: 130 }} class="error-message">
|
<ErrorMessage {error} />
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -110,13 +108,6 @@
|
||||||
.field {
|
.field {
|
||||||
flex: 1 1 auto;
|
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 {
|
.error-icon {
|
||||||
flex: 0 0 auto;
|
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 FancyButton } from "./FancyButton.svelte"
|
||||||
export { default as FancyForm } from "./FancyForm.svelte"
|
export { default as FancyForm } from "./FancyForm.svelte"
|
||||||
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
|
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
|
||||||
|
export { default as ErrorMessage } from "./ErrorMessage.svelte"
|
||||||
|
|
|
@ -62,6 +62,7 @@
|
||||||
"@budibase/frontend-core": "0.0.1",
|
"@budibase/frontend-core": "0.0.1",
|
||||||
"@budibase/shared-core": "0.0.1",
|
"@budibase/shared-core": "0.0.1",
|
||||||
"@budibase/string-templates": "0.0.1",
|
"@budibase/string-templates": "0.0.1",
|
||||||
|
"@budibase/types": "0.0.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
export let disableSorting = false
|
export let disableSorting = false
|
||||||
export let customPlaceholder = false
|
export let customPlaceholder = false
|
||||||
export let allowClickRows
|
export let allowClickRows
|
||||||
|
export let allowEditing = true
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -110,6 +111,7 @@
|
||||||
{rowCount}
|
{rowCount}
|
||||||
{disableSorting}
|
{disableSorting}
|
||||||
{customPlaceholder}
|
{customPlaceholder}
|
||||||
|
allowEditRows={allowEditing}
|
||||||
showAutoColumns={!hideAutocolumns}
|
showAutoColumns={!hideAutocolumns}
|
||||||
{allowClickRows}
|
{allowClickRows}
|
||||||
on:clickrelationship={e => selectRelationship(e.detail)}
|
on:clickrelationship={e => selectRelationship(e.detail)}
|
||||||
|
|
|
@ -58,6 +58,7 @@
|
||||||
{loading}
|
{loading}
|
||||||
{type}
|
{type}
|
||||||
rowCount={10}
|
rowCount={10}
|
||||||
|
allowEditing={false}
|
||||||
bind:hideAutocolumns
|
bind:hideAutocolumns
|
||||||
>
|
>
|
||||||
<ViewFilterButton {view} />
|
<ViewFilterButton {view} />
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
import NavItem from "components/common/NavItem.svelte"
|
import NavItem from "components/common/NavItem.svelte"
|
||||||
import { goto, isActive } from "@roxi/routify"
|
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
|
export let sourceId
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
export let highlighted = false
|
export let highlighted = false
|
||||||
export let rightAlignIcon = false
|
export let rightAlignIcon = false
|
||||||
export let id
|
export let id
|
||||||
|
export let showTooltip = false
|
||||||
|
|
||||||
const scrollApi = getContext("scroll")
|
const scrollApi = getContext("scroll")
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -84,7 +85,7 @@
|
||||||
<Icon color={iconColor} size="S" name={icon} />
|
<Icon color={iconColor} size="S" name={icon} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text">{text}</div>
|
<div class="text" title={showTooltip ? text : null}>{text}</div>
|
||||||
{#if withActions}
|
{#if withActions}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
@ -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 { admin, auth, licensing } from "stores/portal"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { BANNER_TYPES } from "@budibase/bbui"
|
import { BANNER_TYPES } from "@budibase/bbui"
|
||||||
import { capitalise } from "helpers"
|
|
||||||
|
|
||||||
const oneDayInSeconds = 86400
|
const oneDayInSeconds = 86400
|
||||||
|
|
||||||
|
@ -146,23 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
|
||||||
const userLicensing = get(licensing)
|
const userLicensing = get(licensing)
|
||||||
return {
|
return {
|
||||||
key: EXPIRY_KEY,
|
key: EXPIRY_KEY,
|
||||||
type: BANNER_TYPES.WARNING,
|
type: BANNER_TYPES.NEGATIVE,
|
||||||
onChange: () => {
|
onChange: () => {
|
||||||
defaultCacheFn(EXPIRY_KEY)
|
defaultCacheFn(EXPIRY_KEY)
|
||||||
},
|
},
|
||||||
criteria: () => {
|
criteria: () => {
|
||||||
return userLicensing.warnUserLimit
|
return userLicensing.errUserLimit
|
||||||
},
|
},
|
||||||
message: `${capitalise(
|
message: "Your Budibase account is de-activated. Upgrade your plan",
|
||||||
userLicensing.license.plan.type
|
|
||||||
)} plan changes - Users will be limited to ${
|
|
||||||
userLicensing.userLimit
|
|
||||||
} users in ${userLicensing.userLimitDays}`,
|
|
||||||
...{
|
...{
|
||||||
extraButtonText: "Find out more",
|
extraButtonText: "View plans",
|
||||||
extraButtonAction: () => {
|
extraButtonAction: () => {
|
||||||
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
|
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
|
||||||
window.location.href = "/builder/portal/users/users"
|
window.location.href = "https://budibase.com/pricing/"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
export let app
|
export let app
|
||||||
|
|
||||||
|
export let lockedAction
|
||||||
|
|
||||||
const handleDefaultClick = () => {
|
const handleDefaultClick = () => {
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
goToOverview()
|
goToOverview()
|
||||||
|
@ -29,7 +31,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-row" on:click={handleDefaultClick}>
|
<div class="app-row" on:click={lockedAction || handleDefaultClick}>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<div class="app-icon">
|
<div class="app-icon">
|
||||||
<Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} />
|
<Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} />
|
||||||
|
@ -58,8 +60,11 @@
|
||||||
|
|
||||||
<div class="app-row-actions">
|
<div class="app-row-actions">
|
||||||
<AppLockModal {app} buttonSize="M" />
|
<AppLockModal {app} buttonSize="M" />
|
||||||
<Button size="S" secondary on:click={goToOverview}>Manage</Button>
|
<Button size="S" secondary on:click={lockedAction || goToOverview}
|
||||||
<Button size="S" primary on:click={goToBuilder}>Edit</Button>
|
>Manage</Button
|
||||||
|
>
|
||||||
|
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -28,13 +28,16 @@
|
||||||
let inviting = false
|
let inviting = false
|
||||||
let searchFocus = false
|
let searchFocus = false
|
||||||
|
|
||||||
|
// Initially filter entities without app access
|
||||||
|
// Show all when false
|
||||||
|
let filterByAppAccess = true
|
||||||
|
|
||||||
let appInvites = []
|
let appInvites = []
|
||||||
let filteredInvites = []
|
let filteredInvites = []
|
||||||
let filteredUsers = []
|
let filteredUsers = []
|
||||||
let filteredGroups = []
|
let filteredGroups = []
|
||||||
let selectedGroup
|
let selectedGroup
|
||||||
let userOnboardResponse = null
|
let userOnboardResponse = null
|
||||||
|
|
||||||
let userLimitReachedModal
|
let userLimitReachedModal
|
||||||
|
|
||||||
$: queryIsEmail = emailValidator(query) === true
|
$: queryIsEmail = emailValidator(query) === true
|
||||||
|
@ -52,15 +55,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterInvites = async query => {
|
const filterInvites = async query => {
|
||||||
appInvites = await getInvites()
|
if (!prodAppId) {
|
||||||
if (!query || query == "") {
|
|
||||||
filteredInvites = appInvites
|
|
||||||
return
|
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({
|
const usersFetch = fetchData({
|
||||||
API,
|
API,
|
||||||
|
@ -79,9 +99,9 @@
|
||||||
}
|
}
|
||||||
await usersFetch.update({
|
await usersFetch.update({
|
||||||
query: {
|
query: {
|
||||||
appId: query ? null : prodAppId,
|
appId: query || !filterByAppAccess ? null : prodAppId,
|
||||||
email: query,
|
email: query,
|
||||||
paginated: query ? null : false,
|
paginated: query || !filterByAppAccess ? null : false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await usersFetch.refresh()
|
await usersFetch.refresh()
|
||||||
|
@ -107,7 +127,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
||||||
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
|
$: debouncedUpdateFetch(
|
||||||
|
query,
|
||||||
|
$store.builderSidePanel,
|
||||||
|
loaded,
|
||||||
|
filterByAppAccess
|
||||||
|
)
|
||||||
|
|
||||||
const updateAppUser = async (user, role) => {
|
const updateAppUser = async (user, role) => {
|
||||||
if (!prodAppId) {
|
if (!prodAppId) {
|
||||||
|
@ -182,9 +207,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchGroups = (userGroups, query) => {
|
const searchGroups = (userGroups, query) => {
|
||||||
let filterGroups = query?.length
|
let filterGroups =
|
||||||
? userGroups
|
query?.length || !filterByAppAccess
|
||||||
: getAppGroups(userGroups, prodAppId)
|
? userGroups
|
||||||
|
: getAppGroups(userGroups, prodAppId)
|
||||||
return filterGroups
|
return filterGroups
|
||||||
.filter(group => {
|
.filter(group => {
|
||||||
if (!query?.length) {
|
if (!query?.length) {
|
||||||
|
@ -214,7 +240,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds the 'role' attribute and sets it to the current app.
|
// Adds the 'role' attribute and sets it to the current app.
|
||||||
$: enrichedGroups = getEnrichedGroups($groups)
|
$: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
|
||||||
$: filteredGroups = searchGroups(enrichedGroups, query)
|
$: filteredGroups = searchGroups(enrichedGroups, query)
|
||||||
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
||||||
$: allUsers = [...filteredUsers, ...groupUsers]
|
$: allUsers = [...filteredUsers, ...groupUsers]
|
||||||
|
@ -226,7 +252,7 @@
|
||||||
specific roles for the app.
|
specific roles for the app.
|
||||||
*/
|
*/
|
||||||
const buildGroupUsers = (userGroups, filteredUsers) => {
|
const buildGroupUsers = (userGroups, filteredUsers) => {
|
||||||
if (query) {
|
if (query || !filterByAppAccess) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
// Must exclude users who have explicit privileges
|
// Must exclude users who have explicit privileges
|
||||||
|
@ -321,12 +347,12 @@
|
||||||
[prodAppId]: role,
|
[prodAppId]: role,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await filterInvites()
|
await filterInvites(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUninviteAppUser = async invite => {
|
const onUninviteAppUser = async invite => {
|
||||||
await uninviteAppUser(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?
|
// Purge only the app from the invite or recind the invite if only 1 app remains?
|
||||||
|
@ -351,7 +377,6 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
rendered = true
|
rendered = true
|
||||||
searchFocus = true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleKeyDown(evt) {
|
function handleKeyDown(evt) {
|
||||||
|
@ -417,7 +442,6 @@
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
disabled={inviting}
|
disabled={inviting}
|
||||||
value={query}
|
value={query}
|
||||||
autofocus
|
|
||||||
on:input={e => {
|
on:input={e => {
|
||||||
query = e.target.value.trim()
|
query = e.target.value.trim()
|
||||||
}}
|
}}
|
||||||
|
@ -428,16 +452,20 @@
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="search-input-icon"
|
class="search-input-icon"
|
||||||
class:searching={query}
|
class:searching={query || !filterByAppAccess}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
if (!filterByAppAccess) {
|
||||||
|
filterByAppAccess = true
|
||||||
|
}
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
query = null
|
query = null
|
||||||
userOnboardResponse = null
|
userOnboardResponse = null
|
||||||
|
filterByAppAccess = true
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name={query ? "Close" : "Search"} />
|
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,7 @@
|
||||||
text={screen.routing.route}
|
text={screen.routing.route}
|
||||||
on:click={() => store.actions.screens.select(screen._id)}
|
on:click={() => store.actions.screens.select(screen._id)}
|
||||||
rightAlignIcon
|
rightAlignIcon
|
||||||
|
showTooltip
|
||||||
>
|
>
|
||||||
<ScreenDropdownMenu screenId={screen._id} />
|
<ScreenDropdownMenu screenId={screen._id} />
|
||||||
<RoleIndicator slot="right" roleId={screen.routing.roleId} />
|
<RoleIndicator slot="right" roleId={screen.routing.roleId} />
|
||||||
|
|
|
@ -133,7 +133,7 @@
|
||||||
</Body>
|
</Body>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Divider />
|
<Divider />
|
||||||
{#if $licensing.usageMetrics?.dayPasses >= 100}
|
{#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit}
|
||||||
<div>
|
<div>
|
||||||
<Layout gap="S" justifyItems="center">
|
<Layout gap="S" justifyItems="center">
|
||||||
<img class="spaceman" alt="spaceman" src={Spaceman} />
|
<img class="spaceman" alt="spaceman" src={Spaceman} />
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
import Spinner from "components/common/Spinner.svelte"
|
import Spinner from "components/common/Spinner.svelte"
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||||
|
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
|
||||||
|
|
||||||
import { store, automationStore } from "builderStore"
|
import { store, automationStore } from "builderStore"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -28,6 +29,7 @@
|
||||||
let template
|
let template
|
||||||
let creationModal
|
let creationModal
|
||||||
let appLimitModal
|
let appLimitModal
|
||||||
|
let accountLockedModal
|
||||||
let creatingApp = false
|
let creatingApp = false
|
||||||
let searchTerm = ""
|
let searchTerm = ""
|
||||||
let creatingFromTemplate = false
|
let creatingFromTemplate = false
|
||||||
|
@ -48,6 +50,11 @@
|
||||||
: true)
|
: true)
|
||||||
)
|
)
|
||||||
$: automationErrors = getAutomationErrors(enrichedApps)
|
$: automationErrors = getAutomationErrors(enrichedApps)
|
||||||
|
$: isOwner = $auth.accountPortalAccess && $admin.cloud
|
||||||
|
|
||||||
|
const usersLimitLockAction = $licensing?.errUserLimit
|
||||||
|
? () => accountLockedModal.show()
|
||||||
|
: null
|
||||||
|
|
||||||
const enrichApps = (apps, user, sortBy) => {
|
const enrichApps = (apps, user, sortBy) => {
|
||||||
const enrichedApps = apps.map(app => ({
|
const enrichedApps = apps.map(app => ({
|
||||||
|
@ -189,6 +196,9 @@
|
||||||
creatingFromTemplate = true
|
creatingFromTemplate = true
|
||||||
createAppFromTemplateUrl(initInfo.init_template)
|
createAppFromTemplateUrl(initInfo.init_template)
|
||||||
}
|
}
|
||||||
|
if (usersLimitLockAction) {
|
||||||
|
usersLimitLockAction()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error getting init info")
|
notifications.error("Error getting init info")
|
||||||
}
|
}
|
||||||
|
@ -230,20 +240,30 @@
|
||||||
<Layout noPadding gap="L">
|
<Layout noPadding gap="L">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<Button size="M" cta on:click={initiateAppCreation}>
|
<Button
|
||||||
|
size="M"
|
||||||
|
cta
|
||||||
|
on:click={usersLimitLockAction || initiateAppCreation}
|
||||||
|
>
|
||||||
Create new app
|
Create new app
|
||||||
</Button>
|
</Button>
|
||||||
{#if $apps?.length > 0}
|
{#if $apps?.length > 0}
|
||||||
<Button
|
<Button
|
||||||
size="M"
|
size="M"
|
||||||
secondary
|
secondary
|
||||||
on:click={$goto("/builder/portal/apps/templates")}
|
on:click={usersLimitLockAction ||
|
||||||
|
$goto("/builder/portal/apps/templates")}
|
||||||
>
|
>
|
||||||
View templates
|
View templates
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !$apps?.length}
|
{#if !$apps?.length}
|
||||||
<Button size="L" quiet secondary on:click={initiateAppImport}>
|
<Button
|
||||||
|
size="L"
|
||||||
|
quiet
|
||||||
|
secondary
|
||||||
|
on:click={usersLimitLockAction || initiateAppImport}
|
||||||
|
>
|
||||||
Import app
|
Import app
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -267,7 +287,7 @@
|
||||||
|
|
||||||
<div class="app-table">
|
<div class="app-table">
|
||||||
{#each filteredApps as app (app.appId)}
|
{#each filteredApps as app (app.appId)}
|
||||||
<AppRow {app} />
|
<AppRow {app} lockedAction={usersLimitLockAction} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -294,6 +314,11 @@
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<AppLimitModal bind:this={appLimitModal} />
|
<AppLimitModal bind:this={appLimitModal} />
|
||||||
|
<AccountLockedModal
|
||||||
|
bind:this={accountLockedModal}
|
||||||
|
onConfirm={() =>
|
||||||
|
isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.title {
|
.title {
|
||||||
|
|
|
@ -107,8 +107,9 @@
|
||||||
useSampleData,
|
useSampleData,
|
||||||
isGoogle,
|
isGoogle,
|
||||||
}) => {
|
}) => {
|
||||||
|
let app
|
||||||
try {
|
try {
|
||||||
const app = await createApp(useSampleData)
|
app = await createApp(useSampleData)
|
||||||
|
|
||||||
let datasource
|
let datasource
|
||||||
if (datasourceConfig) {
|
if (datasourceConfig) {
|
||||||
|
@ -134,6 +135,17 @@
|
||||||
console.log(e)
|
console.log(e)
|
||||||
creationLoading = false
|
creationLoading = false
|
||||||
notifications.error("There was a problem creating your app")
|
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>
|
</script>
|
||||||
|
@ -146,80 +158,87 @@
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<SplitPage>
|
<div class="full-width">
|
||||||
{#if stage === "name"}
|
<SplitPage>
|
||||||
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
{#if stage === "name"}
|
||||||
{:else if googleComplete}
|
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
||||||
<div class="centered">
|
{:else if googleComplete}
|
||||||
<Body
|
<div class="centered">
|
||||||
>Please login to your Google account in the new tab which as opened to
|
<Body
|
||||||
continue.</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>
|
</div>
|
||||||
<div class="dataButton">
|
{:else if integrationsLoading || creationLoading}
|
||||||
<FancyButton on:click={uploadModal.show}>
|
<div class="centered">
|
||||||
<div class="dataButtonContent">
|
<Spinner />
|
||||||
<div class="dataButtonIcon">
|
|
||||||
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
|
|
||||||
</div>
|
|
||||||
Upload data (CSV or JSON)
|
|
||||||
</div>
|
|
||||||
</FancyButton>
|
|
||||||
</div>
|
</div>
|
||||||
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
|
{:else if stage === "data"}
|
||||||
|
<DataPanel onBack={() => (stage = "name")}>
|
||||||
<div class="dataButton">
|
<div class="dataButton">
|
||||||
<FancyButton on:click={() => (stage = integrationType)}>
|
<FancyButton
|
||||||
|
on:click={() => handleCreateApp({ useSampleData: true })}
|
||||||
|
>
|
||||||
<div class="dataButtonContent">
|
<div class="dataButtonContent">
|
||||||
<div class="dataButtonIcon">
|
<div class="dataButtonIcon">
|
||||||
<IntegrationIcon {integrationType} {schema} />
|
<img
|
||||||
|
alt="Budibase Logo"
|
||||||
|
class="budibaseLogo"
|
||||||
|
src={"https://i.imgur.com/Xhdt1YP.png"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{schema.friendlyName}
|
Budibase Sample data
|
||||||
</div>
|
</div>
|
||||||
</FancyButton>
|
</FancyButton>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div class="dataButton">
|
||||||
</DataPanel>
|
<FancyButton on:click={uploadModal.show}>
|
||||||
{:else if stage in plusIntegrations}
|
<div class="dataButtonContent">
|
||||||
<DatasourceConfigPanel
|
<div class="dataButtonIcon">
|
||||||
title={plusIntegrations[stage].friendlyName}
|
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
|
||||||
fields={plusIntegrations[stage].datasource}
|
</div>
|
||||||
type={stage}
|
Upload data (CSV or JSON)
|
||||||
onBack={() => (stage = "data")}
|
</div>
|
||||||
onNext={data => {
|
</FancyButton>
|
||||||
const isGoogle = data.isGoogle
|
</div>
|
||||||
delete data.isGoogle
|
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
|
||||||
return handleCreateApp({ datasourceConfig: data, isGoogle })
|
<div class="dataButton">
|
||||||
}}
|
<FancyButton on:click={() => (stage = integrationType)}>
|
||||||
/>
|
<div class="dataButtonContent">
|
||||||
{:else}
|
<div class="dataButtonIcon">
|
||||||
<p>There was an problem. Please refresh the page and try again.</p>
|
<IntegrationIcon {integrationType} {schema} />
|
||||||
{/if}
|
</div>
|
||||||
<div slot="right">
|
{schema.friendlyName}
|
||||||
<ExampleApp {name} showData={stage !== "name"} />
|
</div>
|
||||||
</div>
|
</FancyButton>
|
||||||
</SplitPage>
|
</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>
|
<style>
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.centered {
|
.centered {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import EditUserPicker from "./EditUserPicker.svelte"
|
import EditUserPicker from "./EditUserPicker.svelte"
|
||||||
|
|
||||||
import { Heading, Pagination, Table } from "@budibase/bbui"
|
import { Heading, Pagination, Table, Search } from "@budibase/bbui"
|
||||||
import { fetchData } from "@budibase/frontend-core"
|
import { fetchData } from "@budibase/frontend-core"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -12,7 +12,9 @@
|
||||||
|
|
||||||
export let groupId
|
export let groupId
|
||||||
|
|
||||||
const fetchGroupUsers = fetchData({
|
let emailSearch
|
||||||
|
let fetchGroupUsers
|
||||||
|
$: fetchGroupUsers = fetchData({
|
||||||
API,
|
API,
|
||||||
datasource: {
|
datasource: {
|
||||||
type: "groupUser",
|
type: "groupUser",
|
||||||
|
@ -20,6 +22,7 @@
|
||||||
options: {
|
options: {
|
||||||
query: {
|
query: {
|
||||||
groupId,
|
groupId,
|
||||||
|
emailSearch,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -59,24 +62,31 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<Heading size="S">Users</Heading>
|
|
||||||
{#if !scimEnabled}
|
{#if !scimEnabled}
|
||||||
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
|
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
|
||||||
{:else}
|
{:else}
|
||||||
<ScimBanner />
|
<ScimBanner />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="controls-right">
|
||||||
|
<Search bind:value={emailSearch} placeholder="Search email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
schema={userSchema}
|
schema={userSchema}
|
||||||
data={$fetchGroupUsers?.rows}
|
data={$fetchGroupUsers?.rows}
|
||||||
|
loading={$fetchGroupUsers.loading}
|
||||||
allowEditRows={false}
|
allowEditRows={false}
|
||||||
customPlaceholder
|
customPlaceholder
|
||||||
customRenderers={customUserTableRenderers}
|
customRenderers={customUserTableRenderers}
|
||||||
on:click={e => $goto(`../users/${e.detail._id}`)}
|
on:click={e => $goto(`../users/${e.detail._id}`)}
|
||||||
>
|
>
|
||||||
<div class="placeholder" slot="placeholder">
|
<div class="placeholder" slot="placeholder">
|
||||||
<Heading size="S">This user group doesn't have any users</Heading>
|
<Heading size="S"
|
||||||
|
>{emailSearch
|
||||||
|
? `No users found matching the email "${emailSearch}"`
|
||||||
|
: "This user group doesn't have any users"}</Heading
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
@ -98,7 +108,7 @@
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-l);
|
gap: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
@ -109,4 +119,15 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|
|
@ -30,8 +30,8 @@
|
||||||
$: hasError = userData.find(x => x.error != null)
|
$: hasError = userData.find(x => x.error != null)
|
||||||
|
|
||||||
$: userCount = $licensing.userCount + userData.length
|
$: userCount = $licensing.userCount + userData.length
|
||||||
$: willReach = licensing.willReachUserLimit(userCount)
|
$: reached = licensing.usersLimitReached(userCount)
|
||||||
$: willExceed = licensing.willExceedUserLimit(userCount)
|
$: exceeded = licensing.usersLimitExceeded(userCount)
|
||||||
|
|
||||||
function removeInput(idx) {
|
function removeInput(idx) {
|
||||||
userData = userData.filter((e, i) => i !== idx)
|
userData = userData.filter((e, i) => i !== idx)
|
||||||
|
@ -87,7 +87,7 @@
|
||||||
confirmDisabled={disabled}
|
confirmDisabled={disabled}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
showCloseIcon={false}
|
showCloseIcon={false}
|
||||||
disabled={hasError || !userData.length || willExceed}
|
disabled={hasError || !userData.length || exceeded}
|
||||||
>
|
>
|
||||||
<Layout noPadding gap="XS">
|
<Layout noPadding gap="XS">
|
||||||
<Label>Email address</Label>
|
<Label>Email address</Label>
|
||||||
|
@ -118,7 +118,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if willReach}
|
{#if reached}
|
||||||
<div class="user-notification">
|
<div class="user-notification">
|
||||||
<Icon name="Info" />
|
<Icon name="Info" />
|
||||||
<span>
|
<span>
|
||||||
|
|
|
@ -25,10 +25,10 @@
|
||||||
$: invalidEmails = []
|
$: invalidEmails = []
|
||||||
|
|
||||||
$: userCount = $licensing.userCount + userEmails.length
|
$: userCount = $licensing.userCount + userEmails.length
|
||||||
$: willExceed = licensing.willExceedUserLimit(userCount)
|
$: exceed = licensing.usersLimitExceeded(userCount)
|
||||||
|
|
||||||
$: importDisabled =
|
$: importDisabled =
|
||||||
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed
|
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed
|
||||||
|
|
||||||
const validEmails = userEmails => {
|
const validEmails = userEmails => {
|
||||||
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if willExceed}
|
{#if exceed}
|
||||||
<div class="user-notification">
|
<div class="user-notification">
|
||||||
<Icon name="Info" />
|
<Icon name="Info" />
|
||||||
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
|
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
|
||||||
|
|
|
@ -268,8 +268,6 @@
|
||||||
notifications.error("Error fetching user group data")
|
notifications.error("Error fetching user group data")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let staticUserLimit = $licensing.license.quotas.usage.static.users.value
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout noPadding gap="M">
|
<Layout noPadding gap="M">
|
||||||
|
@ -278,7 +276,7 @@
|
||||||
<Body>Add users and control who gets access to your published apps</Body>
|
<Body>Add users and control who gets access to your published apps</Body>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Divider />
|
<Divider />
|
||||||
{#if $licensing.warnUserLimit}
|
{#if $licensing.errUserLimit}
|
||||||
<InlineAlert
|
<InlineAlert
|
||||||
type="error"
|
type="error"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
@ -290,13 +288,9 @@
|
||||||
}}
|
}}
|
||||||
buttonText={isOwner ? "Upgrade" : "View plans"}
|
buttonText={isOwner ? "Upgrade" : "View plans"}
|
||||||
cta
|
cta
|
||||||
header={`Users will soon be limited to ${staticUserLimit}`}
|
header="Account de-activated"
|
||||||
message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}.
|
message="Due to the free plan user limit being exceeded, your account has been de-activated.
|
||||||
|
Upgrade your plan to re-activate your account."
|
||||||
This means any users exceeding the limit will be 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.
|
|
||||||
`}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { auth, admin } from "stores/portal"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import { StripeStatus } from "components/portal/licensing/constants"
|
import { StripeStatus } from "components/portal/licensing/constants"
|
||||||
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
||||||
import dayjs from "dayjs"
|
import { PlanModel } from "@budibase/types"
|
||||||
|
|
||||||
const UNLIMITED = -1
|
const UNLIMITED = -1
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ export const createLicensingStore = () => {
|
||||||
const DEFAULT = {
|
const DEFAULT = {
|
||||||
// navigation
|
// navigation
|
||||||
goToUpgradePage: () => {},
|
goToUpgradePage: () => {},
|
||||||
|
goToPricingPage: () => {},
|
||||||
// the top level license
|
// the top level license
|
||||||
license: undefined,
|
license: undefined,
|
||||||
isFreePlan: true,
|
isFreePlan: true,
|
||||||
|
@ -37,29 +38,37 @@ export const createLicensingStore = () => {
|
||||||
// user limits
|
// user limits
|
||||||
userCount: undefined,
|
userCount: undefined,
|
||||||
userLimit: undefined,
|
userLimit: undefined,
|
||||||
userLimitDays: undefined,
|
|
||||||
userLimitReached: false,
|
userLimitReached: false,
|
||||||
warnUserLimit: false,
|
errUserLimit: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneDayInMilliseconds = 86400000
|
const oneDayInMilliseconds = 86400000
|
||||||
|
|
||||||
const store = writable(DEFAULT)
|
const store = writable(DEFAULT)
|
||||||
|
|
||||||
function willReachUserLimit(userCount, userLimit) {
|
function usersLimitReached(userCount, userLimit) {
|
||||||
if (userLimit === UNLIMITED) {
|
if (userLimit === UNLIMITED) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return userCount >= userLimit
|
return userCount >= userLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
function willExceedUserLimit(userCount, userLimit) {
|
function usersLimitExceeded(userCount, userLimit) {
|
||||||
if (userLimit === UNLIMITED) {
|
if (userLimit === UNLIMITED) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return userCount > userLimit
|
return userCount > userLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isCloud() {
|
||||||
|
let adminStore = get(admin)
|
||||||
|
if (!adminStore.loaded) {
|
||||||
|
await admin.init()
|
||||||
|
adminStore = get(admin)
|
||||||
|
}
|
||||||
|
return adminStore.cloud
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
init: async () => {
|
init: async () => {
|
||||||
actions.setNavigation()
|
actions.setNavigation()
|
||||||
|
@ -71,10 +80,14 @@ export const createLicensingStore = () => {
|
||||||
const goToUpgradePage = () => {
|
const goToUpgradePage = () => {
|
||||||
window.location.href = upgradeUrl
|
window.location.href = upgradeUrl
|
||||||
}
|
}
|
||||||
|
const goToPricingPage = () => {
|
||||||
|
window.open("https://budibase.com/pricing/", "_blank")
|
||||||
|
}
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
goToUpgradePage,
|
goToUpgradePage,
|
||||||
|
goToPricingPage,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -128,15 +141,15 @@ export const createLicensingStore = () => {
|
||||||
quotaUsage,
|
quotaUsage,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
actions.setUsageMetrics()
|
await actions.setUsageMetrics()
|
||||||
},
|
},
|
||||||
willReachUserLimit: userCount => {
|
usersLimitReached: userCount => {
|
||||||
return willReachUserLimit(userCount, get(store).userLimit)
|
return usersLimitReached(userCount, get(store).userLimit)
|
||||||
},
|
},
|
||||||
willExceedUserLimit(userCount) {
|
usersLimitExceeded(userCount) {
|
||||||
return willExceedUserLimit(userCount, get(store).userLimit)
|
return usersLimitExceeded(userCount, get(store).userLimit)
|
||||||
},
|
},
|
||||||
setUsageMetrics: () => {
|
setUsageMetrics: async () => {
|
||||||
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
|
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
|
||||||
const usage = get(store).quotaUsage
|
const usage = get(store).quotaUsage
|
||||||
const license = get(auth).user.license
|
const license = get(auth).user.license
|
||||||
|
@ -198,11 +211,13 @@ export const createLicensingStore = () => {
|
||||||
const userQuota = license.quotas.usage.static.users
|
const userQuota = license.quotas.usage.static.users
|
||||||
const userLimit = userQuota?.value
|
const userLimit = userQuota?.value
|
||||||
const userCount = usage.usageQuota.users
|
const userCount = usage.usageQuota.users
|
||||||
const userLimitReached = willReachUserLimit(userCount, userLimit)
|
const userLimitReached = usersLimitReached(userCount, userLimit)
|
||||||
const userLimitExceeded = willExceedUserLimit(userCount, userLimit)
|
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
|
||||||
const days = dayjs(userQuota?.startDate).diff(dayjs(), "day")
|
const isCloudAccount = await isCloud()
|
||||||
const userLimitDays = days > 1 ? `${days} days` : "1 day"
|
const errUserLimit =
|
||||||
const warnUserLimit = userQuota?.startDate && userLimitExceeded
|
isCloudAccount &&
|
||||||
|
license.plan.model === PlanModel.PER_USER &&
|
||||||
|
userLimitExceeded
|
||||||
|
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
return {
|
return {
|
||||||
|
@ -217,9 +232,8 @@ export const createLicensingStore = () => {
|
||||||
// user limits
|
// user limits
|
||||||
userCount,
|
userCount,
|
||||||
userLimit,
|
userLimit,
|
||||||
userLimitDays,
|
|
||||||
userLimitReached,
|
userLimitReached,
|
||||||
warnUserLimit,
|
errUserLimit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
"name": "@budibase/cli",
|
"name": "@budibase/cli",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/src/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"budi": "dist/index.js"
|
"budi": "dist/src/index.js"
|
||||||
},
|
},
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
|
|
|
@ -2,17 +2,17 @@
|
||||||
process.env.DISABLE_PINO_LOGGER = "1"
|
process.env.DISABLE_PINO_LOGGER = "1"
|
||||||
import "./prebuilds"
|
import "./prebuilds"
|
||||||
import "./environment"
|
import "./environment"
|
||||||
import { env } from "@budibase/backend-core"
|
|
||||||
import { getCommands } from "./options"
|
import { getCommands } from "./options"
|
||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import { getHelpDescription } from "./utils"
|
import { getHelpDescription } from "./utils"
|
||||||
|
import { version } from "../package.json"
|
||||||
|
|
||||||
// add hosting config
|
// add hosting config
|
||||||
async function init() {
|
async function init() {
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
.addHelpCommand("help", getHelpDescription("Help with Budibase commands."))
|
.addHelpCommand("help", getHelpDescription("Help with Budibase commands."))
|
||||||
.helpOption(false)
|
.helpOption(false)
|
||||||
.version(env.VERSION)
|
.version(version)
|
||||||
// add commands
|
// add commands
|
||||||
for (let command of getCommands()) {
|
for (let command of getCommands()) {
|
||||||
command.configure(program)
|
command.configure(program)
|
||||||
|
|
|
@ -13,7 +13,7 @@ if (!process.argv[0].includes("node")) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkForBinaries() {
|
function checkForBinaries() {
|
||||||
const readDir = join(__filename, "..", "..", PREBUILDS, ARCH)
|
const readDir = join(__filename, "..", "..", "..", PREBUILDS, ARCH)
|
||||||
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
|
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@budibase/types": ["../types/src"],
|
"@budibase/types": ["../types/src"],
|
||||||
"@budibase/backend-core": ["../backend-core/src"],
|
"@budibase/backend-core": ["../backend-core/src"],
|
||||||
|
@ -16,6 +17,6 @@
|
||||||
"swc": true
|
"swc": true
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
|
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "package.json"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => {
|
||||||
/**
|
/**
|
||||||
* Gets a group users by the group id
|
* Gets a group users by the group id
|
||||||
*/
|
*/
|
||||||
getGroupUsers: async ({ id, bookmark }) => {
|
getGroupUsers: async ({ id, bookmark, emailSearch }) => {
|
||||||
let url = `/api/global/groups/${id}/users?`
|
let url = `/api/global/groups/${id}/users?`
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
url += `bookmark=${bookmark}`
|
url += `bookmark=${bookmark}&`
|
||||||
|
}
|
||||||
|
if (emailSearch) {
|
||||||
|
url += `emailSearch=${emailSearch}&`
|
||||||
}
|
}
|
||||||
|
|
||||||
return await API.get({
|
return await API.get({
|
||||||
|
|
|
@ -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>
|
<script>
|
||||||
import SplitPage from "./SplitPage.svelte"
|
import SplitPage from "./SplitPage.svelte"
|
||||||
import { Layout } from "@budibase/bbui"
|
import Testimonial from "./Testimonial.svelte"
|
||||||
import Bulgaria from "../../assets/bulgaria.png"
|
|
||||||
import Covanta from "../../assets/covanta.png"
|
|
||||||
import Schnellecke from "../../assets/schnellecke.png"
|
|
||||||
|
|
||||||
export let enabled = true
|
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>
|
</script>
|
||||||
|
|
||||||
<SplitPage>
|
<SplitPage>
|
||||||
<slot />
|
<slot />
|
||||||
<div class:wrapper={enabled} slot="right">
|
<div class:wrapper={enabled} slot="right">
|
||||||
{#if enabled}
|
{#if enabled}
|
||||||
<div class="testimonial">
|
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</SplitPage>
|
</SplitPage>
|
||||||
|
@ -64,20 +21,4 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
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>
|
</style>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as SplitPage } from "./SplitPage.svelte"
|
export { default as SplitPage } from "./SplitPage.svelte"
|
||||||
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
||||||
|
export { default as Testimonial } from "./Testimonial.svelte"
|
||||||
export { Grid } from "./grid"
|
export { Grid } from "./grid"
|
||||||
|
|
|
@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch {
|
||||||
try {
|
try {
|
||||||
const res = await this.API.getGroupUsers({
|
const res = await this.API.getGroupUsers({
|
||||||
id: query.groupId,
|
id: query.groupId,
|
||||||
|
emailSearch: query.emailSearch,
|
||||||
bookmark: cursor,
|
bookmark: cursor,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 14345384f7a6755d1e2de327104741e0f208f55d
|
Subproject commit 64a2025727c25d5813832c92eb360de3947b7aa6
|
|
@ -118,8 +118,11 @@ export async function patch(ctx: UserCtx) {
|
||||||
combinedRow[key] = inputs[key]
|
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
|
// 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({
|
const validateResult = await utils.validate({
|
||||||
row,
|
row,
|
||||||
table,
|
table,
|
||||||
|
@ -163,7 +166,12 @@ export async function save(ctx: UserCtx) {
|
||||||
|
|
||||||
// this returns the table and row incase they have been updated
|
// this returns the table and row incase they have been updated
|
||||||
const dbTable = await db.get(inputs.tableId)
|
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({
|
const validateResult = await utils.validate({
|
||||||
row,
|
row,
|
||||||
table,
|
table,
|
||||||
|
|
|
@ -97,6 +97,7 @@ export async function bulkImport(ctx: UserCtx) {
|
||||||
// right now we don't trigger anything for bulk import because it
|
// 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
|
// can only be done in the builder, but in the future we may need to
|
||||||
// think about events for bulk items
|
// think about events for bulk items
|
||||||
|
|
||||||
ctx.status = 200
|
ctx.status = 200
|
||||||
ctx.body = { message: `Bulk rows created.` }
|
ctx.body = { message: `Bulk rows created.` }
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,8 +184,13 @@ export async function destroy(ctx: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkImport(ctx: any) {
|
export async function bulkImport(ctx: any) {
|
||||||
|
const db = context.getAppDB()
|
||||||
const table = await sdk.tables.getTable(ctx.params.tableId)
|
const table = await sdk.tables.getTable(ctx.params.tableId)
|
||||||
const { rows } = ctx.request.body
|
const { rows } = ctx.request.body
|
||||||
await handleDataImport(ctx.user, table, rows)
|
await handleDataImport(ctx.user, table, rows)
|
||||||
|
|
||||||
|
// Ensure auto id and other table updates are persisted
|
||||||
|
await db.put(table)
|
||||||
|
|
||||||
return table
|
return table
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,17 +129,17 @@ export function importToRows(
|
||||||
// the real schema of the table passed in, not the clone used for
|
// the real schema of the table passed in, not the clone used for
|
||||||
// incrementing auto IDs
|
// incrementing auto IDs
|
||||||
for (const [fieldName, schema] of Object.entries(originalTable.schema)) {
|
for (const [fieldName, schema] of Object.entries(originalTable.schema)) {
|
||||||
|
const rowVal = Array.isArray(row[fieldName])
|
||||||
|
? row[fieldName]
|
||||||
|
: [row[fieldName]]
|
||||||
if (
|
if (
|
||||||
(schema.type === FieldTypes.OPTIONS ||
|
(schema.type === FieldTypes.OPTIONS ||
|
||||||
schema.type === FieldTypes.ARRAY) &&
|
schema.type === FieldTypes.ARRAY) &&
|
||||||
row[fieldName] &&
|
row[fieldName]
|
||||||
(!schema.constraints!.inclusion ||
|
|
||||||
schema.constraints!.inclusion.indexOf(row[fieldName]) === -1)
|
|
||||||
) {
|
) {
|
||||||
schema.constraints!.inclusion = [
|
let merged = [...schema.constraints!.inclusion!, ...rowVal]
|
||||||
...schema.constraints!.inclusion!,
|
let superSet = new Set(merged)
|
||||||
row[fieldName],
|
schema.constraints!.inclusion = Array.from(superSet)
|
||||||
]
|
|
||||||
schema.constraints!.inclusion.sort()
|
schema.constraints!.inclusion.sort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,13 +42,17 @@ if (!env.isTest()) {
|
||||||
host: REDIS_OPTS.host,
|
host: REDIS_OPTS.host,
|
||||||
port: REDIS_OPTS.port,
|
port: REDIS_OPTS.port,
|
||||||
},
|
},
|
||||||
password:
|
}
|
||||||
REDIS_OPTS.opts.password || REDIS_OPTS.opts.redisOptions.password,
|
|
||||||
|
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) {
|
if (!env.REDIS_CLUSTERED) {
|
||||||
// Can't set direct redis db in clustered env
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
// Can't set direct redis db in clustered env
|
||||||
options.database = 1
|
options.database = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,18 +73,97 @@ describe("run misc tests", () => {
|
||||||
type: "string",
|
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(
|
await tableUtils.handleDataImport(
|
||||||
{ userId: "test" },
|
{ userId: "test" },
|
||||||
table,
|
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()
|
const rows = await config.getRows()
|
||||||
expect(rows[0].a).toEqual("1")
|
expect(rows.length).toEqual(4);
|
||||||
expect(rows[0].b).toEqual("2")
|
|
||||||
expect(rows[0].c).toEqual("3")
|
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)
|
row = basicRow(table._id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadRow = async (id, status = 200) =>
|
const loadRow = async (id, tbl_Id, status = 200) =>
|
||||||
await request
|
await request
|
||||||
.get(`/api/${table._id}/rows/${id}`)
|
.get(`/api/${tbl_Id}/rows/${id}`)
|
||||||
.set(config.defaultHeaders())
|
.set(config.defaultHeaders())
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(status)
|
.expect(status)
|
||||||
|
@ -79,6 +79,60 @@ describe("/rows", () => {
|
||||||
await assertQueryUsage(queryUsage + 1)
|
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 () => {
|
it("updates a row successfully", async () => {
|
||||||
const existing = await config.createRow()
|
const existing = await config.createRow()
|
||||||
const rowUsage = await getRowUsage()
|
const rowUsage = await getRowUsage()
|
||||||
|
@ -182,8 +236,32 @@ describe("/rows", () => {
|
||||||
type: "string",
|
type: "string",
|
||||||
presence: false,
|
presence: false,
|
||||||
datetime: { earliest: "", latest: "" },
|
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({
|
table = await config.createTable({
|
||||||
name: "TestTable2",
|
name: "TestTable2",
|
||||||
|
@ -212,7 +290,15 @@ describe("/rows", () => {
|
||||||
attachmentNull: attachment,
|
attachmentNull: attachment,
|
||||||
attachmentUndefined: attachment,
|
attachmentUndefined: attachment,
|
||||||
attachmentEmpty: attachment,
|
attachmentEmpty: attachment,
|
||||||
attachmentEmptyArrayStr: attachment
|
attachmentEmptyArrayStr: attachment,
|
||||||
|
arrayFieldEmptyArrayStr: arrayField,
|
||||||
|
arrayFieldArrayStrKnown: arrayField,
|
||||||
|
arrayFieldNull: arrayField,
|
||||||
|
arrayFieldUndefined: arrayField,
|
||||||
|
optsFieldEmptyStr: optsField,
|
||||||
|
optsFieldUndefined: optsField,
|
||||||
|
optsFieldNull: optsField,
|
||||||
|
optsFieldStrKnown: optsField
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -241,11 +327,20 @@ describe("/rows", () => {
|
||||||
attachmentUndefined: undefined,
|
attachmentUndefined: undefined,
|
||||||
attachmentEmpty: "",
|
attachmentEmpty: "",
|
||||||
attachmentEmptyArrayStr: "[]",
|
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.stringUndefined).toBe(undefined)
|
||||||
expect(saved.stringNull).toBe("")
|
expect(saved.stringNull).toBe("")
|
||||||
|
@ -270,7 +365,15 @@ describe("/rows", () => {
|
||||||
expect(saved.attachmentNull).toEqual([])
|
expect(saved.attachmentNull).toEqual([])
|
||||||
expect(saved.attachmentUndefined).toBe(undefined)
|
expect(saved.attachmentUndefined).toBe(undefined)
|
||||||
expect(saved.attachmentEmpty).toEqual([])
|
expect(saved.attachmentEmpty).toEqual([])
|
||||||
expect(saved.attachmentEmptyArrayStr).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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -299,7 +402,7 @@ describe("/rows", () => {
|
||||||
expect(res.body.name).toEqual("Updated Name")
|
expect(res.body.name).toEqual("Updated Name")
|
||||||
expect(res.body.description).toEqual(existing.description)
|
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.description).toEqual(existing.description)
|
||||||
expect(savedRow.body.name).toEqual("Updated Name")
|
expect(savedRow.body.name).toEqual("Updated Name")
|
||||||
|
@ -401,7 +504,7 @@ describe("/rows", () => {
|
||||||
.expect(200)
|
.expect(200)
|
||||||
|
|
||||||
expect(res.body.length).toEqual(2)
|
expect(res.body.length).toEqual(2)
|
||||||
await loadRow(row1._id, 404)
|
await loadRow(row1._id, table._id, 404)
|
||||||
await assertRowUsage(rowUsage - 2)
|
await assertRowUsage(rowUsage - 2)
|
||||||
await assertQueryUsage(queryUsage + 1)
|
await assertQueryUsage(queryUsage + 1)
|
||||||
})
|
})
|
||||||
|
|
|
@ -167,7 +167,10 @@ describe("/tables", () => {
|
||||||
|
|
||||||
expect(events.table.created).not.toHaveBeenCalled()
|
expect(events.table.created).not.toHaveBeenCalled()
|
||||||
expect(events.rows.imported).toBeCalledTimes(1)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -137,8 +137,7 @@ export function inputProcessing(
|
||||||
opts?: AutoColumnProcessingOpts
|
opts?: AutoColumnProcessingOpts
|
||||||
) {
|
) {
|
||||||
let clonedRow = cloneDeep(row)
|
let clonedRow = cloneDeep(row)
|
||||||
// need to copy the table so it can be differenced on way out
|
|
||||||
const copiedTable = cloneDeep(table)
|
|
||||||
const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
|
const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
|
||||||
for (let [key, value] of Object.entries(clonedRow)) {
|
for (let [key, value] of Object.entries(clonedRow)) {
|
||||||
const field = table.schema[key]
|
const field = table.schema[key]
|
||||||
|
@ -175,7 +174,7 @@ export function inputProcessing(
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle auto columns - this returns an object like {table, row}
|
// handle auto columns - this returns an object like {table, row}
|
||||||
return processAutoColumn(user, copiedTable, clonedRow, opts)
|
return processAutoColumn(user, table, clonedRow, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -2,6 +2,22 @@
|
||||||
import { FieldTypes } from "../../constants"
|
import { FieldTypes } from "../../constants"
|
||||||
import { logging } from "@budibase/backend-core"
|
import { logging } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
const parseArrayString = value => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value === "") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = JSON.parse(value.replace(/'/g, '"'))
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
logging.logAlert("Could not parse row value", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A map of how we convert various properties in rows to each other based on the row type.
|
* A map of how we convert various properties in rows to each other based on the row type.
|
||||||
*/
|
*/
|
||||||
|
@ -26,9 +42,9 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
[undefined]: undefined,
|
[undefined]: undefined,
|
||||||
},
|
},
|
||||||
[FieldTypes.ARRAY]: {
|
[FieldTypes.ARRAY]: {
|
||||||
"": [],
|
|
||||||
[null]: [],
|
[null]: [],
|
||||||
[undefined]: undefined,
|
[undefined]: undefined,
|
||||||
|
parse: parseArrayString,
|
||||||
},
|
},
|
||||||
[FieldTypes.STRING]: {
|
[FieldTypes.STRING]: {
|
||||||
"": "",
|
"": "",
|
||||||
|
@ -70,21 +86,7 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
[FieldTypes.ATTACHMENT]: {
|
[FieldTypes.ATTACHMENT]: {
|
||||||
[null]: [],
|
[null]: [],
|
||||||
[undefined]: undefined,
|
[undefined]: undefined,
|
||||||
parse: attachments => {
|
parse: parseArrayString,
|
||||||
if (typeof attachments === "string") {
|
|
||||||
if (attachments === "") {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = JSON.parse(attachments)
|
|
||||||
} catch (e) {
|
|
||||||
logging.logAlert("Could not parse attachments", e)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return attachments
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[FieldTypes.BOOLEAN]: {
|
[FieldTypes.BOOLEAN]: {
|
||||||
"": null,
|
"": null,
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { events } from "@budibase/backend-core"
|
import { events } from "@budibase/backend-core"
|
||||||
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
import { structures, TestConfiguration, mocks } from "../../../../tests"
|
import { structures, TestConfiguration, mocks } from "../../../../tests"
|
||||||
|
import { UserGroup } from "@budibase/types"
|
||||||
|
|
||||||
|
mocks.licenses.useGroups()
|
||||||
|
|
||||||
describe("/api/global/groups", () => {
|
describe("/api/global/groups", () => {
|
||||||
const config = new TestConfiguration()
|
const config = new TestConfiguration()
|
||||||
|
@ -113,4 +117,118 @@ describe("/api/global/groups", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("find users", () => {
|
||||||
|
describe("without users", () => {
|
||||||
|
let group: UserGroup
|
||||||
|
beforeAll(async () => {
|
||||||
|
group = structures.groups.UserGroup()
|
||||||
|
await config.api.groups.saveGroup(group)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty", async () => {
|
||||||
|
const result = await config.api.groups.searchUsers(group._id!)
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: [],
|
||||||
|
bookmark: undefined,
|
||||||
|
hasNextPage: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("existing users", () => {
|
||||||
|
let groupId: string
|
||||||
|
let users: { _id: string; email: string }[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
groupId = (
|
||||||
|
await config.api.groups.saveGroup(structures.groups.UserGroup())
|
||||||
|
).body._id
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 30 }).map(async (_, i) => {
|
||||||
|
const email = `user${i}@${generator.domain()}`
|
||||||
|
const user = await config.api.users.saveUser({
|
||||||
|
...structures.users.user(),
|
||||||
|
email,
|
||||||
|
})
|
||||||
|
users.push({ _id: user.body._id, email })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
users = users.sort((a, b) => a._id.localeCompare(b._id))
|
||||||
|
await config.api.groups.updateGroupUsers(groupId, {
|
||||||
|
add: users.map(u => u._id),
|
||||||
|
remove: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pagination", () => {
|
||||||
|
it("should return first page", async () => {
|
||||||
|
const result = await config.api.groups.searchUsers(groupId)
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: users.slice(0, 10),
|
||||||
|
bookmark: users[10]._id,
|
||||||
|
hasNextPage: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("given a bookmark, should return skip items", async () => {
|
||||||
|
const result = await config.api.groups.searchUsers(groupId, {
|
||||||
|
bookmark: users[7]._id,
|
||||||
|
})
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: users.slice(7, 17),
|
||||||
|
bookmark: users[17]._id,
|
||||||
|
hasNextPage: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("bookmarking the last page, should return last page info", async () => {
|
||||||
|
const result = await config.api.groups.searchUsers(groupId, {
|
||||||
|
bookmark: users[20]._id,
|
||||||
|
})
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: users.slice(20),
|
||||||
|
bookmark: undefined,
|
||||||
|
hasNextPage: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("search by email", () => {
|
||||||
|
it('should be able to search "starting" by email', async () => {
|
||||||
|
const result = await config.api.groups.searchUsers(groupId, {
|
||||||
|
emailSearch: `user1`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchedUsers = users
|
||||||
|
.filter(u => u.email.startsWith("user1"))
|
||||||
|
.sort((a, b) => a.email.localeCompare(b.email))
|
||||||
|
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: matchedUsers.slice(0, 10),
|
||||||
|
bookmark: matchedUsers[10].email,
|
||||||
|
hasNextPage: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to bookmark when searching by email", async () => {
|
||||||
|
const matchedUsers = users
|
||||||
|
.filter(u => u.email.startsWith("user1"))
|
||||||
|
.sort((a, b) => a.email.localeCompare(b.email))
|
||||||
|
|
||||||
|
const result = await config.api.groups.searchUsers(groupId, {
|
||||||
|
emailSearch: `user1`,
|
||||||
|
bookmark: matchedUsers[4].email,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
users: matchedUsers.slice(4),
|
||||||
|
bookmark: undefined,
|
||||||
|
hasNextPage: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,4 +23,34 @@ export class GroupsAPI extends TestAPI {
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchUsers = (
|
||||||
|
id: string,
|
||||||
|
params?: { bookmark?: string; emailSearch?: string }
|
||||||
|
) => {
|
||||||
|
let url = `/api/global/groups/${id}/users?`
|
||||||
|
if (params?.bookmark) {
|
||||||
|
url += `bookmark=${params.bookmark}&`
|
||||||
|
}
|
||||||
|
if (params?.emailSearch) {
|
||||||
|
url += `emailSearch=${params.emailSearch}&`
|
||||||
|
}
|
||||||
|
return this.request
|
||||||
|
.get(url)
|
||||||
|
.set(this.config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
.expect(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupUsers = (
|
||||||
|
id: string,
|
||||||
|
body: { add: string[]; remove: string[] }
|
||||||
|
) => {
|
||||||
|
return this.request
|
||||||
|
.post(`/api/global/groups/${id}/users`)
|
||||||
|
.send(body)
|
||||||
|
.set(this.config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
.expect(200)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) {
|
||||||
// a hour
|
// a hour
|
||||||
return 3600
|
return 3600
|
||||||
case redis.utils.Databases.INVITATIONS:
|
case redis.utils.Databases.INVITATIONS:
|
||||||
// a day
|
// a week
|
||||||
return 604800
|
return 604800
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"test:watch": "yarn run test --watch",
|
"test:watch": "yarn run test --watch",
|
||||||
"test:debug": "DEBUG=1 yarn run test",
|
"test:debug": "DEBUG=1 yarn run test",
|
||||||
"test:notify": "node scripts/testResultsWebhook",
|
"test:notify": "node scripts/testResultsWebhook",
|
||||||
"test:smoke": "yarn run test --testPathIgnorePatterns=\\\"\\/dataSources\\/\\\"",
|
"test:smoke": "yarn run test --testPathIgnorePatterns=/.+\\.nightly\\.spec\\.ts",
|
||||||
"test:ci": "start-server-and-test dev:built http://localhost:4001/health test:smoke",
|
"test:ci": "start-server-and-test dev:built http://localhost:4001/health test:smoke",
|
||||||
"serve": "start-server-and-test dev:built http://localhost:4001/health",
|
"serve": "start-server-and-test dev:built http://localhost:4001/health",
|
||||||
"dev:built": "cd ../ && yarn dev:built"
|
"dev:built": "cd ../ && yarn dev:built"
|
||||||
|
|
Loading…
Reference in New Issue