Merge branch 'v3-ui' of github.com:Budibase/budibase into views-openapi

This commit is contained in:
mike12345567 2024-10-30 13:13:07 +00:00
commit 3b14b9207b
65 changed files with 1190 additions and 1398 deletions

View File

@ -3,7 +3,7 @@ name: Deploy QA
on: on:
push: push:
branches: branches:
- v3-ui - feature/automation-branching-ux
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -22,7 +22,7 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile --network-concurrency 1
# copy the actual code # copy the actual code
COPY packages/server/dist packages/server/dist COPY packages/server/dist packages/server/dist

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.33.3", "version": "2.33.12",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -171,9 +171,9 @@ const identifyUser = async (
if (isSSOUser(user)) { if (isSSOUser(user)) {
providerType = user.providerType providerType = user.providerType
} }
const accountHolder = account?.budibaseUserId === user._id || false const accountHolder = await users.getExistingAccounts([user.email])
const verified = const isAccountHolder = accountHolder.length > 0
account && account?.budibaseUserId === user._id ? account.verified : false const verified = !!account && isAccountHolder && account.verified
const installationId = await getInstallationId() const installationId = await getInstallationId()
const hosting = account ? account.hosting : getHostingFromEnv() const hosting = account ? account.hosting : getHostingFromEnv()
const environment = getDeploymentEnvironment() const environment = getDeploymentEnvironment()
@ -185,7 +185,7 @@ const identifyUser = async (
installationId, installationId,
tenantId, tenantId,
verified, verified,
accountHolder, accountHolder: isAccountHolder,
providerType, providerType,
builder, builder,
admin, admin,
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
const environment = getDeploymentEnvironment() const environment = getDeploymentEnvironment()
if (isCloudAccount(account)) { if (isCloudAccount(account)) {
if (account.budibaseUserId) { const user = await users.getGlobalUserByEmail(account.email)
if (user?._id) {
// use the budibase user as the id if set // use the budibase user as the id if set
id = account.budibaseUserId id = user._id
} }
} }

View File

@ -237,7 +237,10 @@ export function validInherits(
export function builtinRoleToNumber(id: string) { export function builtinRoleToNumber(id: string) {
const builtins = getBuiltinRoles() const builtins = getBuiltinRoles()
const MAX = Object.values(builtins).length + 1 const MAX = Object.values(builtins).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { if (
roleIDsAreEqual(id, BUILTIN_IDS.ADMIN) ||
roleIDsAreEqual(id, BUILTIN_IDS.BUILDER)
) {
return MAX return MAX
} }
let role = builtins[id], let role = builtins[id],
@ -274,7 +277,9 @@ export async function roleToNumber(id: string) {
// find the built-in roles, get their number, sort it, then get the last one // find the built-in roles, get their number, sort it, then get the last one
const highestBuiltin: number | undefined = role.inherits const highestBuiltin: number | undefined = role.inherits
.map(roleId => { .map(roleId => {
const foundRole = hierarchy.find(role => role._id === roleId) const foundRole = hierarchy.find(role =>
roleIDsAreEqual(role._id!, roleId)
)
if (foundRole) { if (foundRole) {
return findNumber(foundRole) + 1 return findNumber(foundRole) + 1
} }
@ -398,7 +403,7 @@ async function getAllUserRoles(
): Promise<RoleDoc[]> { ): Promise<RoleDoc[]> {
const allRoles = await getAllRoles() const allRoles = await getAllRoles()
// admins have access to all roles // admins have access to all roles
if (userRoleId === BUILTIN_IDS.ADMIN) { if (roleIDsAreEqual(userRoleId, BUILTIN_IDS.ADMIN)) {
return allRoles return allRoles
} }
@ -509,17 +514,21 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
// need to combine builtin with any DB record of them (for sake of permissions) // need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of externalBuiltinRoles) { for (let builtinRoleId of externalBuiltinRoles) {
const builtinRole = builtinRoles[builtinRoleId] const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter( const dbBuiltin = roles.filter(dbRole =>
dbRole => roleIDsAreEqual(dbRole._id!, builtinRoleId)
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
)[0] )[0]
if (dbBuiltin == null) { if (dbBuiltin == null) {
roles.push(builtinRole || builtinRoles.BASIC) roles.push(builtinRole || builtinRoles.BASIC)
} else { } else {
// remove role and all back after combining with the builtin // remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id) roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version) dbBuiltin._id = getExternalRoleID(builtinRole._id!, dbBuiltin.version)
roles.push(Object.assign(builtinRole, dbBuiltin)) roles.push({
...builtinRole,
...dbBuiltin,
name: builtinRole.name,
_id: getExternalRoleID(builtinRole._id!, builtinRole.version),
})
} }
} }
// check permissions // check permissions
@ -565,9 +574,9 @@ export class AccessController {
if ( if (
tryingRoleId == null || tryingRoleId == null ||
tryingRoleId === "" || tryingRoleId === "" ||
tryingRoleId === userRoleId || roleIDsAreEqual(tryingRoleId, BUILTIN_IDS.BUILDER) ||
tryingRoleId === BUILTIN_IDS.BUILDER || roleIDsAreEqual(userRoleId!, tryingRoleId) ||
userRoleId === BUILTIN_IDS.BUILDER roleIDsAreEqual(userRoleId!, BUILTIN_IDS.BUILDER)
) { ) {
return true return true
} }

View File

@ -179,12 +179,6 @@ class InternalBuilder {
return this.table.schema[column] return this.table.schema[column]
} }
private supportsILike(): boolean {
return !(
this.client === SqlClient.ORACLE || this.client === SqlClient.SQL_LITE
)
}
private quoteChars(): [string, string] { private quoteChars(): [string, string] {
const wrapped = this.knexClient.wrapIdentifier("foo", {}) const wrapped = this.knexClient.wrapIdentifier("foo", {})
return [wrapped[0], wrapped[wrapped.length - 1]] return [wrapped[0], wrapped[wrapped.length - 1]]
@ -216,8 +210,30 @@ class InternalBuilder {
return formatter.wrap(value, false) return formatter.wrap(value, false)
} }
private rawQuotedValue(value: string): Knex.Raw { private castIntToString(identifier: string | Knex.Raw): Knex.Raw {
return this.knex.raw(this.quotedValue(value)) switch (this.client) {
case SqlClient.ORACLE: {
return this.knex.raw("to_char(??)", [identifier])
}
case SqlClient.POSTGRES: {
return this.knex.raw("??::TEXT", [identifier])
}
case SqlClient.MY_SQL:
case SqlClient.MARIADB: {
return this.knex.raw("CAST(?? AS CHAR)", [identifier])
}
case SqlClient.SQL_LITE: {
// Technically sqlite can actually represent numbers larger than a 64bit
// int as a string, but it does it using scientific notation (e.g.
// "1e+20") which is not what we want. Given that the external SQL
// databases are limited to supporting only 64bit ints, we settle for
// that here.
return this.knex.raw("printf('%d', ??)", [identifier])
}
case SqlClient.MS_SQL: {
return this.knex.raw("CONVERT(NVARCHAR, ??)", [identifier])
}
}
} }
// Unfortuantely we cannot rely on knex's identifier escaping because it trims // Unfortuantely we cannot rely on knex's identifier escaping because it trims
@ -1078,21 +1094,26 @@ class InternalBuilder {
query = query.count(`* as ${aggregation.name}`) query = query.count(`* as ${aggregation.name}`)
} }
} else { } else {
const field = `${tableName}.${aggregation.field} as ${aggregation.name}` const fieldSchema = this.getFieldSchema(aggregation.field)
switch (op) { if (!fieldSchema) {
case CalculationType.SUM: // This should not happen in practice.
query = query.sum(field) throw new Error(
break `field schema missing for aggregation target: ${aggregation.field}`
case CalculationType.AVG: )
query = query.avg(field)
break
case CalculationType.MIN:
query = query.min(field)
break
case CalculationType.MAX:
query = query.max(field)
break
} }
let aggregate = this.knex.raw("??(??)", [
this.knex.raw(op),
this.rawQuotedIdentifier(`${tableName}.${aggregation.field}`),
])
if (fieldSchema.type === FieldType.BIGINT) {
aggregate = this.castIntToString(aggregate)
}
query = query.select(
this.knex.raw("?? as ??", [aggregate, aggregation.name])
)
} }
} }
return query return query

View File

@ -1,29 +1,6 @@
import { getDB } from "../db/db" import { getDB } from "../db/db"
import { getGlobalDBName } from "../context" import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) { export function getTenantDB(tenantId: string) {
return getDB(getGlobalDBName(tenantId)) return getDB(getGlobalDBName(tenantId))
} }
export async function saveTenantInfo(tenantInfo: TenantInfo) {
const db = getTenantDB(tenantInfo.tenantId)
// save the tenant info to db
return db.put({
_id: "tenant_info",
...tenantInfo,
})
}
export async function getTenantInfo(
tenantId: string
): Promise<TenantInfo | undefined> {
try {
const db = getTenantDB(tenantId)
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
delete tenantInfo.owner.password
return tenantInfo
} catch {
return undefined
}
}

View File

@ -16,14 +16,15 @@ import {
isSSOUser, isSSOUser,
SaveUserOpts, SaveUserOpts,
User, User,
UserStatus,
UserGroup, UserGroup,
UserIdentifier,
UserStatus,
PlatformUserBySsoId, PlatformUserBySsoId,
PlatformUserById, PlatformUserById,
AnyDocument, AnyDocument,
} from "@budibase/types" } from "@budibase/types"
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUsers,
isAdmin, isAdmin,
isCreator, isCreator,
validateUniqueUser, validateUniqueUser,
@ -412,7 +413,9 @@ export class UserDB {
) )
} }
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> { static async bulkDelete(
users: Array<UserIdentifier>
): Promise<BulkUserDeleted> {
const db = getGlobalDB() const db = getGlobalDB()
const response: BulkUserDeleted = { const response: BulkUserDeleted = {
@ -421,13 +424,13 @@ export class UserDB {
} }
// remove the account holder from the delete request if present // remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds) const accountHolder = await getAccountHolderFromUsers(users)
if (account) { if (accountHolder) {
userIds = userIds.filter(u => u !== account.budibaseUserId) users = users.filter(u => u.userId !== accountHolder.userId)
// mark user as unsuccessful // mark user as unsuccessful
response.unsuccessful.push({ response.unsuccessful.push({
_id: account.budibaseUserId, _id: accountHolder.userId,
email: account.email, email: accountHolder.email,
reason: "Account holder cannot be deleted", reason: "Account holder cannot be deleted",
}) })
} }
@ -435,7 +438,7 @@ export class UserDB {
// Get users and delete // Get users and delete
const allDocsResponse = await db.allDocs<User>({ const allDocsResponse = await db.allDocs<User>({
include_docs: true, include_docs: true,
keys: userIds, keys: users.map(u => u.userId),
}) })
const usersToDelete = allDocsResponse.rows.map(user => { const usersToDelete = allDocsResponse.rows.map(user => {
return user.doc! return user.doc!

View File

@ -70,6 +70,17 @@ export async function getAllUserIds() {
return response.rows.map(row => row.id) return response.rows.map(row => row.id)
} }
export async function getAllUsers(): Promise<User[]> {
const db = getGlobalDB()
const startKey = `${DocumentType.USER}${SEPARATOR}`
const response = await db.allDocs({
startkey: startKey,
endkey: `${startKey}${UNICODE_MAX}`,
include_docs: true,
})
return response.rows.map(row => row.doc) as User[]
}
export async function bulkUpdateGlobalUsers(users: User[]) { export async function bulkUpdateGlobalUsers(users: User[]) {
const db = getGlobalDB() const db = getGlobalDB()
return (await db.bulkDocs(users)) as BulkDocsResponse return (await db.bulkDocs(users)) as BulkDocsResponse

View File

@ -1,11 +1,9 @@
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types" import { ContextUser, User, UserGroup, UserIdentifier } from "@budibase/types"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
import env from "../environment" import env from "../environment"
import { getFirstPlatformUser } from "./lookup" import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors" import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
import { BUILTIN_ROLE_IDS } from "../security/roles" import { BUILTIN_ROLE_IDS } from "../security/roles"
import * as context from "../context" import * as context from "../context"
@ -67,21 +65,17 @@ export async function validateUniqueUser(email: string, tenantId: string) {
} }
/** /**
* For the given user id's, return the account holder if it is in the ids. * For a list of users, return the account holder if there is an email match.
*/ */
export async function getAccountHolderFromUserIds( export async function getAccountHolderFromUsers(
userIds: string[] users: Array<UserIdentifier>
): Promise<CloudAccount | undefined> { ): Promise<UserIdentifier | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId() const accountMetadata = await getExistingAccounts(
const account = await getAccountByTenantId(tenantId) users.map(user => user.email)
if (!account) { )
throw new Error(`Account not found for tenantId=${tenantId}`) return users.find(user =>
} accountMetadata.map(metadata => metadata.email).includes(user.email)
)
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
} }
} }

View File

@ -1,20 +1,144 @@
<script> <script>
import { ActionButton, Modal } from "@budibase/bbui" import {
import ExportModal from "../modals/ExportModal.svelte" ActionButton,
Select,
notifications,
Body,
Button,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { ROW_EXPORT_FORMATS } from "constants/backend"
import DetailPopover from "components/common/DetailPopover.svelte"
export let view export let view
export let filters
export let sorting export let sorting
export let disabled = false export let disabled = false
export let selectedRows export let selectedRows
export let formats export let formats
let modal const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
let popover
let exportFormat
let loading = false
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
const openPopover = () => {
loading = false
popover.show()
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
const exportAllData = async () => {
return await API.exportView({
viewName: view,
format: exportFormat,
})
}
const exportFilteredData = async () => {
let payload = {
tableId: view,
format: exportFormat,
search: {
paginate: false,
},
}
if (selectedRows?.length) {
payload.rows = selectedRows.map(row => row._id)
}
if (sorting) {
payload.search.sort = sorting.sortColumn
payload.search.sortOrder = sorting.sortOrder
}
return await API.exportRows(payload)
}
const exportData = async () => {
try {
loading = true
let data
if (selectedRows?.length || sorting) {
data = await exportFilteredData()
} else {
data = await exportAllData()
}
notifications.success("Export successful")
downloadWithBlob(data, `export.${exportFormat}`)
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Error exporting data")
} finally {
loading = false
}
}
</script> </script>
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}> <DetailPopover title="Export data" bind:this={popover}>
Export <svelte:fragment slot="anchor" let:open>
</ActionButton> <ActionButton
<Modal bind:this={modal}> icon="DataDownload"
<ExportModal {view} {filters} {sorting} {selectedRows} {formats} /> quiet
</Modal> on:click={openPopover}
{disabled}
selected={open}
>
Export
</ActionButton>
</svelte:fragment>
{#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported.`}
</span>
</Body>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows.
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
<div>
<Button cta disabled={loading} on:click={exportData}>Export</Button>
</div>
</DetailPopover>

View File

@ -1,17 +1,81 @@
<script> <script>
import { ActionButton, Modal } from "@budibase/bbui" import { ActionButton, Button, Body, notifications } from "@budibase/bbui"
import ImportModal from "../modals/ImportModal.svelte" import DetailPopover from "components/common/DetailPopover.svelte"
import ExistingTableDataImport from "components/backend/TableNavigator/ExistingTableDataImport.svelte"
import { createEventDispatcher } from "svelte"
import { API } from "api"
export let tableId export let tableId
export let tableType export let tableType
export let disabled export let disabled
let modal const dispatch = createEventDispatcher()
let popover
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
let loading = false
const openPopover = () => {
rows = []
allValid = false
displayColumn = null
identifierFields = []
loading = false
popover.show()
}
const importData = async () => {
try {
loading = true
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Unable to import data")
} finally {
loading = false
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script> </script>
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}> <DetailPopover title="Import data" bind:this={popover}>
Import <svelte:fragment slot="anchor" let:open>
</ActionButton> <ActionButton
<Modal bind:this={modal}> icon="DataUpload"
<ImportModal {tableId} {tableType} on:importrows /> quiet
</Modal> on:click={openPopover}
{disabled}
selected={open}
>
Import
</ActionButton>
</svelte:fragment>
<Body size="S">
Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<ExistingTableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
<div>
<Button cta disabled={loading || !allValid} on:click={importData}>
Import
</Button>
</div>
</DetailPopover>

View File

@ -1,11 +1,12 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui" import { ActionButton, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding" import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core" import { search } from "@budibase/frontend-core"
import { tables } from "stores/builder" import { tables } from "stores/builder"
import DetailPopover from "components/common/DetailPopover.svelte"
export let schema export let schema
export let filters export let filters
@ -14,7 +15,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let drawer let popover
$: localFilters = filters $: localFilters = filters
$: schemaFields = search.getFields( $: schemaFields = search.getFields(
@ -39,45 +40,44 @@
}, },
...getUserBindings(), ...getUserBindings(),
] ]
const openPopover = () => {
localFilters = filters
popover.show()
}
</script> </script>
<ActionButton <DetailPopover bind:this={popover} title="Configure filters" width={800}>
icon="Filter" <svelte:fragment slot="anchor" let:open>
quiet <ActionButton
{disabled} icon="Filter"
on:click={drawer.show} quiet
selected={filterCount > 0} {disabled}
accentColor="#004EA6" on:click={openPopover}
> selected={open || filterCount > 0}
{filterCount ? `Filter: ${filterCount}` : "Filter"} accentColor="#004EA6"
</ActionButton> >
{filterCount ? `Filter: ${filterCount}` : "Filter"}
</ActionButton>
</svelte:fragment>
<Drawer <FilterBuilder
bind:this={drawer} filters={localFilters}
title="Filtering" {schemaFields}
on:drawerHide datasource={{ type: "table", tableId }}
on:drawerShow={() => { on:change={e => (localFilters = e.detail)}
localFilters = filters {bindings}
}} />
forceModal <div>
> <Button
<Button cta
cta slot="buttons"
slot="buttons" on:click={() => {
on:click={() => { dispatch("change", localFilters)
dispatch("change", localFilters) popover.hide()
drawer.hide() }}
}} >
> Save
Save </Button>
</Button> </div>
<DrawerContent slot="body"> </DetailPopover>
<FilterBuilder
filters={localFilters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (localFilters = e.detail)}
{bindings}
/>
</DrawerContent>
</Drawer>

View File

@ -210,16 +210,18 @@
anchor={relationshipPanelAnchor} anchor={relationshipPanelAnchor}
align="left" align="left"
> >
{#if relationshipPanelColumns.length} <div class="nested">
<div class="relationship-header"> {#if relationshipPanelColumns.length}
{relationshipFieldName} columns <div class="relationship-header">
</div> {relationshipFieldName} columns
{/if} </div>
<svelte:self {/if}
columns={relationshipPanelColumns} <svelte:self
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]} columns={relationshipPanelColumns}
fromRelationshipField={relationshipField} permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
/> fromRelationshipField={relationshipField}
/>
</div>
</Popover> </Popover>
{/if} {/if}
@ -230,11 +232,13 @@
} }
.content { .content {
padding: 12px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.nested {
padding: 12px;
}
.columns { .columns {
display: grid; display: grid;
align-items: center; align-items: center;
@ -262,6 +266,6 @@
} }
.relationship-header { .relationship-header {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
padding: 12px 12px 0 12px; margin-bottom: 12px;
} }
</style> </style>

View File

@ -8,15 +8,15 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import ColumnsSettingContent from "./ColumnsSettingContent.svelte" import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
import { isEnabled } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
import { FeatureFlag } from "@budibase/types" import { FeatureFlag } from "@budibase/types"
import DetailPopover from "components/common/DetailPopover.svelte"
const { tableColumns, datasource } = getContext("grid") const { tableColumns, datasource } = getContext("grid")
let open = false let popover
let anchor
$: anyRestricted = $tableColumns.filter( $: anyRestricted = $tableColumns.filter(
col => !col.visible || col.readonly col => !col.visible || col.readonly
@ -32,24 +32,23 @@
: [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN] : [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN]
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Column settings">
<ActionButton <svelte:fragment slot="anchor" let:open>
icon="ColumnSettings" <ActionButton
quiet icon="ColumnSettings"
size="M" quiet
on:click={() => (open = !open)} size="M"
selected={open || anyRestricted} on:click={popover?.open}
disabled={!$tableColumns.length} selected={open || anyRestricted}
accentColor="#674D00" disabled={!$tableColumns.length}
> accentColor="#674D00"
{text} >
</ActionButton> {text}
</div> </ActionButton>
</svelte:fragment>
<Popover bind:open {anchor} align="left">
<ColumnsSettingContent <ColumnsSettingContent
columns={$tableColumns} columns={$tableColumns}
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)} canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
{permissions} {permissions}
/> />
</Popover> </DetailPopover>

View File

@ -9,21 +9,13 @@
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id })) $: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
</script> </script>
<span data-ignore-click-outside="true"> <ExportButton
<ExportButton {disabled}
{disabled} view={$datasource.tableId}
view={$datasource.tableId} filters={$filter}
filters={$filter} sorting={{
sorting={{ sortColumn: $sort.column,
sortColumn: $sort.column, sortOrder: $sort.order,
sortOrder: $sort.order, }}
}} selectedRows={selectedRowArray}
selectedRows={selectedRowArray} />
/>
</span>
<style>
span {
display: contents;
}
</style>

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Label } from "@budibase/bbui" import { ActionButton, Label } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
const { const {
Constants, Constants,
@ -32,8 +33,7 @@
}, },
] ]
let open = false let popover
let anchor
// Column width sizes // Column width sizes
$: allSmall = $columns.every(col => col.width === smallColSize) $: allSmall = $columns.every(col => col.width === smallColSize)
@ -66,63 +66,54 @@
} }
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Column and row size" width={300}>
<ActionButton <svelte:fragment slot="anchor" let:open>
icon="MoveUpDown" <ActionButton
quiet icon="MoveUpDown"
size="M" quiet
on:click={() => (open = !open)} size="M"
selected={open} on:click={popover?.open}
disabled={!$columns.length} selected={open}
> disabled={!$columns.length}
Size >
</ActionButton> Size
</div> </ActionButton>
</svelte:fragment>
<Popover bind:open {anchor} align="left"> <div class="size">
<div class="content"> <Label>Row height</Label>
<div class="size"> <div class="options">
<Label>Row height</Label> {#each rowSizeOptions as option}
<div class="options"> <ActionButton
{#each rowSizeOptions as option} disabled={$fixedRowHeight}
<ActionButton quiet
disabled={$fixedRowHeight} selected={$rowHeight === option.size}
quiet on:click={() => changeRowHeight(option.size)}
selected={$rowHeight === option.size} >
on:click={() => changeRowHeight(option.size)} {option.label}
> </ActionButton>
{option.label} {/each}
</ActionButton>
{/each}
</div>
</div>
<div class="size">
<Label>Column width</Label>
<div class="options">
{#each columnSizeOptions as option}
<ActionButton
quiet
on:click={() => columns.actions.changeAllColumnWidths(option.size)}
selected={option.selected}
>
{option.label}
</ActionButton>
{/each}
{#if custom}
<ActionButton selected={custom} quiet>Custom</ActionButton>
{/if}
</div>
</div> </div>
</div> </div>
</Popover> <div class="size">
<Label>Column width</Label>
<div class="options">
{#each columnSizeOptions as option}
<ActionButton
quiet
on:click={() => columns.actions.changeAllColumnWidths(option.size)}
selected={option.selected}
>
{option.label}
</ActionButton>
{/each}
{#if custom}
<ActionButton selected={custom} quiet>Custom</ActionButton>
{/if}
</div>
</div>
</DetailPopover>
<style> <style>
.content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.size { .size {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,12 +1,12 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui" import { ActionButton, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/frontend-core" import { canBeSortColumn } from "@budibase/frontend-core"
import DetailPopover from "components/common/DetailPopover.svelte"
const { sort, columns } = getContext("grid") const { sort, columns } = getContext("grid")
let open = false let popover
let anchor
$: columnOptions = $columns $: columnOptions = $columns
.filter(col => canBeSortColumn(col.schema)) .filter(col => canBeSortColumn(col.schema))
@ -45,50 +45,35 @@
} }
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Sorting" width={300}>
<ActionButton <svelte:fragment slot="anchor" let:open>
icon="SortOrderDown" <ActionButton
quiet icon="SortOrderDown"
size="M" quiet
on:click={() => (open = !open)} size="M"
selected={open} on:click={popover?.open}
disabled={!columnOptions.length} selected={open}
> disabled={!columnOptions.length}
Sort >
</ActionButton> Sort
</div> </ActionButton>
</svelte:fragment>
<Popover bind:open {anchor} align="left"> <Select
<div class="content"> placeholder="Default"
value={$sort.column}
options={columnOptions}
autoWidth
on:change={updateSortColumn}
label="Column"
/>
{#if $sort.column}
<Select <Select
placeholder="Default" placeholder={null}
value={$sort.column} value={$sort.order || "ascending"}
options={columnOptions} options={orderOptions}
autoWidth autoWidth
on:change={updateSortColumn} on:change={updateSortOrder}
label="Column" label="Order"
/> />
{#if $sort.column} {/if}
<Select </DetailPopover>
placeholder={null}
value={$sort.order || "ascending"}
options={orderOptions}
autoWidth
on:change={updateSortOrder}
label="Order"
/>
{/if}
</div>
</Popover>
<style>
.content {
padding: 6px 12px 12px 12px;
display: flex;
align-items: center;
gap: 8px;
}
.content :global(.spectrum-Picker) {
width: 140px;
}
</style>

View File

@ -1,15 +1,15 @@
<script> <script>
import { import {
ActionButton, ActionButton,
Modal,
ModalContent,
Select, Select,
Icon, Icon,
Multiselect, Multiselect,
Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import { CalculationType, canGroupBy, FieldType } from "@budibase/types" import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import DetailPopover from "components/common/DetailPopover.svelte"
const { definition, datasource, rows } = getContext("grid") const { definition, datasource, rows } = getContext("grid")
const calculationTypeOptions = [ const calculationTypeOptions = [
@ -35,19 +35,20 @@
}, },
] ]
let modal let popover
let calculations = [] let calculations = []
let groupBy = [] let groupBy = []
let schema = {} let schema = {}
let loading = false
$: schema = $definition?.schema || {} $: schema = $definition?.schema || {}
$: count = extractCalculations($definition?.schema || {}).length $: count = extractCalculations($definition?.schema || {}).length
$: groupByOptions = getGroupByOptions(schema) $: groupByOptions = getGroupByOptions(schema)
const open = () => { const openPopover = () => {
calculations = extractCalculations(schema) calculations = extractCalculations(schema)
groupBy = calculations.length ? extractGroupBy(schema) : [] groupBy = calculations.length ? extractGroupBy(schema) : []
modal?.show() popover?.show()
} }
const extractCalculations = schema => { const extractCalculations = schema => {
@ -90,10 +91,7 @@
return Object.entries(schema) return Object.entries(schema)
.filter(([field, fieldSchema]) => { .filter(([field, fieldSchema]) => {
// Only allow numeric fields that are not calculations themselves // Only allow numeric fields that are not calculations themselves
if ( if (fieldSchema.calculationType || !isNumeric(fieldSchema.type)) {
fieldSchema.calculationType ||
fieldSchema.type !== FieldType.NUMBER
) {
return false return false
} }
// Don't allow duplicates // Don't allow duplicates
@ -135,6 +133,7 @@
const save = async () => { const save = async () => {
let newSchema = {} let newSchema = {}
loading = true
// Add calculations // Add calculations
for (let calc of calculations) { for (let calc of calculations) {
@ -168,76 +167,80 @@
} }
// Save changes // Save changes
await datasource.actions.saveDefinition({ try {
...$definition, await datasource.actions.saveDefinition({
primaryDisplay, ...$definition,
schema: newSchema, primaryDisplay,
}) schema: newSchema,
await rows.actions.refreshData() })
await rows.actions.refreshData()
} finally {
loading = false
popover.hide()
}
} }
</script> </script>
<ActionButton icon="WebPage" quiet on:click={open}> <DetailPopover bind:this={popover} title="Configure calculations" width={480}>
Configure calculations{count ? `: ${count}` : ""} <svelte:fragment slot="anchor" let:open>
</ActionButton> <ActionButton icon="WebPage" quiet on:click={openPopover} selected={open}>
Configure calculations{count ? `: ${count}` : ""}
</ActionButton>
</svelte:fragment>
<Modal bind:this={modal}> {#if calculations.length}
<ModalContent <div class="calculations">
title="Calculations" {#each calculations as calc, idx}
confirmText="Save" <span>{idx === 0 ? "Calculate" : "and"} the</span>
size="M" <Select
onConfirm={save} options={getTypeOptions(calc, calculations)}
> bind:value={calc.type}
{#if calculations.length} placeholder={false}
<div class="calculations"> />
{#each calculations as calc, idx} <span>of</span>
<span>{idx === 0 ? "Calculate" : "and"} the</span> <Select
<Select options={getFieldOptions(calc, calculations, schema)}
options={getTypeOptions(calc, calculations)} bind:value={calc.field}
bind:value={calc.type} placeholder="Column"
placeholder={false} />
/> <Icon
<span>of</span> hoverable
<Select name="Delete"
options={getFieldOptions(calc, calculations, schema)} size="S"
bind:value={calc.field} on:click={() => deleteCalc(idx)}
placeholder="Column" color="var(--spectrum-global-color-gray-700)"
/> />
<Icon {/each}
hoverable <span>Group by</span>
name="Delete" <div class="group-by">
size="S" <Multiselect
on:click={() => deleteCalc(idx)} options={groupByOptions}
color="var(--spectrum-global-color-gray-700)" bind:value={groupBy}
/> placeholder="None"
{/each} />
<span>Group by</span>
<div class="group-by">
<Multiselect
options={groupByOptions}
bind:value={groupBy}
placeholder="None"
/>
</div>
</div> </div>
{/if}
<div class="buttons">
<ActionButton
quiet
icon="Add"
on:click={addCalc}
disabled={calculations.length >= 5}
>
Add calculation
</ActionButton>
</div> </div>
<InfoDisplay {/if}
icon="Help" <div class="buttons">
<ActionButton
quiet quiet
body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once." icon="Add"
/> on:click={addCalc}
</ModalContent> disabled={calculations.length >= 5}
</Modal> >
Add calculation
</ActionButton>
</div>
<InfoDisplay
icon="Help"
quiet
body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once."
/>
<div>
<Button cta on:click={save} disabled={loading}>Save</Button>
</div>
</DetailPopover>
<style> <style>
.calculations { .calculations {

View File

@ -1,224 +0,0 @@
<script>
import {
Select,
ModalContent,
notifications,
Body,
Table,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { QueryUtils } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view
export let filters
export let sorting
export let selectedRows = []
export let formats
const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
$: appliedFilters = filters?.filter(filter => !filter.onEmptyFilter)
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
let exportFormat
let filterLookup
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
$: query = QueryUtils.buildQuery(appliedFilters)
$: exportOpDisplay = buildExportOpDisplay(
sorting,
filterDisplay,
appliedFilters
)
filterLookup = utils.filterValueToLabel()
const filterDisplay = () => {
if (!appliedFilters) {
return []
}
return appliedFilters.map(filter => {
let newFieldName = filter.field + ""
const parts = newFieldName.split(":")
parts.shift()
newFieldName = parts.join(":")
return {
Field: newFieldName,
Operation: filterLookup[filter.operator],
"Field Value": filter.value || "",
}
})
}
const buildExportOpDisplay = (sorting, filterDisplay) => {
let filterDisplayConfig = filterDisplay()
if (sorting?.sortColumn) {
filterDisplayConfig = [
...filterDisplayConfig,
{
Field: sorting.sortColumn,
Operation: "Order By",
"Field Value": sorting.sortOrder,
},
]
}
return filterDisplayConfig
}
const displaySchema = {
Field: {
type: "string",
fieldName: "Field",
},
Operation: {
type: "string",
fieldName: "Operation",
},
"Field Value": {
type: "string",
fieldName: "Value",
},
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
async function exportView() {
try {
const data = await API.exportView({
viewName: view,
format: exportFormat,
})
downloadWithBlob(
data,
`export.${exportFormat === "csv" ? "csv" : "json"}`
)
} catch (error) {
notifications.error(`Unable to export ${exportFormat.toUpperCase()} data`)
}
}
async function exportRows() {
if (selectedRows?.length) {
const data = await API.exportRows({
tableId: view,
rows: selectedRows.map(row => row._id),
format: exportFormat,
})
downloadWithBlob(data, `export.${exportFormat}`)
} else if (appliedFilters || sorting) {
let response
try {
response = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
downloadWithBlob(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {
await exportView()
}
}
</script>
<ModalContent
title="Export Data"
confirmText="Export"
onConfirm={exportRows}
size={appliedFilters?.length || sorting ? "M" : "S"}
>
{#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported`}
</span>
</Body>
{:else if appliedFilters?.length || (sorting?.sortOrder && sorting?.sortColumn)}
<Body size="S">
{#if !appliedFilters}
<span data-testid="exporting-rows">
Exporting <strong>all</strong> rows
</span>
{:else}
<span data-testid="filters-applied">Filters applied</span>
{/if}
</Body>
<div class="table-wrap" data-testid="export-config-table">
<Table
schema={displaySchema}
data={exportOpDisplay}
{appliedFilters}
loading={false}
rowCount={appliedFilters?.length + 1}
disableSorting={true}
allowSelectRows={false}
allowEditRows={false}
allowEditColumns={false}
quiet={true}
compact={true}
/>
</div>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
</ModalContent>
<style>
.table-wrap :global(.wrapper) {
max-width: 400px;
}
</style>

View File

@ -1,243 +0,0 @@
import { it, expect, describe, vi } from "vitest"
import { render, screen } from "@testing-library/svelte"
import "@testing-library/jest-dom"
import ExportModal from "./ExportModal.svelte"
import { utils } from "@budibase/shared-core"
const labelLookup = utils.filterValueToLabel()
const rowText = filter => {
let readableField = filter.field.split(":")[1]
let rowLabel = labelLookup[filter.operator]
let value = Array.isArray(filter.value)
? JSON.stringify(filter.value)
: filter.value
return `${readableField}${rowLabel}${value}`.trim()
}
const defaultFilters = [
{
onEmptyFilter: "all",
},
]
vi.mock("svelte", async () => {
return {
getContext: () => {
return {
hide: vi.fn(),
cancel: vi.fn(),
}
},
createEventDispatcher: vi.fn(),
onDestroy: vi.fn(),
tick: vi.fn(),
}
})
vi.mock("api", async () => {
return {
API: {
exportView: vi.fn(),
exportRows: vi.fn(),
},
}
})
describe("Export Modal", () => {
it("show default messaging with no export config specified", () => {
render(ExportModal, {
props: {},
})
expect(screen.getByTestId("export-all-rows")).toBeVisible()
expect(screen.getByTestId("export-all-rows")).toHaveTextContent(
"Exporting all rows"
)
expect(screen.queryByTestId("export-config-table")).toBe(null)
})
it("indicate that a filter is being applied to the export", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
}
render(ExportModal, {
props: propsCfg,
})
expect(propsCfg.filters[0].field).toBe("1:Cost")
expect(screen.getByTestId("filters-applied")).toBeVisible()
expect(screen.getByTestId("filters-applied").textContent).toBe(
"Filters applied"
)
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
let rowTextContent = rowText(propsCfg.filters[0])
//"CostLess than or equal to100"
expect(rows[0].textContent?.trim()).toEqual(rowTextContent)
})
it("Show only selected row messaging if rows are supplied", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
selectedRows: [
{
_id: "ro_ta_bb_expenses_57d5f6fe1b6640d8bb22b15f5eae62cd",
},
{
_id: "ro_ta_bb_expenses_99ce5760a53a430bab4349cd70335a07",
},
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeNull()
expect(screen.queryByTestId("filters-applied")).toBeNull()
expect(screen.queryByTestId("exporting-n-rows")).toBeVisible()
expect(screen.queryByTestId("exporting-n-rows").textContent).toEqual(
"2 rows will be exported"
)
})
it("Show only the configured sort when no filters are specified", () => {
const propsCfg = {
filters: [...defaultFilters],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeVisible()
const ele = screen.queryByTestId("export-config-table")
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
expect(rows[0].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("Display all currently configured filters and applied sort", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
{
id: "2ot-aB0gE",
field: "2:Expense Tags",
operator: "contains",
value: ["Equipment", "Services"],
valueType: "Value",
type: "array",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Payment Due",
sortOrder: "ascending",
},
}
render(ExportModal, {
props: propsCfg,
})
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(3)
let rowTextContent1 = rowText(propsCfg.filters[0])
expect(rows[0].textContent?.trim()).toEqual(rowTextContent1)
let rowTextContent2 = rowText(propsCfg.filters[1])
expect(rows[1].textContent?.trim()).toEqual(rowTextContent2)
expect(rows[2].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("show only the valid, configured download formats", () => {
const propsCfg = {
formats: ["badger", "json"],
}
render(ExportModal, {
props: propsCfg,
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("JSON")
})
it("Load the default format config when no explicit formats are configured", () => {
render(ExportModal, {
props: {},
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("CSV")
})
})

View File

@ -1,61 +0,0 @@
<script>
import {
ModalContent,
Label,
notifications,
Body,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../../TableNavigator/ExistingTableDataImport.svelte"
import { API } from "api"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let tableId
export let tableType
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
async function importData() {
try {
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
} catch (error) {
notifications.error("Unable to import data")
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script>
<ModalContent
title="Import Data"
confirmText="Import"
onConfirm={importData}
disabled={!allValid}
>
<Body size="S">
Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<Layout gap="XS" noPadding>
<Label grey extraSmall>CSV or JSON file to import</Label>
<TableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
</Layout>
</ModalContent>

View File

@ -4,7 +4,7 @@
BBReferenceFieldSubType, BBReferenceFieldSubType,
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" import { Select, Toggle, Multiselect, Label, Layout } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend" import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api" import { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
@ -140,84 +140,91 @@
} }
</script> </script>
<div class="dropzone"> <Layout gap="S" noPadding>
<input <Layout noPadding gap="XS">
disabled={!schema || loading} <Label grey extraSmall>CSV or JSON file to import</Label>
id="file-upload" <div class="dropzone">
accept="text/csv,application/json" <input
type="file" disabled={!schema || loading}
on:change={handleFile} id="file-upload"
/> accept="text/csv,application/json"
<label for="file-upload" class:uploaded={rows.length > 0}> type="file"
{#if loading} on:change={handleFile}
loading...
{:else if error}
error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
{#if fileName && Object.keys(validation).length === 0}
<p>No valid fields, try another file</p>
{:else if rows.length > 0 && !error}
<div class="schema-fields">
{#each Object.keys(validation) as name}
<div class="field">
<span>{name}</span>
<Select
value={`${schema[name]?.type}${schema[name]?.subtype || ""}`}
options={typeOptions}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
disabled
/>
<span
class={loading || validation[name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{validation[name] ? "Success" : "Failure"}
</span>
</div>
{/each}
</div>
<br />
<!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{/if}
{#if updateExistingRows}
{#if tableType === DB_TYPE_INTERNAL}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/> />
{:else} <label for="file-upload" class:uploaded={rows.length > 0}>
<p>Rows will be updated based on the table's primary key.</p> {#if loading}
loading...
{:else if error}
error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
</Layout>
{#if fileName && Object.keys(validation).length === 0}
<div>No valid fields - please try another file.</div>
{:else if fileName && rows.length > 0 && !error}
<div>
{#each Object.keys(validation) as name}
<div class="field">
<span>{name}</span>
<Select
value={`${schema[name]?.type}${schema[name]?.subtype || ""}`}
options={typeOptions}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
disabled
/>
<span
class={loading || validation[name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{validation[name] ? "Success" : "Failure"}
</span>
</div>
{/each}
</div>
<!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{/if}
{#if updateExistingRows}
{#if tableType === DB_TYPE_INTERNAL}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/>
{:else}
<div>Rows will be updated based on the table's primary key.</div>
{/if}
{/if}
{#if invalidColumns.length > 0}
<Layout noPadding gap="XS">
<div>
The following columns are present in the data you wish to import, but
do not match the schema of this table and will be ignored:
</div>
<div>
{#each invalidColumns as column}
- {column}<br />
{/each}
</div>
</Layout>
{/if} {/if}
{/if} {/if}
{#if invalidColumns.length > 0} </Layout>
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM">
The following columns are present in the data you wish to import, but do
not match the schema of this table and will be ignored.
</p>
<ul class="ignoredList">
{#each invalidColumns as column}
<li>{column}</li>
{/each}
</ul>
{/if}
{/if}
<style> <style>
.dropzone { .dropzone {
@ -228,11 +235,9 @@
border-radius: 10px; border-radius: 10px;
transition: all 0.3s; transition: all 0.3s;
} }
input { input {
display: none; display: none;
} }
label { label {
font-family: var(--font-sans); font-family: var(--font-sans);
cursor: pointer; cursor: pointer;
@ -240,7 +245,6 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l); padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s; transition: all 0.2s ease 0s;
display: inline-flex; display: inline-flex;
@ -254,20 +258,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
background-color: var(--grey-2); background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-xs); font-size: var(--font-size-s);
line-height: normal; line-height: normal;
border: var(--border-transparent); border: var(--border-transparent);
} }
.uploaded { .uploaded {
color: var(--blue); color: var(--spectrum-global-color-blue-600);
} }
.schema-fields {
margin-top: var(--spacing-xl);
}
.field { .field {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1fr auto; grid-template-columns: 2fr 2fr 1fr auto;
@ -276,23 +274,14 @@
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
} }
.fieldStatusSuccess { .fieldStatusSuccess {
color: var(--green); color: var(--green);
justify-self: center; justify-self: center;
font-weight: 600; font-weight: 600;
} }
.fieldStatusFailure { .fieldStatusFailure {
color: var(--red); color: var(--red);
justify-self: center; justify-self: center;
font-weight: 600; font-weight: 600;
} }
.ignoredList {
margin: 0;
padding: 0;
list-style: none;
font-size: var(--spectrum-global-dimension-font-size-75);
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select, Icon } from "@budibase/bbui" import { Select, Icon, Layout, Label } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { canBeDisplayColumn } from "@budibase/frontend-core" import { canBeDisplayColumn } from "@budibase/frontend-core"
@ -184,70 +184,76 @@
} }
</script> </script>
<div class="dropzone"> <Layout noPadding gap="S">
<input <Layout gap="XS" noPadding>
bind:this={fileInput} <Label grey extraSmall>
disabled={loading} Create a Table from a CSV or JSON file (Optional)
id="file-upload" </Label>
accept="text/csv,application/json" <div class="dropzone">
type="file" <input
on:change={handleFile} bind:this={fileInput}
/> disabled={loading}
<label for="file-upload" class:uploaded={rawRows.length > 0}> id="file-upload"
{#if error} accept="text/csv,application/json"
Error: {error} type="file"
{:else if fileName} on:change={handleFile}
{fileName} />
{:else} <label for="file-upload" class:uploaded={rawRows.length > 0}>
Upload {#if error}
{/if} Error: {error}
</label> {:else if fileName}
</div> {fileName}
{#if rawRows.length > 0 && !error} {:else}
<div class="schema-fields"> Upload
{#each Object.entries(schema) as [name, column]} {/if}
<div class="field"> </label>
<span>{column.name}</span> </div>
<Select </Layout>
bind:value={selectedColumnTypes[column.name]}
on:change={e => handleChange(name, e)} {#if rawRows.length > 0 && !error}
options={Object.values(typeOptions)} <div>
placeholder={null} {#each Object.entries(schema) as [name, column]}
getOptionLabel={option => option.label} <div class="field">
getOptionValue={option => option.value} <span>{column.name}</span>
/> <Select
<span bind:value={selectedColumnTypes[column.name]}
class={validation[column.name] on:change={e => handleChange(name, e)}
? "fieldStatusSuccess" options={Object.values(typeOptions)}
: "fieldStatusFailure"} placeholder={null}
> getOptionLabel={option => option.label}
{#if validation[column.name]} getOptionValue={option => option.value}
Success />
{:else} <span
Failure class={validation[column.name]
{#if errors[column.name]} ? "fieldStatusSuccess"
<Icon name="Help" tooltip={errors[column.name]} /> : "fieldStatusFailure"}
>
{#if validation[column.name]}
Success
{:else}
Failure
{#if errors[column.name]}
<Icon name="Help" tooltip={errors[column.name]} />
{/if}
{/if} {/if}
{/if} </span>
</span> <Icon
<Icon size="S"
size="S" name="Close"
name="Close" hoverable
hoverable on:click={() => deleteColumn(column.name)}
on:click={() => deleteColumn(column.name)} />
/> </div>
</div> {/each}
{/each} </div>
</div>
<div class="display-column">
<Select <Select
label="Display Column" label="Display Column"
bind:value={displayColumn} bind:value={displayColumn}
options={displayColumnOptions} options={displayColumnOptions}
sort sort
/> />
</div> {/if}
{/if} </Layout>
<style> <style>
.dropzone { .dropzone {
@ -269,7 +275,6 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l); padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s; transition: all 0.2s ease 0s;
display: inline-flex; display: inline-flex;
@ -283,20 +288,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
background-color: var(--grey-2); background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-xs); font-size: var(--font-size-s);
line-height: normal; line-height: normal;
border: var(--border-transparent); border: var(--border-transparent);
} }
.uploaded { .uploaded {
color: var(--blue); color: var(--spectrum-global-color-blue-600);
} }
.schema-fields {
margin-top: var(--spacing-xl);
}
.field { .field {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1fr auto; grid-template-columns: 2fr 2fr 1fr auto;
@ -322,8 +321,4 @@
.fieldStatusFailure :global(.spectrum-Icon) { .fieldStatusFailure :global(.spectrum-Icon) {
width: 12px; width: 12px;
} }
.display-column {
margin-top: var(--spacing-xl);
}
</style> </style>

View File

@ -1,13 +1,7 @@
<script> <script>
import { goto, url } from "@roxi/routify" import { goto, url } from "@roxi/routify"
import { tables, datasources } from "stores/builder" import { tables, datasources } from "stores/builder"
import { import { notifications, Input, ModalContent } from "@budibase/bbui"
notifications,
Input,
Label,
ModalContent,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte" import TableDataImport from "../TableDataImport.svelte"
import { import {
BUDIBASE_INTERNAL_DB_ID, BUDIBASE_INTERNAL_DB_ID,
@ -101,18 +95,11 @@
bind:value={name} bind:value={name}
{error} {error}
/> />
<div> <TableDataImport
<Layout gap="XS" noPadding> {promptUpload}
<Label grey extraSmall bind:rows
>Create a Table from a CSV or JSON file (Optional)</Label bind:schema
> bind:allValid
<TableDataImport bind:displayColumn
{promptUpload} />
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</Layout>
</div>
</ModalContent> </ModalContent>

View File

@ -30,6 +30,7 @@
{showPopover} {showPopover}
on:open on:open
on:close on:close
customZindex={100}
> >
<div class="detail-popover"> <div class="detail-popover">
<div class="detail-popover__header"> <div class="detail-popover__header">

View File

@ -60,10 +60,10 @@
buttonsCollapsed buttonsCollapsed
> >
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
<GridManageAccessButton />
{#if calculation} {#if calculation}
<GridViewCalculationButton /> <GridViewCalculationButton />
{/if} {/if}
<GridManageAccessButton />
<GridFilterButton /> <GridFilterButton />
<GridSortButton /> <GridSortButton />
<GridSizeButton /> <GridSizeButton />

View File

@ -38,6 +38,10 @@
let loaded = false let loaded = false
$: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId)) $: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
// Reset the page every time that a filter gets updated
$: pageInfo.reset(), automationId, status, timeRange
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
$: isCloud = $admin.cloud $: isCloud = $admin.cloud

View File

@ -206,7 +206,7 @@
if (!user?._id) { if (!user?._id) {
$goto("./") $goto("./")
} }
tenantOwner = await users.tenantOwner($auth.tenantId) tenantOwner = await users.getAccountHolder()
} }
async function toggleFlags(detail) { async function toggleFlags(detail) {

View File

@ -71,7 +71,6 @@
] ]
let userData = [] let userData = []
let invitesLoaded = false let invitesLoaded = false
let tenantOwnerLoaded = false
let pendingInvites = [] let pendingInvites = []
let parsedInvites = [] let parsedInvites = []
@ -100,13 +99,9 @@
$: pendingSchema = getPendingSchema(schema) $: pendingSchema = getPendingSchema(schema)
$: userData = [] $: userData = []
$: inviteUsersResponse = { successful: [], unsuccessful: [] } $: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: setEnrichedUsers($fetch.rows, tenantOwnerLoaded) $: setEnrichedUsers($fetch.rows, tenantOwner)
const setEnrichedUsers = async rows => { const setEnrichedUsers = async (rows, owner) => {
if (!tenantOwnerLoaded) {
enrichedUsers = []
return
}
enrichedUsers = rows?.map(user => { enrichedUsers = rows?.map(user => {
let userGroups = [] let userGroups = []
$groups.forEach(group => { $groups.forEach(group => {
@ -118,7 +113,9 @@
}) })
} }
}) })
user.tenantOwnerEmail = tenantOwner?.email if (owner) {
user.tenantOwnerEmail = owner.email
}
const role = Constants.ExtendedBudibaseRoleOptions.find( const role = Constants.ExtendedBudibaseRoleOptions.find(
x => x.value === users.getUserRole(user) x => x.value === users.getUserRole(user)
) )
@ -280,7 +277,12 @@
} }
if (ids.length > 0) { if (ids.length > 0) {
await users.bulkDelete(ids) await users.bulkDelete(
selectedRows.map(user => ({
userId: user._id,
email: user.email,
}))
)
} }
if (selectedInvites.length > 0) { if (selectedInvites.length > 0) {
@ -317,13 +319,22 @@
try { try {
await groups.actions.init() await groups.actions.init()
groupsLoaded = true groupsLoaded = true
pendingInvites = await users.getInvites()
invitesLoaded = true
tenantOwner = await users.tenantOwner($auth.tenantId)
tenantOwnerLoaded = true
} catch (error) { } catch (error) {
notifications.error("Error fetching user group data") notifications.error("Error fetching user group data")
} }
try {
pendingInvites = await users.getInvites()
invitesLoaded = true
} catch (err) {
notifications.error("Error fetching user invitations")
}
try {
tenantOwner = await users.getAccountHolder()
} catch (err) {
if (err.status !== 404) {
notifications.error("Error fetching account holder")
}
}
}) })
</script> </script>

View File

@ -112,8 +112,8 @@ export function createUsersStore() {
return await API.getUserCountByApp({ appId }) return await API.getUserCountByApp({ appId })
} }
async function bulkDelete(userIds) { async function bulkDelete(users) {
return API.deleteUsers(userIds) return API.deleteUsers(users)
} }
async function save(user) { async function save(user) {
@ -128,9 +128,8 @@ export function createUsersStore() {
return await API.removeAppBuilder({ userId, appId }) return await API.removeAppBuilder({ userId, appId })
} }
async function getTenantOwner(tenantId) { async function getAccountHolder() {
const tenantInfo = await API.getTenantInfo({ tenantId }) return await API.getAccountHolder()
return tenantInfo?.owner
} }
const getUserRole = user => { const getUserRole = user => {
@ -176,7 +175,7 @@ export function createUsersStore() {
save: refreshUsage(save), save: refreshUsage(save),
bulkDelete: refreshUsage(bulkDelete), bulkDelete: refreshUsage(bulkDelete),
delete: refreshUsage(del), delete: refreshUsage(del),
tenantOwner: getTenantOwner, getAccountHolder,
} }
} }

View File

@ -22,7 +22,7 @@
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const { API, fetchDatasourceSchema } = getContext("sdk") const { fetchDatasourceSchema, fetchDatasourceDefinition } = getContext("sdk")
const getInitialFormStep = () => { const getInitialFormStep = () => {
const parsedFormStep = parseInt(initialFormStep) const parsedFormStep = parseInt(initialFormStep)
@ -32,9 +32,9 @@
return parsedFormStep return parsedFormStep
} }
let loaded = false let definition
let schema let schema
let table let loaded = false
let currentStep = getContext("current-step") || writable(getInitialFormStep()) let currentStep = getContext("current-step") || writable(getInitialFormStep())
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
@ -84,12 +84,10 @@
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource?.tableId && !dataSource?.type?.startsWith("query")) { try {
try { definition = await fetchDatasourceDefinition(dataSource)
table = await API.fetchTableDefinition(dataSource.tableId) } catch (error) {
} catch (error) { definition = null
table = null
}
} }
const res = await fetchDatasourceSchema(dataSource) const res = await fetchDatasourceSchema(dataSource)
schema = res || {} schema = res || {}
@ -121,7 +119,7 @@
{readonly} {readonly}
{actionType} {actionType}
{schema} {schema}
{table} {definition}
{initialValues} {initialValues}
{disableSchemaValidation} {disableSchemaValidation}
{editAutoColumns} {editAutoColumns}

View File

@ -10,7 +10,7 @@
export let initialValues export let initialValues
export let size export let size
export let schema export let schema
export let table export let definition
export let disableSchemaValidation = false export let disableSchemaValidation = false
export let editAutoColumns = false export let editAutoColumns = false
@ -164,7 +164,7 @@
schemaConstraints, schemaConstraints,
validationRules, validationRules,
field, field,
table definition
) )
// Sanitise the default value to ensure it doesn't contain invalid data // Sanitise the default value to ensure it doesn't contain invalid data
@ -338,7 +338,7 @@
schemaConstraints, schemaConstraints,
validationRules, validationRules,
field, field,
table definition
) )
// Update validator // Update validator

View File

@ -113,7 +113,10 @@
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => { const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi?.setValue([]) fieldApi?.setValue([])
selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
} }
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {

View File

@ -5,17 +5,17 @@ import { Helpers } from "@budibase/bbui"
/** /**
* Creates a validation function from a combination of schema-level constraints * Creates a validation function from a combination of schema-level constraints
* and custom validation rules * and custom validation rules
* @param schemaConstraints any schema level constraints from the table * @param schemaConstraints any schema level constraints from the datasource
* @param customRules any custom validation rules * @param customRules any custom validation rules
* @param field the field name we are evaluating * @param field the field name we are evaluating
* @param table the definition of the table we are evaluating * @param definition the definition of the datasource we are evaluating
* @returns {function} a validator function which accepts test values * @returns {function} a validator function which accepts test values
*/ */
export const createValidatorFromConstraints = ( export const createValidatorFromConstraints = (
schemaConstraints, schemaConstraints,
customRules, customRules,
field, field,
table definition
) => { ) => {
let rules = [] let rules = []
@ -23,7 +23,7 @@ export const createValidatorFromConstraints = (
if (schemaConstraints) { if (schemaConstraints) {
// Required constraint // Required constraint
if ( if (
field === table?.primaryDisplay || field === definition?.primaryDisplay ||
schemaConstraints.presence?.allowEmpty === false || schemaConstraints.presence?.allowEmpty === false ||
schemaConstraints.presence === true schemaConstraints.presence === true
) { ) {

View File

@ -26,7 +26,10 @@ import Provider from "components/context/Provider.svelte"
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { ActionTypes } from "./constants" import { ActionTypes } from "./constants"
import { fetchDatasourceSchema } from "./utils/schema.js" import {
fetchDatasourceSchema,
fetchDatasourceDefinition,
} from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js" import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js" import { enrichButtonActions } from "./utils/buttonActions.js"
import { processStringSync, makePropSafe } from "@budibase/string-templates" import { processStringSync, makePropSafe } from "@budibase/string-templates"
@ -66,6 +69,7 @@ export default {
linkable, linkable,
getAction, getAction,
fetchDatasourceSchema, fetchDatasourceSchema,
fetchDatasourceDefinition,
fetchData, fetchData,
QueryUtils, QueryUtils,
ContextScopes: Constants.ContextScopes, ContextScopes: Constants.ContextScopes,

View File

@ -10,16 +10,13 @@ import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js"
import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch" import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch"
/** /**
* Fetches the schema of any kind of datasource. * Constructs a fetch instance for a given datasource.
* All datasource fetch classes implement their own functionality to get the * All datasource fetch classes implement their own functionality to get the
* schema of a datasource of their respective types. * schema of a datasource of their respective types.
* @param datasource the datasource to fetch the schema for * @param datasource the datasource
* @param options options for enriching the schema * @returns
*/ */
export const fetchDatasourceSchema = async ( const getDatasourceFetchInstance = datasource => {
datasource,
options = { enrichRelationships: false, formSchema: false }
) => {
const handler = { const handler = {
table: TableFetch, table: TableFetch,
view: ViewFetch, view: ViewFetch,
@ -34,10 +31,23 @@ export const fetchDatasourceSchema = async (
if (!handler) { if (!handler) {
return null return null
} }
const instance = new handler({ API }) return new handler({ API })
}
// Get the datasource definition and then schema /**
const definition = await instance.getDefinition(datasource) * Fetches the schema of any kind of datasource.
* @param datasource the datasource to fetch the schema for
* @param options options for enriching the schema
*/
export const fetchDatasourceSchema = async (
datasource,
options = { enrichRelationships: false, formSchema: false }
) => {
const instance = getDatasourceFetchInstance(datasource)
const definition = await instance?.getDefinition(datasource)
if (!definition) {
return null
}
// Get the normal schema as long as we aren't wanting a form schema // Get the normal schema as long as we aren't wanting a form schema
let schema let schema
@ -75,6 +85,15 @@ export const fetchDatasourceSchema = async (
return instance.enrichSchema(schema) return instance.enrichSchema(schema)
} }
/**
* Fetches the definition of any kind of datasource.
* @param datasource the datasource to fetch the schema for
*/
export const fetchDatasourceDefinition = async datasource => {
const instance = getDatasourceFetchInstance(datasource)
return await instance?.getDefinition(datasource)
}
/** /**
* Fetches the schema of relationship fields for a SQL table schema * Fetches the schema of relationship fields for a SQL table schema
* @param schema the schema to enrich * @param schema the schema to enrich

View File

@ -122,14 +122,14 @@ export const buildUserEndpoints = API => ({
/** /**
* Deletes multiple users * Deletes multiple users
* @param userIds the ID of the user to delete * @param users the ID/email pair of the user to delete
*/ */
deleteUsers: async userIds => { deleteUsers: async users => {
const res = await API.post({ const res = await API.post({
url: `/api/global/users/bulk`, url: `/api/global/users/bulk`,
body: { body: {
delete: { delete: {
userIds, users,
}, },
}, },
}) })
@ -296,9 +296,9 @@ export const buildUserEndpoints = API => ({
}) })
}, },
getTenantInfo: async ({ tenantId }) => { getAccountHolder: async () => {
return await API.get({ return await API.get({
url: `/api/global/tenant/${tenantId}`, url: `/api/global/users/accountholder`,
}) })
}, },
}) })

View File

@ -6,6 +6,7 @@ export const buildViewV2Endpoints = API => ({
fetchDefinition: async viewId => { fetchDefinition: async viewId => {
return await API.get({ return await API.get({
url: `/api/v2/views/${encodeURIComponent(viewId)}`, url: `/api/v2/views/${encodeURIComponent(viewId)}`,
cache: true,
}) })
}, },
/** /**

View File

@ -184,7 +184,7 @@ export default class DataFetch {
// Build the query // Build the query
let query = this.options.query let query = this.options.query
if (!query && this.features.supportsSearch) { if (!query) {
query = buildQuery(filter) query = buildQuery(filter)
} }

View File

@ -30,11 +30,10 @@ export default class UserFetch extends DataFetch {
async getData() { async getData() {
const { limit, paginate } = this.options const { limit, paginate } = this.options
const { cursor, query } = get(this.store) const { cursor, query } = get(this.store)
let finalQuery
// convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query || {}
finalQuery = utils.isSupportedUserSearch(rest) // Convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query || {}
const finalQuery = utils.isSupportedUserSearch(rest)
? query ? query
: { string: { email: null } } : { string: { email: null } }

@ -1 +1 @@
Subproject commit f6aebba94451ce47bba551926e5ad72bd75f71c6 Subproject commit 2ab8536b6005576684810d774f1ac22239218546

View File

@ -41,7 +41,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
# Install yarn packages with caching # Install yarn packages with caching
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \ RUN yarn install --production=true --network-timeout 1000000 \
&& yarn cache clean \ && yarn cache clean \
&& apk del g++ make python3 jq \ && apk del g++ make python3 jq \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View File

@ -763,12 +763,25 @@ describe.each([
expect(row.food).toEqual(["apple", "orange"]) expect(row.food).toEqual(["apple", "orange"])
}) })
it("creates a new row with a default value when given an empty list", async () => {
const row = await config.api.row.save(table._id!, { food: [] })
expect(row.food).toEqual(["apple", "orange"])
})
it("does not use default value if value specified", async () => { it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, { const row = await config.api.row.save(table._id!, {
food: ["orange"], food: ["orange"],
}) })
expect(row.food).toEqual(["orange"]) expect(row.food).toEqual(["orange"])
}) })
it("resets back to its default value when empty", async () => {
let row = await config.api.row.save(table._id!, {
food: ["orange"],
})
row = await config.api.row.save(table._id!, { ...row, food: [] })
expect(row.food).toEqual(["apple", "orange"])
})
}) })
describe("user column", () => { describe("user column", () => {
@ -835,6 +848,62 @@ describe.each([
}) })
}) })
describe("boolean column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
active: {
name: "active",
type: FieldType.BOOLEAN,
default: "true",
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.active).toEqual(true)
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
active: false,
})
expect(row.active).toEqual(false)
})
})
describe("bigint column", () => {
beforeAll(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
bigNumber: {
name: "bigNumber",
type: FieldType.BIGINT,
default: "1234567890",
},
},
})
)
})
it("creates a new row with a default value successfully", async () => {
const row = await config.api.row.save(table._id!, {})
expect(row.bigNumber).toEqual("1234567890")
})
it("does not use default value if value specified", async () => {
const row = await config.api.row.save(table._id!, {
bigNumber: "9876543210",
})
expect(row.bigNumber).toEqual("9876543210")
})
})
describe("bindings", () => { describe("bindings", () => {
describe("string column", () => { describe("string column", () => {
beforeAll(async () => { beforeAll(async () => {

View File

@ -2268,58 +2268,118 @@ describe.each([
}) })
}) })
describe("calculation views", () => { !isLucene &&
it("should not remove calculation columns when modifying table schema", async () => { describe("calculation views", () => {
let table = await config.api.table.save( it("should not remove calculation columns when modifying table schema", async () => {
saveTableRequest({ let table = await config.api.table.save(
schema: { saveTableRequest({
name: { schema: {
name: "name", name: {
type: FieldType.STRING, name: "name",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
}, },
age: { })
name: "age", )
type: FieldType.NUMBER,
let view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "age",
}, },
}, },
}) })
)
let view = await config.api.viewV2.create({ table = await config.api.table.get(table._id!)
tableId: table._id!, await config.api.table.save({
name: generator.guid(), ...table,
type: ViewV2Type.CALCULATION, schema: {
schema: { ...table.schema,
sum: { name: {
visible: true, name: "name",
calculationType: CalculationType.SUM, type: FieldType.STRING,
field: "age", constraints: { presence: true },
},
}, },
}, })
view = await config.api.viewV2.get(view.id)
expect(Object.keys(view.schema!).sort()).toEqual([
"age",
"id",
"name",
"sum",
])
}) })
table = await config.api.table.get(table._id!) describe("bigints", () => {
await config.api.table.save({ let table: Table
...table, let view: ViewV2
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
},
})
view = await config.api.viewV2.get(view.id) beforeEach(async () => {
expect(Object.keys(view.schema!).sort()).toEqual([ table = await config.api.table.save(
"age", saveTableRequest({
"id", schema: {
"name", bigint: {
"sum", name: "bigint",
]) type: FieldType.BIGINT,
},
},
})
)
view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "bigint",
},
},
})
})
it("should not lose precision handling ints larger than JSs int53", async () => {
// The sum of the following 3 numbers cannot be represented by
// JavaScripts default int53 datatype for numbers, so this is a test
// that makes sure we aren't losing precision between the DB and the
// user.
await config.api.row.bulkImport(table._id!, {
rows: [
{ bigint: "1000000000000000000" },
{ bigint: "123" },
{ bigint: "321" },
],
})
const { rows } = await config.api.row.search(view.id)
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual("1000000000000000444")
})
it("should be able to handle up to 2**63 - 1 bigints", async () => {
await config.api.row.bulkImport(table._id!, {
rows: [{ bigint: "9223372036854775806" }, { bigint: "1" }],
})
const { rows } = await config.api.row.search(view.id)
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual("9223372036854775807")
})
})
}) })
})
}) })
describe("row operations", () => { describe("row operations", () => {
@ -4270,6 +4330,97 @@ describe.each([
}, },
], ],
}, },
{
name: "can handle logical operator any",
insert: [{ string: "bar" }, { string: "foo" }],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ANY,
filters: [
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "string",
value: "bar",
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "bar" }, { string: "foo" }],
},
{
name: "can handle logical operator all",
insert: [
{ string: "bar", number: 1 },
{ string: "foo", number: 2 },
],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "number",
value: 2,
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "foo", number: 2 }],
},
{
name: "overrides allOr with logical operators",
insert: [
{ string: "bar", number: 1 },
{ string: "foo", number: 1 },
],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{ operator: "allOr" },
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "number",
value: 1,
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "foo", number: 1 }],
},
] ]
it.each(testCases)( it.each(testCases)(

View File

@ -134,7 +134,12 @@ async function processDefaultValues(table: Table, row: Row) {
} }
for (const [key, schema] of Object.entries(table.schema)) { for (const [key, schema] of Object.entries(table.schema)) {
if ("default" in schema && schema.default != null && row[key] == null) { const isEmpty =
row[key] == null ||
row[key] === "" ||
(Array.isArray(row[key]) && row[key].length === 0)
if ("default" in schema && schema.default != null && isEmpty) {
let processed: string | string[] let processed: string | string[]
if (Array.isArray(schema.default)) { if (Array.isArray(schema.default)) {
processed = schema.default.map(val => processStringSync(val, ctx)) processed = schema.default.map(val => processStringSync(val, ctx))
@ -440,19 +445,26 @@ export async function coreOutputProcessing(
} }
if (sdk.views.isView(source)) { if (sdk.views.isView(source)) {
const calculationFields = Object.keys( // We ensure calculation fields are returned as numbers. During the
helpers.views.calculationFields(source)
)
// We ensure all calculation fields are returned as numbers. During the
// testing of this feature it was discovered that the COUNT operation // testing of this feature it was discovered that the COUNT operation
// returns a string for MySQL, MariaDB, and Postgres. But given that all // returns a string for MySQL, MariaDB, and Postgres. But given that all
// calculation fields should be numbers, we blanket make sure of that // calculation fields (except ones operating on BIGINTs) should be
// here. // numbers, we blanket make sure of that here.
for (const key of calculationFields) { for (const [name, field] of Object.entries(
helpers.views.calculationFields(source)
)) {
if ("field" in field) {
const targetSchema = table.schema[field.field]
// We don't convert BIGINT fields to floats because we could lose
// precision.
if (targetSchema.type === FieldType.BIGINT) {
continue
}
}
for (const row of rows) { for (const row of rows) {
if (typeof row[key] === "string") { if (typeof row[name] === "string") {
row[key] = parseFloat(row[key]) row[name] = parseFloat(row[name])
} }
} }
} }

View File

@ -488,7 +488,13 @@ export function buildQuery(
if (onEmptyFilter) { if (onEmptyFilter) {
query.onEmptyFilter = onEmptyFilter query.onEmptyFilter = onEmptyFilter
} }
const operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
// logicalOperator takes precendence over allOr
let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
if (group.logicalOperator) {
operator = logicalOperatorFromUI(group.logicalOperator)
}
return { return {
[operator]: { conditions: filters.map(buildCondition).filter(f => f) }, [operator]: { conditions: filters.map(buildCondition).filter(f => f) },
} }

View File

@ -57,12 +57,12 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
[FieldType.STRING]: true, [FieldType.STRING]: true,
[FieldType.OPTIONS]: true, [FieldType.OPTIONS]: true,
[FieldType.ARRAY]: true, [FieldType.ARRAY]: true,
[FieldType.BIGINT]: true,
[FieldType.BOOLEAN]: true,
[FieldType.AUTO]: false, [FieldType.AUTO]: false,
[FieldType.INTERNAL]: false, [FieldType.INTERNAL]: false,
[FieldType.BARCODEQR]: false, [FieldType.BARCODEQR]: false,
[FieldType.BIGINT]: false,
[FieldType.BOOLEAN]: false,
[FieldType.FORMULA]: false, [FieldType.FORMULA]: false,
[FieldType.AI]: false, [FieldType.AI]: false,
[FieldType.ATTACHMENTS]: false, [FieldType.ATTACHMENTS]: false,

View File

@ -15,7 +15,10 @@ export interface UserDetails {
export interface BulkUserRequest { export interface BulkUserRequest {
delete?: { delete?: {
userIds: string[] users: Array<{
userId: string
email: string
}>
} }
create?: { create?: {
roles?: any[] roles?: any[]

View File

@ -186,6 +186,16 @@ export interface ArrayFieldMetadata extends BaseFieldSchema {
default?: string[] default?: string[]
} }
export interface BooleanFieldMetadata extends BaseFieldSchema {
type: FieldType.BOOLEAN
default?: string
}
export interface BigIntFieldMetadata extends BaseFieldSchema {
type: FieldType.BIGINT
default?: string
}
interface BaseFieldSchema extends UIFieldMetadata { interface BaseFieldSchema extends UIFieldMetadata {
type: FieldType type: FieldType
name: string name: string
@ -214,6 +224,8 @@ interface OtherFieldMetadata extends BaseFieldSchema {
| FieldType.STRING | FieldType.STRING
| FieldType.ARRAY | FieldType.ARRAY
| FieldType.OPTIONS | FieldType.OPTIONS
| FieldType.BOOLEAN
| FieldType.BIGINT
> >
} }
@ -233,6 +245,8 @@ export type FieldSchema =
| BBReferenceSingleFieldMetadata | BBReferenceSingleFieldMetadata
| ArrayFieldMetadata | ArrayFieldMetadata
| OptionsFieldMetadata | OptionsFieldMetadata
| BooleanFieldMetadata
| BigIntFieldMetadata
export interface TableSchema { export interface TableSchema {
[key: string]: FieldSchema [key: string]: FieldSchema

View File

@ -7,4 +7,3 @@ export * from "./schedule"
export * from "./templates" export * from "./templates"
export * from "./environmentVariables" export * from "./environmentVariables"
export * from "./auditLogs" export * from "./auditLogs"
export * from "./tenantInfo"

View File

@ -1,15 +0,0 @@
import { Hosting } from "../../sdk"
import { Document } from "../document"
export interface TenantInfo extends Document {
owner: {
email: string
password?: string
ssoId?: string
givenName?: string
familyName?: string
budibaseUserId?: string
}
tenantId: string
hosting: Hosting
}

View File

@ -38,6 +38,11 @@ export function isSSOUser(user: User): user is SSOUser {
// USER // USER
export interface UserIdentifier {
userId: string
email: string
}
export interface User extends Document { export interface User extends Document {
tenantId: string tenantId: string
email: string email: string

View File

@ -24,7 +24,7 @@ COPY packages/worker/dist/yarn.lock .
RUN ../scripts/removeWorkspaceDependencies.sh package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 RUN yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean

View File

@ -1,14 +0,0 @@
import { tenancy } from "@budibase/backend-core"
import { TenantInfo, Ctx } from "@budibase/types"
export const save = async (ctx: Ctx<TenantInfo>) => {
const response = await tenancy.saveTenantInfo(ctx.request.body)
ctx.body = {
_id: response.id,
_rev: response.rev,
}
}
export const get = async (ctx: Ctx) => {
ctx.body = await tenancy.getTenantInfo(ctx.params.id)
}

View File

@ -23,9 +23,11 @@ import {
SearchUsersRequest, SearchUsersRequest,
User, User,
UserCtx, UserCtx,
UserIdentifier,
} from "@budibase/types" } from "@budibase/types"
import { import {
accounts, accounts,
users,
cache, cache,
ErrorCode, ErrorCode,
events, events,
@ -55,8 +57,8 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
const requestUser = ctx.request.body const requestUser = ctx.request.body
// Do not allow the account holder role to be changed // Do not allow the account holder role to be changed
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId) const accountMetadata = await users.getExistingAccounts([requestUser.email])
if (tenantInfo?.owner.email === requestUser.email) { if (accountMetadata?.length > 0) {
if ( if (
requestUser.admin?.global !== true || requestUser.admin?.global !== true ||
requestUser.builder?.global !== true requestUser.builder?.global !== true
@ -103,11 +105,14 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
} }
} }
const bulkDelete = async (userIds: string[], currentUserId: string) => { const bulkDelete = async (
if (userIds?.indexOf(currentUserId) !== -1) { users: Array<UserIdentifier>,
currentUserId: string
) => {
if (users.find(u => u.userId === currentUserId)) {
throw new Error("Unable to delete self.") throw new Error("Unable to delete self.")
} }
return await userSdk.db.bulkDelete(userIds) return await userSdk.db.bulkDelete(users)
} }
const bulkCreate = async (users: User[], groupIds: string[]) => { const bulkCreate = async (users: User[], groupIds: string[]) => {
@ -130,7 +135,7 @@ export const bulkUpdate = async (
created = await bulkCreate(input.create.users, input.create.groups) created = await bulkCreate(input.create.users, input.create.groups)
} }
if (input.delete) { if (input.delete) {
deleted = await bulkDelete(input.delete.userIds, currentUserId) deleted = await bulkDelete(input.delete.users, currentUserId)
} }
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err?.message || err) ctx.throw(err.status || 400, err?.message || err)
@ -302,6 +307,23 @@ export const tenantUserLookup = async (ctx: any) => {
} }
} }
/**
* This will be paginated to a default of the first 50 users,
* So the account holder may not be found until further pagination has occurred
*/
export const accountHolderLookup = async (ctx: Ctx) => {
const users = await userSdk.core.getAllUsers()
const response = await userSdk.core.getExistingAccounts(
users.map(u => u.email)
)
const holder = response[0]
if (!holder) {
return
}
holder._id = users.find(u => u.email === holder.email)?._id
ctx.body = holder
}
/* /*
Encapsulate the app user onboarding flows here. Encapsulate the app user onboarding flows here.
*/ */

View File

@ -71,10 +71,6 @@ const PUBLIC_ENDPOINTS = [
route: "/api/global/users/invite", route: "/api/global/users/invite",
method: "GET", method: "GET",
}, },
{
route: "/api/global/tenant",
method: "POST",
},
] ]
const NO_TENANCY_ENDPOINTS = [ const NO_TENANCY_ENDPOINTS = [
@ -121,11 +117,7 @@ const NO_TENANCY_ENDPOINTS = [
method: "GET", method: "GET",
}, },
{ {
route: "/api/global/tenant", route: "/api/global/users/accountholder",
method: "POST",
},
{
route: "/api/global/tenant/:id",
method: "GET", method: "GET",
}, },
] ]

View File

@ -1,11 +0,0 @@
import Router from "@koa/router"
import * as controller from "../../controllers/global/tenant"
import cloudRestricted from "../../../middleware/cloudRestricted"
const router: Router = new Router()
router
.post("/api/global/tenant", cloudRestricted, controller.save)
.get("/api/global/tenant/:id", controller.get)
export default router

View File

@ -1,48 +0,0 @@
import { Hosting, TenantInfo } from "@budibase/types"
import { TestConfiguration } from "../../../../tests"
import { tenancy as _tenancy } from "@budibase/backend-core"
const tenancy = jest.mocked(_tenancy)
describe("/api/global/tenant", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
beforeEach(() => {
jest.clearAllMocks()
})
describe("POST /api/global/tenant", () => {
it("should save the tenantInfo", async () => {
tenancy.saveTenantInfo = jest.fn().mockImplementation(async () => ({
id: "DOC_ID",
ok: true,
rev: "DOC_REV",
}))
const tenantInfo: TenantInfo = {
owner: {
email: "test@example.com",
password: "PASSWORD123!",
ssoId: "SSO_ID",
givenName: "Jane",
familyName: "Doe",
budibaseUserId: "USER_ID",
},
tenantId: "tenant123",
hosting: Hosting.CLOUD,
}
const response = await config.api.tenants.saveTenantInfo(tenantInfo)
expect(_tenancy.saveTenantInfo).toHaveBeenCalledTimes(1)
expect(_tenancy.saveTenantInfo).toHaveBeenCalledWith(tenantInfo)
expect(response.text).toEqual('{"_id":"DOC_ID","_rev":"DOC_REV"}')
})
})
})

View File

@ -412,28 +412,6 @@ describe("/api/global/users", () => {
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1) expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
}) })
it("should not be able to update an account holder user to a basic user", async () => {
const accountHolderUser = await config.createUser(
structures.users.adminUser()
)
jest.clearAllMocks()
tenancy.getTenantInfo = jest.fn().mockImplementation(() => ({
owner: {
email: accountHolderUser.email,
},
}))
accountHolderUser.admin!.global = false
accountHolderUser.builder!.global = false
await config.api.users.saveUser(accountHolderUser, 400)
expect(events.user.created).not.toHaveBeenCalled()
expect(events.user.updated).not.toHaveBeenCalled()
expect(events.user.permissionAdminRemoved).not.toHaveBeenCalled()
expect(events.user.permissionBuilderRemoved).not.toHaveBeenCalled()
})
it("should be able to update an builder user to a basic user", async () => { it("should be able to update an builder user to a basic user", async () => {
const user = await config.createUser(structures.users.builderUser()) const user = await config.createUser(structures.users.builderUser())
jest.clearAllMocks() jest.clearAllMocks()
@ -592,55 +570,21 @@ describe("/api/global/users", () => {
describe("POST /api/global/users/bulk (delete)", () => { describe("POST /api/global/users/bulk (delete)", () => {
it("should not be able to bulk delete current user", async () => { it("should not be able to bulk delete current user", async () => {
const user = await config.user! const user = config.user!
const response = await config.api.users.bulkDeleteUsers([user._id!], 400) const response = await config.api.users.bulkDeleteUsers(
[
{
userId: user._id!,
email: "test@example.com",
},
],
400
)
expect(response.message).toBe("Unable to delete self.") expect(response.message).toBe("Unable to delete self.")
expect(events.user.deleted).not.toHaveBeenCalled() expect(events.user.deleted).not.toHaveBeenCalled()
}) })
it("should not be able to bulk delete account owner", async () => {
const user = await config.createUser()
const account = structures.accounts.cloudAccount()
account.budibaseUserId = user._id!
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
const response = await config.api.users.bulkDeleteUsers([user._id!])
expect(response.deleted?.successful.length).toBe(0)
expect(response.deleted?.unsuccessful.length).toBe(1)
expect(response.deleted?.unsuccessful[0].reason).toBe(
"Account holder cannot be deleted"
)
expect(response.deleted?.unsuccessful[0]._id).toBe(user._id)
expect(events.user.deleted).not.toHaveBeenCalled()
})
it("should be able to bulk delete users", async () => {
const account = structures.accounts.cloudAccount()
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
const builder = structures.users.builderUser()
const admin = structures.users.adminUser()
const user = structures.users.user()
const createdUsers = await config.api.users.bulkCreateUsers([
builder,
admin,
user,
])
const toDelete = createdUsers.created?.successful.map(
u => u._id!
) as string[]
const response = await config.api.users.bulkDeleteUsers(toDelete)
expect(response.deleted?.successful.length).toBe(3)
expect(response.deleted?.unsuccessful.length).toBe(0)
expect(events.user.deleted).toHaveBeenCalledTimes(3)
expect(events.user.permissionAdminRemoved).toHaveBeenCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(2)
})
}) })
describe("POST /api/global/users/search", () => { describe("POST /api/global/users/search", () => {

View File

@ -136,6 +136,7 @@ router
buildAdminInitValidation(), buildAdminInitValidation(),
controller.adminUser controller.adminUser
) )
.get("/api/global/users/accountholder", controller.accountHolderLookup)
.get("/api/global/users/tenant/:id", controller.tenantUserLookup) .get("/api/global/users/tenant/:id", controller.tenantUserLookup)
// global endpoint but needs to come at end (blocks other endpoints otherwise) // global endpoint but needs to come at end (blocks other endpoints otherwise)
.get("/api/global/users/:id", auth.builderOrAdmin, controller.find) .get("/api/global/users/:id", auth.builderOrAdmin, controller.find)

View File

@ -1,7 +1,6 @@
import Router from "@koa/router" import Router from "@koa/router"
import { api as pro } from "@budibase/pro" import { api as pro } from "@budibase/pro"
import userRoutes from "./global/users" import userRoutes from "./global/users"
import tenantRoutes from "./global/tenant"
import configRoutes from "./global/configs" import configRoutes from "./global/configs"
import workspaceRoutes from "./global/workspaces" import workspaceRoutes from "./global/workspaces"
import templateRoutes from "./global/templates" import templateRoutes from "./global/templates"
@ -41,7 +40,6 @@ export const routes: Router[] = [
accountRoutes, accountRoutes,
restoreRoutes, restoreRoutes,
eventRoutes, eventRoutes,
tenantRoutes,
pro.scim, pro.scim,
] ]

View File

@ -66,7 +66,14 @@ export const buildUserBulkUserValidation = (isSelf = false) => {
users: Joi.array().items(Joi.object(schema).required().unknown(true)), users: Joi.array().items(Joi.object(schema).required().unknown(true)),
}), }),
delete: Joi.object({ delete: Joi.object({
userIds: Joi.array().items(Joi.string()), users: Joi.array().items(
Joi.object({
email: Joi.string(),
userId: Joi.string(),
})
.required()
.unknown(true)
),
}), }),
} }

View File

@ -1,4 +1,3 @@
import { TenantInfo } from "@budibase/types"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI, TestAPIOpts } from "./base" import { TestAPI, TestAPIOpts } from "./base"
@ -15,12 +14,4 @@ export class TenantAPI extends TestAPI {
.set(opts?.headers) .set(opts?.headers)
.expect(opts?.status ? opts.status : 204) .expect(opts?.status ? opts.status : 204)
} }
saveTenantInfo = (tenantInfo: TenantInfo) => {
return this.request
.post("/api/global/tenant")
.set(this.config.internalAPIHeaders())
.send(tenantInfo)
.expect(200)
}
} }

View File

@ -81,8 +81,14 @@ export class UserAPI extends TestAPI {
return res.body as BulkUserResponse return res.body as BulkUserResponse
} }
bulkDeleteUsers = async (userIds: string[], status?: number) => { bulkDeleteUsers = async (
const body: BulkUserRequest = { delete: { userIds } } users: Array<{
userId: string
email: string
}>,
status?: number
) => {
const body: BulkUserRequest = { delete: { users } }
const res = await this.request const res = await this.request
.post(`/api/global/users/bulk`) .post(`/api/global/users/bulk`)
.send(body) .send(body)