Merge branch 'develop' into feature/datasource-conns

This commit is contained in:
Adria Navarro 2023-05-17 12:00:56 +02:00
commit 4f3139a47b
54 changed files with 806 additions and 281 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_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";
@ -82,6 +82,12 @@ http {
set $couchdb ${COUCHDB_UPSTREAM_URL};
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
location /health {
access_log off;
add_header 'Content-Type' 'application/json';
return 200 '{ "status": "OK" }';
}
location /app {
proxy_pass $apps;
}

View File

@ -1,5 +1,5 @@
{
"version": "2.6.8-alpha.11",
"version": "2.6.16-alpha.2",
"npmClient": "yarn",
"packages": [
"packages/backend-core",

View File

@ -21,7 +21,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user",
USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger",
}

View File

@ -69,10 +69,10 @@ function findVersion() {
try {
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
const content = readFileSync(packageJsonFile!, "utf-8")
const version = JSON.parse(content).version
return version
return JSON.parse(content).version
} catch {
throw new Error("Cannot find a valid version in its package.json")
// throwing an error here is confusing/causes backend-core to be hard to import
return undefined
}
}
@ -95,7 +95,7 @@ const environment = {
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
REDIS_PASSWORD: process.env.REDIS_PASSWORD || "budibase",
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MOCK_REDIS: process.env.MOCK_REDIS,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,

View File

@ -2,6 +2,7 @@
import "@spectrum-css/button/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let type
export let disabled = false
export let size = "M"
export let cta = false
@ -21,6 +22,7 @@
<button
{id}
{type}
class:spectrum-Button--cta={cta}
class:spectrum-Button--primary={primary}
class:spectrum-Button--secondary={secondary}
@ -73,6 +75,7 @@
button {
position: relative;
}
.spectrum-Button-label {
white-space: nowrap;
overflow: hidden;

View File

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

View File

@ -1,7 +1,7 @@
<script>
import Icon from "../Icon/Icon.svelte"
import { getContext, onMount } from "svelte"
import { slide } from "svelte/transition"
import ErrorMessage from "./ErrorMessage.svelte"
export let disabled = false
export let error = null
@ -55,9 +55,7 @@
{/if}
</div>
{#if error}
<div transition:slide|local={{ duration: 130 }} class="error-message">
{error}
</div>
<ErrorMessage {error} />
{/if}
</div>
@ -110,13 +108,6 @@
.field {
flex: 1 1 auto;
}
.error-message {
background: var(--spectrum-global-color-red-400);
color: white;
font-size: 14px;
padding: 6px 16px;
font-weight: 500;
}
.error-icon {
flex: 0 0 auto;
}

View File

@ -4,3 +4,4 @@ export { default as FancySelect } from "./FancySelect.svelte"
export { default as FancyButton } from "./FancyButton.svelte"
export { default as FancyForm } from "./FancyForm.svelte"
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
export { default as ErrorMessage } from "./ErrorMessage.svelte"

View File

@ -62,6 +62,7 @@
"@budibase/frontend-core": "0.0.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -23,6 +23,7 @@
export let disableSorting = false
export let customPlaceholder = false
export let allowClickRows
export let allowEditing = true
const dispatch = createEventDispatcher()
@ -110,6 +111,7 @@
{rowCount}
{disableSorting}
{customPlaceholder}
allowEditRows={allowEditing}
showAutoColumns={!hideAutocolumns}
{allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)}

View File

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

View File

@ -6,7 +6,8 @@
import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify"
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId

View File

@ -17,6 +17,7 @@
export let highlighted = false
export let rightAlignIcon = false
export let id
export let showTooltip = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -84,7 +85,7 @@
<Icon color={iconColor} size="S" name={icon} />
</div>
{/if}
<div class="text">{text}</div>
<div class="text" title={showTooltip ? text : null}>{text}</div>
{#if withActions}
<div class="actions">
<slot />

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 { get } from "svelte/store"
import { BANNER_TYPES } from "@budibase/bbui"
import { capitalise } from "helpers"
const oneDayInSeconds = 86400
@ -146,23 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
const userLicensing = get(licensing)
return {
key: EXPIRY_KEY,
type: BANNER_TYPES.WARNING,
type: BANNER_TYPES.NEGATIVE,
onChange: () => {
defaultCacheFn(EXPIRY_KEY)
},
criteria: () => {
return userLicensing.warnUserLimit
return userLicensing.errUserLimit
},
message: `${capitalise(
userLicensing.license.plan.type
)} plan changes - Users will be limited to ${
userLicensing.userLimit
} users in ${userLicensing.userLimitDays}`,
message: "Your Budibase account is de-activated. Upgrade your plan",
...{
extraButtonText: "Find out more",
extraButtonText: "View plans",
extraButtonAction: () => {
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
window.location.href = "/builder/portal/users/users"
window.location.href = "https://budibase.com/pricing/"
},
},
showCloseButton: true,

View File

@ -6,6 +6,8 @@
export let app
export let lockedAction
const handleDefaultClick = () => {
if (window.innerWidth < 640) {
goToOverview()
@ -29,7 +31,7 @@
}
</script>
<div class="app-row" on:click={handleDefaultClick}>
<div class="app-row" on:click={lockedAction || handleDefaultClick}>
<div class="title">
<div class="app-icon">
<Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} />
@ -58,8 +60,11 @@
<div class="app-row-actions">
<AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={goToOverview}>Manage</Button>
<Button size="S" primary on:click={goToBuilder}>Edit</Button>
<Button size="S" secondary on:click={lockedAction || goToOverview}
>Manage</Button
>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
>
</div>
</div>

View File

@ -28,13 +28,16 @@
let inviting = false
let searchFocus = false
// Initially filter entities without app access
// Show all when false
let filterByAppAccess = true
let appInvites = []
let filteredInvites = []
let filteredUsers = []
let filteredGroups = []
let selectedGroup
let userOnboardResponse = null
let userLimitReachedModal
$: queryIsEmail = emailValidator(query) === true
@ -52,15 +55,32 @@
}
const filterInvites = async query => {
appInvites = await getInvites()
if (!query || query == "") {
filteredInvites = appInvites
if (!prodAppId) {
return
}
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
appInvites = await getInvites()
//On Focus behaviour
if (!filterByAppAccess && !query) {
filteredInvites =
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
return
}
filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) {
return Object.keys(inviteInfo).includes(prodAppId)
}
return invite.email.includes(query)
})
}
$: filterInvites(query)
$: filterByAppAccess, prodAppId, filterInvites(query)
$: if (searchFocus === true) {
filterByAppAccess = false
}
const usersFetch = fetchData({
API,
@ -79,9 +99,9 @@
}
await usersFetch.update({
query: {
appId: query ? null : prodAppId,
appId: query || !filterByAppAccess ? null : prodAppId,
email: query,
paginated: query ? null : false,
paginated: query || !filterByAppAccess ? null : false,
},
})
await usersFetch.refresh()
@ -107,7 +127,12 @@
}
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
$: debouncedUpdateFetch(
query,
$store.builderSidePanel,
loaded,
filterByAppAccess
)
const updateAppUser = async (user, role) => {
if (!prodAppId) {
@ -182,9 +207,10 @@
}
const searchGroups = (userGroups, query) => {
let filterGroups = query?.length
? userGroups
: getAppGroups(userGroups, prodAppId)
let filterGroups =
query?.length || !filterByAppAccess
? userGroups
: getAppGroups(userGroups, prodAppId)
return filterGroups
.filter(group => {
if (!query?.length) {
@ -214,7 +240,7 @@
}
// Adds the 'role' attribute and sets it to the current app.
$: enrichedGroups = getEnrichedGroups($groups)
$: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
$: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers]
@ -226,7 +252,7 @@
specific roles for the app.
*/
const buildGroupUsers = (userGroups, filteredUsers) => {
if (query) {
if (query || !filterByAppAccess) {
return []
}
// Must exclude users who have explicit privileges
@ -321,12 +347,12 @@
[prodAppId]: role,
},
})
await filterInvites()
await filterInvites(query)
}
const onUninviteAppUser = async invite => {
await uninviteAppUser(invite)
await filterInvites()
await filterInvites(query)
}
// Purge only the app from the invite or recind the invite if only 1 app remains?
@ -351,7 +377,6 @@
onMount(() => {
rendered = true
searchFocus = true
})
function handleKeyDown(evt) {
@ -417,7 +442,6 @@
autocomplete="off"
disabled={inviting}
value={query}
autofocus
on:input={e => {
query = e.target.value.trim()
}}
@ -428,16 +452,20 @@
<span
class="search-input-icon"
class:searching={query}
class:searching={query || !filterByAppAccess}
on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true
}}
>
<Icon name={query ? "Close" : "Search"} />
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
</span>
</div>

View File

@ -59,6 +59,7 @@
text={screen.routing.route}
on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon
showTooltip
>
<ScreenDropdownMenu screenId={screen._id} />
<RoleIndicator slot="right" roleId={screen.routing.roleId} />

View File

@ -133,7 +133,7 @@
</Body>
</Layout>
<Divider />
{#if $licensing.usageMetrics?.dayPasses >= 100}
{#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit}
<div>
<Layout gap="S" justifyItems="center">
<img class="spaceman" alt="spaceman" src={Spaceman} />

View File

@ -14,6 +14,7 @@
import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { store, automationStore } from "builderStore"
import { API } from "api"
@ -28,6 +29,7 @@
let template
let creationModal
let appLimitModal
let accountLockedModal
let creatingApp = false
let searchTerm = ""
let creatingFromTemplate = false
@ -48,6 +50,11 @@
: true)
)
$: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show()
: null
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
@ -189,6 +196,9 @@
creatingFromTemplate = true
createAppFromTemplateUrl(initInfo.init_template)
}
if (usersLimitLockAction) {
usersLimitLockAction()
}
} catch (error) {
notifications.error("Error getting init info")
}
@ -230,20 +240,30 @@
<Layout noPadding gap="L">
<div class="title">
<div class="buttons">
<Button size="M" cta on:click={initiateAppCreation}>
<Button
size="M"
cta
on:click={usersLimitLockAction || initiateAppCreation}
>
Create new app
</Button>
{#if $apps?.length > 0}
<Button
size="M"
secondary
on:click={$goto("/builder/portal/apps/templates")}
on:click={usersLimitLockAction ||
$goto("/builder/portal/apps/templates")}
>
View templates
</Button>
{/if}
{#if !$apps?.length}
<Button size="L" quiet secondary on:click={initiateAppImport}>
<Button
size="L"
quiet
secondary
on:click={usersLimitLockAction || initiateAppImport}
>
Import app
</Button>
{/if}
@ -267,7 +287,7 @@
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} />
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
@ -294,6 +314,11 @@
</Modal>
<AppLimitModal bind:this={appLimitModal} />
<AccountLockedModal
bind:this={accountLockedModal}
onConfirm={() =>
isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()}
/>
<style>
.title {

View File

@ -107,8 +107,9 @@
useSampleData,
isGoogle,
}) => {
let app
try {
const app = await createApp(useSampleData)
app = await createApp(useSampleData)
let datasource
if (datasourceConfig) {
@ -134,6 +135,17 @@
console.log(e)
creationLoading = false
notifications.error("There was a problem creating your app")
// Reset the store so that we don't send up stale headers
store.actions.reset()
// If we successfully created an app, delete it again so that we
// can try again once the error has been corrected.
// This also ensures onboarding can't be skipped by entering invalid
// data credentials.
if (app?.appId) {
await API.deleteApp(app.appId)
}
}
}
</script>
@ -146,80 +158,87 @@
/>
</Modal>
<SplitPage>
{#if stage === "name"}
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
{:else if googleComplete}
<div class="centered">
<Body
>Please login to your Google account in the new tab which as opened to
continue.</Body
>
</div>
{:else if integrationsLoading || creationLoading}
<div class="centered">
<Spinner />
</div>
{:else if stage === "data"}
<DataPanel onBack={() => (stage = "name")}>
<div class="dataButton">
<FancyButton on:click={() => handleCreateApp({ useSampleData: true })}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<img
alt="Budibase Logo"
class="budibaseLogo"
src={"https://i.imgur.com/Xhdt1YP.png"}
/>
</div>
Budibase Sample data
</div>
</FancyButton>
<div class="full-width">
<SplitPage>
{#if stage === "name"}
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
{:else if googleComplete}
<div class="centered">
<Body
>Please login to your Google account in the new tab which as opened to
continue.</Body
>
</div>
<div class="dataButton">
<FancyButton on:click={uploadModal.show}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
</div>
Upload data (CSV or JSON)
</div>
</FancyButton>
{:else if integrationsLoading || creationLoading}
<div class="centered">
<Spinner />
</div>
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
{:else if stage === "data"}
<DataPanel onBack={() => (stage = "name")}>
<div class="dataButton">
<FancyButton on:click={() => (stage = integrationType)}>
<FancyButton
on:click={() => handleCreateApp({ useSampleData: true })}
>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<IntegrationIcon {integrationType} {schema} />
<img
alt="Budibase Logo"
class="budibaseLogo"
src={"https://i.imgur.com/Xhdt1YP.png"}
/>
</div>
{schema.friendlyName}
Budibase Sample data
</div>
</FancyButton>
</div>
{/each}
</DataPanel>
{:else if stage in plusIntegrations}
<DatasourceConfigPanel
title={plusIntegrations[stage].friendlyName}
fields={plusIntegrations[stage].datasource}
type={stage}
onBack={() => (stage = "data")}
onNext={data => {
const isGoogle = data.isGoogle
delete data.isGoogle
return handleCreateApp({ datasourceConfig: data, isGoogle })
}}
/>
{:else}
<p>There was an problem. Please refresh the page and try again.</p>
{/if}
<div slot="right">
<ExampleApp {name} showData={stage !== "name"} />
</div>
</SplitPage>
<div class="dataButton">
<FancyButton on:click={uploadModal.show}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
</div>
Upload data (CSV or JSON)
</div>
</FancyButton>
</div>
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
<div class="dataButton">
<FancyButton on:click={() => (stage = integrationType)}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<IntegrationIcon {integrationType} {schema} />
</div>
{schema.friendlyName}
</div>
</FancyButton>
</div>
{/each}
</DataPanel>
{:else if stage in plusIntegrations}
<DatasourceConfigPanel
title={plusIntegrations[stage].friendlyName}
fields={plusIntegrations[stage].datasource}
type={stage}
onBack={() => (stage = "data")}
onNext={data => {
const isGoogle = data.isGoogle
delete data.isGoogle
return handleCreateApp({ datasourceConfig: data, isGoogle })
}}
/>
{:else}
<p>There was an problem. Please refresh the page and try again.</p>
{/if}
<div slot="right">
<ExampleApp {name} showData={stage !== "name"} />
</div>
</SplitPage>
</div>
<style>
.full-width {
width: 100%;
}
.centered {
display: flex;
justify-content: center;

View File

@ -1,7 +1,7 @@
<script>
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 { goto } from "@roxi/routify"
import { API } from "api"
@ -12,7 +12,9 @@
export let groupId
const fetchGroupUsers = fetchData({
let emailSearch
let fetchGroupUsers
$: fetchGroupUsers = fetchData({
API,
datasource: {
type: "groupUser",
@ -20,6 +22,7 @@
options: {
query: {
groupId,
emailSearch,
},
},
})
@ -59,24 +62,31 @@
</script>
<div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
{:else}
<ScimBanner />
{/if}
</div>
<div class="controls-right">
<Search bind:value={emailSearch} placeholder="Search email" />
</div>
</div>
<Table
schema={userSchema}
data={$fetchGroupUsers?.rows}
loading={$fetchGroupUsers.loading}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">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>
</Table>
@ -98,7 +108,7 @@
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
justify-content: space-between;
align-items: center;
gap: var(--spacing-l);
}
@ -109,4 +119,15 @@
width: 100%;
text-align: center;
}
.controls-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.controls-right :global(.spectrum-Search) {
width: 200px;
}
</style>

View File

@ -30,8 +30,8 @@
$: hasError = userData.find(x => x.error != null)
$: userCount = $licensing.userCount + userData.length
$: willReach = licensing.willReachUserLimit(userCount)
$: willExceed = licensing.willExceedUserLimit(userCount)
$: reached = licensing.usersLimitReached(userCount)
$: exceeded = licensing.usersLimitExceeded(userCount)
function removeInput(idx) {
userData = userData.filter((e, i) => i !== idx)
@ -87,7 +87,7 @@
confirmDisabled={disabled}
cancelText="Cancel"
showCloseIcon={false}
disabled={hasError || !userData.length || willExceed}
disabled={hasError || !userData.length || exceeded}
>
<Layout noPadding gap="XS">
<Label>Email address</Label>
@ -118,7 +118,7 @@
</div>
{/each}
{#if willReach}
{#if reached}
<div class="user-notification">
<Icon name="Info" />
<span>

View File

@ -25,10 +25,10 @@
$: invalidEmails = []
$: userCount = $licensing.userCount + userEmails.length
$: willExceed = licensing.willExceedUserLimit(userCount)
$: exceed = licensing.usersLimitExceeded(userCount)
$: importDisabled =
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed
const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
@ -93,7 +93,7 @@
</label>
</div>
{#if willExceed}
{#if exceed}
<div class="user-notification">
<Icon name="Info" />
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}

View File

@ -268,8 +268,6 @@
notifications.error("Error fetching user group data")
}
})
let staticUserLimit = $licensing.license.quotas.usage.static.users.value
</script>
<Layout noPadding gap="M">
@ -278,7 +276,7 @@
<Body>Add users and control who gets access to your published apps</Body>
</Layout>
<Divider />
{#if $licensing.warnUserLimit}
{#if $licensing.errUserLimit}
<InlineAlert
type="error"
onConfirm={() => {
@ -290,13 +288,9 @@
}}
buttonText={isOwner ? "Upgrade" : "View plans"}
cta
header={`Users will soon be limited to ${staticUserLimit}`}
message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}.
This means any users exceeding the limit 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.
`}
header="Account de-activated"
message="Due to the free plan user limit being exceeded, your account has been de-activated.
Upgrade your plan to re-activate your account."
/>
{/if}
<div class="controls">

View File

@ -4,7 +4,7 @@ import { auth, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "components/portal/licensing/constants"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import dayjs from "dayjs"
import { PlanModel } from "@budibase/types"
const UNLIMITED = -1
@ -12,6 +12,7 @@ export const createLicensingStore = () => {
const DEFAULT = {
// navigation
goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
@ -37,29 +38,37 @@ export const createLicensingStore = () => {
// user limits
userCount: undefined,
userLimit: undefined,
userLimitDays: undefined,
userLimitReached: false,
warnUserLimit: false,
errUserLimit: false,
}
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT)
function willReachUserLimit(userCount, userLimit) {
function usersLimitReached(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount >= userLimit
}
function willExceedUserLimit(userCount, userLimit) {
function usersLimitExceeded(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount > userLimit
}
async function isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
const actions = {
init: async () => {
actions.setNavigation()
@ -71,10 +80,14 @@ export const createLicensingStore = () => {
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
store.update(state => {
return {
...state,
goToUpgradePage,
goToPricingPage,
}
})
},
@ -128,15 +141,15 @@ export const createLicensingStore = () => {
quotaUsage,
}
})
actions.setUsageMetrics()
await actions.setUsageMetrics()
},
willReachUserLimit: userCount => {
return willReachUserLimit(userCount, get(store).userLimit)
usersLimitReached: userCount => {
return usersLimitReached(userCount, get(store).userLimit)
},
willExceedUserLimit(userCount) {
return willExceedUserLimit(userCount, get(store).userLimit)
usersLimitExceeded(userCount) {
return usersLimitExceeded(userCount, get(store).userLimit)
},
setUsageMetrics: () => {
setUsageMetrics: async () => {
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
const usage = get(store).quotaUsage
const license = get(auth).user.license
@ -198,11 +211,13 @@ export const createLicensingStore = () => {
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = willReachUserLimit(userCount, userLimit)
const userLimitExceeded = willExceedUserLimit(userCount, userLimit)
const days = dayjs(userQuota?.startDate).diff(dayjs(), "day")
const userLimitDays = days > 1 ? `${days} days` : "1 day"
const warnUserLimit = userQuota?.startDate && userLimitExceeded
const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => {
return {
@ -217,9 +232,8 @@ export const createLicensingStore = () => {
// user limits
userCount,
userLimit,
userLimitDays,
userLimitReached,
warnUserLimit,
errUserLimit,
}
})
}

View File

@ -2,9 +2,9 @@
"name": "@budibase/cli",
"version": "0.0.1",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "dist/index.js",
"main": "dist/src/index.js",
"bin": {
"budi": "dist/index.js"
"budi": "dist/src/index.js"
},
"author": "Budibase",
"license": "GPL-3.0",

View File

@ -2,17 +2,17 @@
process.env.DISABLE_PINO_LOGGER = "1"
import "./prebuilds"
import "./environment"
import { env } from "@budibase/backend-core"
import { getCommands } from "./options"
import { Command } from "commander"
import { getHelpDescription } from "./utils"
import { version } from "../package.json"
// add hosting config
async function init() {
const program = new Command()
.addHelpCommand("help", getHelpDescription("Help with Budibase commands."))
.helpOption(false)
.version(env.VERSION)
.version(version)
// add commands
for (let command of getCommands()) {
command.configure(program)

View File

@ -13,7 +13,7 @@ if (!process.argv[0].includes("node")) {
}
function checkForBinaries() {
const readDir = join(__filename, "..", "..", PREBUILDS, ARCH)
const readDir = join(__filename, "..", "..", "..", PREBUILDS, ARCH)
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
return
}

View File

@ -5,6 +5,7 @@
"declaration": true,
"sourceMap": true,
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@budibase/types": ["../types/src"],
"@budibase/backend-core": ["../backend-core/src"],
@ -16,6 +17,6 @@
"swc": true
},
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
"include": ["src/**/*"],
"include": ["src/**/*", "package.json"],
"exclude": ["node_modules", "dist"]
}

View File

@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => {
/**
* Gets a group users by the group id
*/
getGroupUsers: async ({ id, bookmark }) => {
getGroupUsers: async ({ id, bookmark, emailSearch }) => {
let url = `/api/global/groups/${id}/users?`
if (bookmark) {
url += `bookmark=${bookmark}`
url += `bookmark=${bookmark}&`
}
if (emailSearch) {
url += `emailSearch=${emailSearch}&`
}
return await API.get({

View File

@ -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 its now used daily for internal development for those apps that you know you need but dont 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>

View File

@ -1,58 +1,15 @@
<script>
import SplitPage from "./SplitPage.svelte"
import { Layout } from "@budibase/bbui"
import Bulgaria from "../../assets/bulgaria.png"
import Covanta from "../../assets/covanta.png"
import Schnellecke from "../../assets/schnellecke.png"
import Testimonial from "./Testimonial.svelte"
export let enabled = true
const testimonials = [
{
text: "Budibase was the only solution that checked all the boxes for Covanta. Covanta expects to realize $3.2MM in savings due to the elimination of redundant data entry.",
name: "Charles Link",
role: "Senior Director, Data and Analytics",
image: Covanta,
imageSize: 105,
},
{
text: "Budibase was mission-critical for us and went a long way in preventing what could have become a humanitarian crisis here in Bulgaria.",
name: "Bozhidar Bozhanov",
role: "Government of Bulgaria",
image: Bulgaria,
imageSize: 49,
},
{
text: "Centralization of authentication, quick turnaround time for requests, integration with different database systems has given it the edge and its now used daily for internal development for those apps that you know you need but dont feel value in losing days of development to reinvent the wheel.",
name: "Davide Lenzarini",
role: "IT manager",
image: Schnellecke,
imageSize: 141,
},
]
const testimonial = testimonials[Math.floor(Math.random() * 3)]
</script>
<SplitPage>
<slot />
<div class:wrapper={enabled} slot="right">
{#if enabled}
<div class="testimonial">
<Layout noPadding gap="S">
<img
width={testimonial.imageSize}
alt="a-happy-budibase-user"
src={testimonial.image}
/>
<div class="text">
"{testimonial.text}"
</div>
<div class="author">
<div class="name">{testimonial.name}</div>
<div class="company">{testimonial.role}</div>
</div>
</Layout>
</div>
<Testimonial />
{/if}
</div>
</SplitPage>
@ -64,20 +21,4 @@
display: grid;
place-items: center;
}
.testimonial {
width: 380px;
padding: 40px;
}
.text {
font-size: var(--font-size-l);
font-style: italic;
}
.name {
font-weight: bold;
color: var(--spectrum-global-color-gray-900);
font-size: var(--font-size-l);
}
.company {
color: var(--spectrum-global-color-gray-700);
}
</style>

View File

@ -1,3 +1,4 @@
export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte"
export { Grid } from "./grid"

View File

@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch {
try {
const res = await this.API.getGroupUsers({
id: query.groupId,
emailSearch: query.emailSearch,
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]
}
// need to copy the table so it can be differenced on way out
const tableClone = cloneDeep(dbTable)
// this returns the table and row incase they have been updated
let { table, row } = inputProcessing(ctx.user, dbTable, combinedRow)
let { table, row } = inputProcessing(ctx.user, tableClone, combinedRow)
const validateResult = await utils.validate({
row,
table,
@ -163,7 +166,12 @@ export async function save(ctx: UserCtx) {
// this returns the table and row incase they have been updated
const dbTable = await db.get(inputs.tableId)
let { table, row } = inputProcessing(ctx.user, dbTable, inputs)
// need to copy the table so it can be differenced on way out
const tableClone = cloneDeep(dbTable)
let { table, row } = inputProcessing(ctx.user, tableClone, inputs)
const validateResult = await utils.validate({
row,
table,

View File

@ -97,6 +97,7 @@ export async function bulkImport(ctx: UserCtx) {
// right now we don't trigger anything for bulk import because it
// can only be done in the builder, but in the future we may need to
// think about events for bulk items
ctx.status = 200
ctx.body = { message: `Bulk rows created.` }
}

View File

@ -184,8 +184,13 @@ export async function destroy(ctx: any) {
}
export async function bulkImport(ctx: any) {
const db = context.getAppDB()
const table = await sdk.tables.getTable(ctx.params.tableId)
const { rows } = ctx.request.body
await handleDataImport(ctx.user, table, rows)
// Ensure auto id and other table updates are persisted
await db.put(table)
return table
}

View File

@ -129,17 +129,17 @@ export function importToRows(
// the real schema of the table passed in, not the clone used for
// incrementing auto IDs
for (const [fieldName, schema] of Object.entries(originalTable.schema)) {
const rowVal = Array.isArray(row[fieldName])
? row[fieldName]
: [row[fieldName]]
if (
(schema.type === FieldTypes.OPTIONS ||
schema.type === FieldTypes.ARRAY) &&
row[fieldName] &&
(!schema.constraints!.inclusion ||
schema.constraints!.inclusion.indexOf(row[fieldName]) === -1)
row[fieldName]
) {
schema.constraints!.inclusion = [
...schema.constraints!.inclusion!,
row[fieldName],
]
let merged = [...schema.constraints!.inclusion!, ...rowVal]
let superSet = new Set(merged)
schema.constraints!.inclusion = Array.from(superSet)
schema.constraints!.inclusion.sort()
}
}

View File

@ -42,13 +42,17 @@ if (!env.isTest()) {
host: REDIS_OPTS.host,
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) {
// Can't set direct redis db in clustered env
// @ts-ignore
// Can't set direct redis db in clustered env
options.database = 1
}
}

View File

@ -73,18 +73,97 @@ describe("run misc tests", () => {
type: "string",
},
},
e: {
name: "Auto ID",
type: "number",
subtype: "autoID",
icon: "ri-magic-line",
autocolumn: true,
constraints: {
type: "number",
presence: false,
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
},
f: {
type: "array",
constraints: {
type: "array",
presence: {
"allowEmpty": true
},
inclusion: [
"One",
"Two",
"Three",
]
},
name: "Sample Tags",
sortable: false
},
g: {
type: "options",
constraints: {
type: "string",
presence: false,
inclusion: [
"Alpha",
"Beta",
"Gamma"
]
},
name: "Sample Opts"
}
},
})
// Shift specific row tests to the row spec
await tableUtils.handleDataImport(
{ userId: "test" },
table,
[{ a: '1', b: '2', c: '3', d: '4'}]
[
{ a: '1', b: '2', c: '3', d: '4', f: "['One']", g: "Alpha" },
{ a: '5', b: '6', c: '7', d: '8', f: "[]", g: undefined},
{ a: '9', b: '10', c: '11', d: '12', f: "['Two','Four']", g: ""},
{ a: '13', b: '14', c: '15', d: '16', g: "Omega"}
]
)
// 4 rows imported, the auto ID starts at 1
// We expect the handleDataImport function to update the lastID
expect(table.schema.e.lastID).toEqual(4);
// Array/Multi - should have added a new value to the inclusion.
expect(table.schema.f.constraints.inclusion).toEqual(['Four','One','Three','Two']);
// Options - should have a new value in the inclusion
expect(table.schema.g.constraints.inclusion).toEqual(['Alpha','Beta','Gamma','Omega']);
const rows = await config.getRows()
expect(rows[0].a).toEqual("1")
expect(rows[0].b).toEqual("2")
expect(rows[0].c).toEqual("3")
expect(rows.length).toEqual(4);
const rowOne = rows.find(row => row.e === 1)
expect(rowOne.a).toEqual("1")
expect(rowOne.f).toEqual(['One'])
expect(rowOne.g).toEqual('Alpha')
const rowTwo = rows.find(row => row.e === 2)
expect(rowTwo.a).toEqual("5")
expect(rowTwo.f).toEqual([])
expect(rowTwo.g).toEqual(undefined)
const rowThree = rows.find(row => row.e === 3)
expect(rowThree.a).toEqual("9")
expect(rowThree.f).toEqual(['Two','Four'])
expect(rowThree.g).toEqual(null)
const rowFour = rows.find(row => row.e === 4)
expect(rowFour.a).toEqual("13")
expect(rowFour.f).toEqual(undefined)
expect(rowFour.g).toEqual('Omega')
})
})
})

View File

@ -34,9 +34,9 @@ describe("/rows", () => {
row = basicRow(table._id)
})
const loadRow = async (id, status = 200) =>
const loadRow = async (id, tbl_Id, status = 200) =>
await request
.get(`/api/${table._id}/rows/${id}`)
.get(`/api/${tbl_Id}/rows/${id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(status)
@ -79,6 +79,60 @@ describe("/rows", () => {
await assertQueryUsage(queryUsage + 1)
})
it("Increment row autoId per create row request", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const newTable = await config.createTable({
name: "TestTableAuto",
type: "table",
key: "name",
schema: {
...table.schema,
"Row ID": {
name: "Row ID",
type: "number",
subtype: "autoID",
icon: "ri-magic-line",
autocolumn: true,
constraints: {
type: "number",
presence: false,
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
},
}
})
const ids = [1,2,3]
// Performing several create row requests should increment the autoID fields accordingly
const createRow = async (id) => {
const res = await request
.post(`/api/${newTable._id}/rows`)
.send({
name: "row_" + id
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual(`${newTable.name} saved successfully`)
expect(res.body.name).toEqual("row_" + id)
expect(res.body._rev).toBeDefined()
expect(res.body["Row ID"]).toEqual(id)
}
for (let i=0; i<ids.length; i++ ){
await createRow(ids[i])
}
await assertRowUsage(rowUsage + ids.length)
await assertQueryUsage(queryUsage + ids.length)
})
it("updates a row successfully", async () => {
const existing = await config.createRow()
const rowUsage = await getRowUsage()
@ -182,8 +236,32 @@ describe("/rows", () => {
type: "string",
presence: false,
datetime: { earliest: "", latest: "" },
},
}
}
const arrayField = {
type: "array",
constraints: {
type: "array",
presence: false,
inclusion: [
"One",
"Two",
"Three",
]
},
name: "Sample Tags",
sortable: false
}
const optsField = {
fieldName: "Sample Opts",
name: "Sample Opts",
type: "options",
constraints: {
type: "string",
presence: false,
inclusion: [ "Alpha", "Beta", "Gamma" ]
},
},
table = await config.createTable({
name: "TestTable2",
@ -212,7 +290,15 @@ describe("/rows", () => {
attachmentNull: attachment,
attachmentUndefined: 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,
attachmentEmpty: "",
attachmentEmptyArrayStr: "[]",
arrayFieldEmptyArrayStr: "[]",
arrayFieldUndefined: undefined,
arrayFieldNull: null,
arrayFieldArrayStrKnown: "['One']",
optsFieldEmptyStr: "",
optsFieldUndefined: undefined,
optsFieldNull: null,
optsFieldStrKnown: 'Alpha'
}
const id = (await config.createRow(row))._id
const createdRow = await config.createRow(row);
const id = createdRow._id
const saved = (await loadRow(id)).body
const saved = (await loadRow(id, table._id)).body
expect(saved.stringUndefined).toBe(undefined)
expect(saved.stringNull).toBe("")
@ -270,7 +365,15 @@ describe("/rows", () => {
expect(saved.attachmentNull).toEqual([])
expect(saved.attachmentUndefined).toBe(undefined)
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.description).toEqual(existing.description)
const savedRow = await loadRow(res.body._id)
const savedRow = await loadRow(res.body._id, table._id)
expect(savedRow.body.description).toEqual(existing.description)
expect(savedRow.body.name).toEqual("Updated Name")
@ -401,7 +504,7 @@ describe("/rows", () => {
.expect(200)
expect(res.body.length).toEqual(2)
await loadRow(row1._id, 404)
await loadRow(row1._id, table._id, 404)
await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1)
})

View File

@ -167,7 +167,10 @@ describe("/tables", () => {
expect(events.table.created).not.toHaveBeenCalled()
expect(events.rows.imported).toBeCalledTimes(1)
expect(events.rows.imported).toBeCalledWith(table, 1)
expect(events.rows.imported).toBeCalledWith(expect.objectContaining({
name: "TestTable",
_id: table._id
}), 1)
})
})

View File

@ -137,8 +137,7 @@ export function inputProcessing(
opts?: AutoColumnProcessingOpts
) {
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"]
for (let [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key]
@ -175,7 +174,7 @@ export function inputProcessing(
}
// 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 { 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.
*/
@ -26,9 +42,9 @@ export const TYPE_TRANSFORM_MAP: any = {
[undefined]: undefined,
},
[FieldTypes.ARRAY]: {
"": [],
[null]: [],
[undefined]: undefined,
parse: parseArrayString,
},
[FieldTypes.STRING]: {
"": "",
@ -70,21 +86,7 @@ export const TYPE_TRANSFORM_MAP: any = {
[FieldTypes.ATTACHMENT]: {
[null]: [],
[undefined]: undefined,
parse: attachments => {
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
},
parse: parseArrayString,
},
[FieldTypes.BOOLEAN]: {
"": null,

View File

@ -1,5 +1,9 @@
import { events } from "@budibase/backend-core"
import { generator } from "@budibase/backend-core/tests"
import { structures, TestConfiguration, mocks } from "../../../../tests"
import { UserGroup } from "@budibase/types"
mocks.licenses.useGroups()
describe("/api/global/groups", () => {
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(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
return 3600
case redis.utils.Databases.INVITATIONS:
// a day
// a week
return 604800
}
}

View File

@ -14,7 +14,7 @@
"test:watch": "yarn run test --watch",
"test:debug": "DEBUG=1 yarn run test",
"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",
"serve": "start-server-and-test dev:built http://localhost:4001/health",
"dev:built": "cd ../ && yarn dev:built"