Merge remote-tracking branch 'origin/develop' into feature/binding-v2-updates

This commit is contained in:
Dean 2023-05-17 09:12:12 +01:00
commit de6f841e30
42 changed files with 710 additions and 208 deletions

View File

@ -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;
} }

View File

@ -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",

View File

@ -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",
} }

View File

@ -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,

View File

@ -69,6 +69,7 @@
"@codemirror/state": "^6.2.0", "@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.11.2", "@codemirror/view": "^6.11.2",
"@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",

View File

@ -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)}

View File

@ -58,6 +58,7 @@
{loading} {loading}
{type} {type}
rowCount={10} rowCount={10}
allowEditing={false}
bind:hideAutocolumns bind:hideAutocolumns
> >
<ViewFilterButton {view} /> <ViewFilterButton {view} />

View File

@ -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

View File

@ -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 />

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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
} }
$: filterInvites(query) filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) {
return Object.keys(inviteInfo).includes(prodAppId)
}
return invite.email.includes(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,7 +207,8 @@
} }
const searchGroups = (userGroups, query) => { const searchGroups = (userGroups, query) => {
let filterGroups = query?.length let filterGroups =
query?.length || !filterByAppAccess
? userGroups ? userGroups
: getAppGroups(userGroups, prodAppId) : getAppGroups(userGroups, prodAppId)
return filterGroups return filterGroups
@ -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>

View File

@ -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} />

View File

@ -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} />

View File

@ -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 {

View File

@ -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,6 +158,7 @@
/> />
</Modal> </Modal>
<div class="full-width">
<SplitPage> <SplitPage>
{#if stage === "name"} {#if stage === "name"}
<NamePanel bind:name bind:url onNext={() => (stage = "data")} /> <NamePanel bind:name bind:url onNext={() => (stage = "data")} />
@ -163,7 +176,9 @@
{:else if stage === "data"} {:else if stage === "data"}
<DataPanel onBack={() => (stage = "name")}> <DataPanel onBack={() => (stage = "name")}>
<div class="dataButton"> <div class="dataButton">
<FancyButton on:click={() => handleCreateApp({ useSampleData: true })}> <FancyButton
on:click={() => handleCreateApp({ useSampleData: true })}
>
<div class="dataButtonContent"> <div class="dataButtonContent">
<div class="dataButtonIcon"> <div class="dataButtonIcon">
<img <img
@ -218,8 +233,12 @@
<ExampleApp {name} showData={stage !== "name"} /> <ExampleApp {name} showData={stage !== "name"} />
</div> </div>
</SplitPage> </SplitPage>
</div>
<style> <style>
.full-width {
width: 100%;
}
.centered { .centered {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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">

View File

@ -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,
} }
}) })
} }

View File

@ -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",

View File

@ -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)

View File

@ -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
} }

View File

@ -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"]
} }

View File

@ -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({

View File

@ -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

View File

@ -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,

View File

@ -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.` }
} }

View File

@ -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
} }

View File

@ -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()
} }
} }

View File

@ -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
} }
} }

View File

@ -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')
}) })
}) })
}) })

View File

@ -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("")
@ -271,6 +366,14 @@ describe("/rows", () => {
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)
}) })

View File

@ -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)
}) })
}) })

View File

@ -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)
} }
/** /**

View File

@ -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,

View File

@ -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,
})
})
})
})
})
}) })

View File

@ -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)
}
} }

View File

@ -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
} }
} }