Merge branch 'v3-ui' of github.com:Budibase/budibase into views-openapi
This commit is contained in:
commit
3b14b9207b
|
@ -3,7 +3,7 @@ name: Deploy QA
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- v3-ui
|
||||
- feature/automation-branching-ux
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -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 ./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 packages/server/dist packages/server/dist
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.33.3",
|
||||
"version": "2.33.12",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -171,9 +171,9 @@ const identifyUser = async (
|
|||
if (isSSOUser(user)) {
|
||||
providerType = user.providerType
|
||||
}
|
||||
const accountHolder = account?.budibaseUserId === user._id || false
|
||||
const verified =
|
||||
account && account?.budibaseUserId === user._id ? account.verified : false
|
||||
const accountHolder = await users.getExistingAccounts([user.email])
|
||||
const isAccountHolder = accountHolder.length > 0
|
||||
const verified = !!account && isAccountHolder && account.verified
|
||||
const installationId = await getInstallationId()
|
||||
const hosting = account ? account.hosting : getHostingFromEnv()
|
||||
const environment = getDeploymentEnvironment()
|
||||
|
@ -185,7 +185,7 @@ const identifyUser = async (
|
|||
installationId,
|
||||
tenantId,
|
||||
verified,
|
||||
accountHolder,
|
||||
accountHolder: isAccountHolder,
|
||||
providerType,
|
||||
builder,
|
||||
admin,
|
||||
|
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
|
|||
const environment = getDeploymentEnvironment()
|
||||
|
||||
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
|
||||
id = account.budibaseUserId
|
||||
id = user._id
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -237,7 +237,10 @@ export function validInherits(
|
|||
export function builtinRoleToNumber(id: string) {
|
||||
const builtins = getBuiltinRoles()
|
||||
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
|
||||
}
|
||||
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
|
||||
const highestBuiltin: number | undefined = role.inherits
|
||||
.map(roleId => {
|
||||
const foundRole = hierarchy.find(role => role._id === roleId)
|
||||
const foundRole = hierarchy.find(role =>
|
||||
roleIDsAreEqual(role._id!, roleId)
|
||||
)
|
||||
if (foundRole) {
|
||||
return findNumber(foundRole) + 1
|
||||
}
|
||||
|
@ -398,7 +403,7 @@ async function getAllUserRoles(
|
|||
): Promise<RoleDoc[]> {
|
||||
const allRoles = await getAllRoles()
|
||||
// admins have access to all roles
|
||||
if (userRoleId === BUILTIN_IDS.ADMIN) {
|
||||
if (roleIDsAreEqual(userRoleId, BUILTIN_IDS.ADMIN)) {
|
||||
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)
|
||||
for (let builtinRoleId of externalBuiltinRoles) {
|
||||
const builtinRole = builtinRoles[builtinRoleId]
|
||||
const dbBuiltin = roles.filter(
|
||||
dbRole =>
|
||||
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
|
||||
const dbBuiltin = roles.filter(dbRole =>
|
||||
roleIDsAreEqual(dbRole._id!, builtinRoleId)
|
||||
)[0]
|
||||
if (dbBuiltin == null) {
|
||||
roles.push(builtinRole || builtinRoles.BASIC)
|
||||
} else {
|
||||
// remove role and all back after combining with the builtin
|
||||
roles = roles.filter(role => role._id !== dbBuiltin._id)
|
||||
dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version)
|
||||
roles.push(Object.assign(builtinRole, dbBuiltin))
|
||||
dbBuiltin._id = getExternalRoleID(builtinRole._id!, dbBuiltin.version)
|
||||
roles.push({
|
||||
...builtinRole,
|
||||
...dbBuiltin,
|
||||
name: builtinRole.name,
|
||||
_id: getExternalRoleID(builtinRole._id!, builtinRole.version),
|
||||
})
|
||||
}
|
||||
}
|
||||
// check permissions
|
||||
|
@ -565,9 +574,9 @@ export class AccessController {
|
|||
if (
|
||||
tryingRoleId == null ||
|
||||
tryingRoleId === "" ||
|
||||
tryingRoleId === userRoleId ||
|
||||
tryingRoleId === BUILTIN_IDS.BUILDER ||
|
||||
userRoleId === BUILTIN_IDS.BUILDER
|
||||
roleIDsAreEqual(tryingRoleId, BUILTIN_IDS.BUILDER) ||
|
||||
roleIDsAreEqual(userRoleId!, tryingRoleId) ||
|
||||
roleIDsAreEqual(userRoleId!, BUILTIN_IDS.BUILDER)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -179,12 +179,6 @@ class InternalBuilder {
|
|||
return this.table.schema[column]
|
||||
}
|
||||
|
||||
private supportsILike(): boolean {
|
||||
return !(
|
||||
this.client === SqlClient.ORACLE || this.client === SqlClient.SQL_LITE
|
||||
)
|
||||
}
|
||||
|
||||
private quoteChars(): [string, string] {
|
||||
const wrapped = this.knexClient.wrapIdentifier("foo", {})
|
||||
return [wrapped[0], wrapped[wrapped.length - 1]]
|
||||
|
@ -216,8 +210,30 @@ class InternalBuilder {
|
|||
return formatter.wrap(value, false)
|
||||
}
|
||||
|
||||
private rawQuotedValue(value: string): Knex.Raw {
|
||||
return this.knex.raw(this.quotedValue(value))
|
||||
private castIntToString(identifier: string | Knex.Raw): Knex.Raw {
|
||||
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
|
||||
|
@ -1078,21 +1094,26 @@ class InternalBuilder {
|
|||
query = query.count(`* as ${aggregation.name}`)
|
||||
}
|
||||
} else {
|
||||
const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
|
||||
switch (op) {
|
||||
case CalculationType.SUM:
|
||||
query = query.sum(field)
|
||||
break
|
||||
case CalculationType.AVG:
|
||||
query = query.avg(field)
|
||||
break
|
||||
case CalculationType.MIN:
|
||||
query = query.min(field)
|
||||
break
|
||||
case CalculationType.MAX:
|
||||
query = query.max(field)
|
||||
break
|
||||
const fieldSchema = this.getFieldSchema(aggregation.field)
|
||||
if (!fieldSchema) {
|
||||
// This should not happen in practice.
|
||||
throw new Error(
|
||||
`field schema missing for aggregation target: ${aggregation.field}`
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -1,29 +1,6 @@
|
|||
import { getDB } from "../db/db"
|
||||
import { getGlobalDBName } from "../context"
|
||||
import { TenantInfo } from "@budibase/types"
|
||||
|
||||
export function getTenantDB(tenantId: string) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,14 +16,15 @@ import {
|
|||
isSSOUser,
|
||||
SaveUserOpts,
|
||||
User,
|
||||
UserStatus,
|
||||
UserGroup,
|
||||
UserIdentifier,
|
||||
UserStatus,
|
||||
PlatformUserBySsoId,
|
||||
PlatformUserById,
|
||||
AnyDocument,
|
||||
} from "@budibase/types"
|
||||
import {
|
||||
getAccountHolderFromUserIds,
|
||||
getAccountHolderFromUsers,
|
||||
isAdmin,
|
||||
isCreator,
|
||||
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 response: BulkUserDeleted = {
|
||||
|
@ -421,13 +424,13 @@ export class UserDB {
|
|||
}
|
||||
|
||||
// remove the account holder from the delete request if present
|
||||
const account = await getAccountHolderFromUserIds(userIds)
|
||||
if (account) {
|
||||
userIds = userIds.filter(u => u !== account.budibaseUserId)
|
||||
const accountHolder = await getAccountHolderFromUsers(users)
|
||||
if (accountHolder) {
|
||||
users = users.filter(u => u.userId !== accountHolder.userId)
|
||||
// mark user as unsuccessful
|
||||
response.unsuccessful.push({
|
||||
_id: account.budibaseUserId,
|
||||
email: account.email,
|
||||
_id: accountHolder.userId,
|
||||
email: accountHolder.email,
|
||||
reason: "Account holder cannot be deleted",
|
||||
})
|
||||
}
|
||||
|
@ -435,7 +438,7 @@ export class UserDB {
|
|||
// Get users and delete
|
||||
const allDocsResponse = await db.allDocs<User>({
|
||||
include_docs: true,
|
||||
keys: userIds,
|
||||
keys: users.map(u => u.userId),
|
||||
})
|
||||
const usersToDelete = allDocsResponse.rows.map(user => {
|
||||
return user.doc!
|
||||
|
|
|
@ -70,6 +70,17 @@ export async function getAllUserIds() {
|
|||
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[]) {
|
||||
const db = getGlobalDB()
|
||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||
|
|
|
@ -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 env from "../environment"
|
||||
import { getFirstPlatformUser } from "./lookup"
|
||||
import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
|
||||
import { EmailUnavailableError } from "../errors"
|
||||
import { getTenantId } from "../context"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import { getAccountByTenantId } from "../accounts"
|
||||
import { BUILTIN_ROLE_IDS } from "../security/roles"
|
||||
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(
|
||||
userIds: string[]
|
||||
): Promise<CloudAccount | undefined> {
|
||||
export async function getAccountHolderFromUsers(
|
||||
users: Array<UserIdentifier>
|
||||
): Promise<UserIdentifier | undefined> {
|
||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||
const tenantId = getTenantId()
|
||||
const account = await getAccountByTenantId(tenantId)
|
||||
if (!account) {
|
||||
throw new Error(`Account not found for tenantId=${tenantId}`)
|
||||
}
|
||||
|
||||
const budibaseUserId = account.budibaseUserId
|
||||
if (userIds.includes(budibaseUserId)) {
|
||||
return account
|
||||
}
|
||||
const accountMetadata = await getExistingAccounts(
|
||||
users.map(user => user.email)
|
||||
)
|
||||
return users.find(user =>
|
||||
accountMetadata.map(metadata => metadata.email).includes(user.email)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,144 @@
|
|||
<script>
|
||||
import { ActionButton, Modal } from "@budibase/bbui"
|
||||
import ExportModal from "../modals/ExportModal.svelte"
|
||||
import {
|
||||
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 filters
|
||||
export let sorting
|
||||
export let disabled = false
|
||||
export let selectedRows
|
||||
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>
|
||||
|
||||
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}>
|
||||
Export
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<ExportModal {view} {filters} {sorting} {selectedRows} {formats} />
|
||||
</Modal>
|
||||
<DetailPopover title="Export data" bind:this={popover}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="DataDownload"
|
||||
quiet
|
||||
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>
|
||||
|
|
|
@ -1,17 +1,81 @@
|
|||
<script>
|
||||
import { ActionButton, Modal } from "@budibase/bbui"
|
||||
import ImportModal from "../modals/ImportModal.svelte"
|
||||
import { ActionButton, Button, Body, notifications } from "@budibase/bbui"
|
||||
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 tableType
|
||||
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>
|
||||
|
||||
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}>
|
||||
Import
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<ImportModal {tableId} {tableType} on:importrows />
|
||||
</Modal>
|
||||
<DetailPopover title="Import data" bind:this={popover}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="DataUpload"
|
||||
quiet
|
||||
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>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<script>
|
||||
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 { getUserBindings } from "dataBinding"
|
||||
import { makePropSafe } from "@budibase/string-templates"
|
||||
import { search } from "@budibase/frontend-core"
|
||||
import { tables } from "stores/builder"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
|
||||
export let schema
|
||||
export let filters
|
||||
|
@ -14,7 +15,7 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let drawer
|
||||
let popover
|
||||
|
||||
$: localFilters = filters
|
||||
$: schemaFields = search.getFields(
|
||||
|
@ -39,45 +40,44 @@
|
|||
},
|
||||
...getUserBindings(),
|
||||
]
|
||||
|
||||
const openPopover = () => {
|
||||
localFilters = filters
|
||||
popover.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
icon="Filter"
|
||||
quiet
|
||||
{disabled}
|
||||
on:click={drawer.show}
|
||||
selected={filterCount > 0}
|
||||
accentColor="#004EA6"
|
||||
>
|
||||
{filterCount ? `Filter: ${filterCount}` : "Filter"}
|
||||
</ActionButton>
|
||||
<DetailPopover bind:this={popover} title="Configure filters" width={800}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="Filter"
|
||||
quiet
|
||||
{disabled}
|
||||
on:click={openPopover}
|
||||
selected={open || filterCount > 0}
|
||||
accentColor="#004EA6"
|
||||
>
|
||||
{filterCount ? `Filter: ${filterCount}` : "Filter"}
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
title="Filtering"
|
||||
on:drawerHide
|
||||
on:drawerShow={() => {
|
||||
localFilters = filters
|
||||
}}
|
||||
forceModal
|
||||
>
|
||||
<Button
|
||||
cta
|
||||
slot="buttons"
|
||||
on:click={() => {
|
||||
dispatch("change", localFilters)
|
||||
drawer.hide()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DrawerContent slot="body">
|
||||
<FilterBuilder
|
||||
filters={localFilters}
|
||||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
on:change={e => (localFilters = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<FilterBuilder
|
||||
filters={localFilters}
|
||||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
on:change={e => (localFilters = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
cta
|
||||
slot="buttons"
|
||||
on:click={() => {
|
||||
dispatch("change", localFilters)
|
||||
popover.hide()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DetailPopover>
|
||||
|
|
|
@ -210,16 +210,18 @@
|
|||
anchor={relationshipPanelAnchor}
|
||||
align="left"
|
||||
>
|
||||
{#if relationshipPanelColumns.length}
|
||||
<div class="relationship-header">
|
||||
{relationshipFieldName} columns
|
||||
</div>
|
||||
{/if}
|
||||
<svelte:self
|
||||
columns={relationshipPanelColumns}
|
||||
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
|
||||
fromRelationshipField={relationshipField}
|
||||
/>
|
||||
<div class="nested">
|
||||
{#if relationshipPanelColumns.length}
|
||||
<div class="relationship-header">
|
||||
{relationshipFieldName} columns
|
||||
</div>
|
||||
{/if}
|
||||
<svelte:self
|
||||
columns={relationshipPanelColumns}
|
||||
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
|
||||
fromRelationshipField={relationshipField}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
|
@ -230,11 +232,13 @@
|
|||
}
|
||||
|
||||
.content {
|
||||
padding: 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.nested {
|
||||
padding: 12px;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
@ -262,6 +266,6 @@
|
|||
}
|
||||
.relationship-header {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
padding: 12px 12px 0 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,15 +8,15 @@
|
|||
|
||||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover } from "@budibase/bbui"
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
|
||||
import { isEnabled } from "helpers/featureFlags"
|
||||
import { FeatureFlag } from "@budibase/types"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
|
||||
const { tableColumns, datasource } = getContext("grid")
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
let popover
|
||||
|
||||
$: anyRestricted = $tableColumns.filter(
|
||||
col => !col.visible || col.readonly
|
||||
|
@ -32,24 +32,23 @@
|
|||
: [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN]
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="ColumnSettings"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open || anyRestricted}
|
||||
disabled={!$tableColumns.length}
|
||||
accentColor="#674D00"
|
||||
>
|
||||
{text}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<DetailPopover bind:this={popover} title="Column settings">
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="ColumnSettings"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={popover?.open}
|
||||
selected={open || anyRestricted}
|
||||
disabled={!$tableColumns.length}
|
||||
accentColor="#674D00"
|
||||
>
|
||||
{text}
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
<ColumnsSettingContent
|
||||
columns={$tableColumns}
|
||||
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
|
||||
{permissions}
|
||||
/>
|
||||
</Popover>
|
||||
</DetailPopover>
|
||||
|
|
|
@ -9,21 +9,13 @@
|
|||
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
|
||||
</script>
|
||||
|
||||
<span data-ignore-click-outside="true">
|
||||
<ExportButton
|
||||
{disabled}
|
||||
view={$datasource.tableId}
|
||||
filters={$filter}
|
||||
sorting={{
|
||||
sortColumn: $sort.column,
|
||||
sortOrder: $sort.order,
|
||||
}}
|
||||
selectedRows={selectedRowArray}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
<ExportButton
|
||||
{disabled}
|
||||
view={$datasource.tableId}
|
||||
filters={$filter}
|
||||
sorting={{
|
||||
sortColumn: $sort.column,
|
||||
sortOrder: $sort.order,
|
||||
}}
|
||||
selectedRows={selectedRowArray}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
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 {
|
||||
Constants,
|
||||
|
@ -32,8 +33,7 @@
|
|||
},
|
||||
]
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
let popover
|
||||
|
||||
// Column width sizes
|
||||
$: allSmall = $columns.every(col => col.width === smallColSize)
|
||||
|
@ -66,63 +66,54 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="MoveUpDown"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
disabled={!$columns.length}
|
||||
>
|
||||
Size
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
<div class="size">
|
||||
<Label>Row height</Label>
|
||||
<div class="options">
|
||||
{#each rowSizeOptions as option}
|
||||
<ActionButton
|
||||
disabled={$fixedRowHeight}
|
||||
quiet
|
||||
selected={$rowHeight === option.size}
|
||||
on:click={() => changeRowHeight(option.size)}
|
||||
>
|
||||
{option.label}
|
||||
</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>
|
||||
<DetailPopover bind:this={popover} title="Column and row size" width={300}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="MoveUpDown"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={popover?.open}
|
||||
selected={open}
|
||||
disabled={!$columns.length}
|
||||
>
|
||||
Size
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
<div class="size">
|
||||
<Label>Row height</Label>
|
||||
<div class="options">
|
||||
{#each rowSizeOptions as option}
|
||||
<ActionButton
|
||||
disabled={$fixedRowHeight}
|
||||
quiet
|
||||
selected={$rowHeight === option.size}
|
||||
on:click={() => changeRowHeight(option.size)}
|
||||
>
|
||||
{option.label}
|
||||
</ActionButton>
|
||||
{/each}
|
||||
</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>
|
||||
.content {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.size {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover, Select } from "@budibase/bbui"
|
||||
import { ActionButton, Select } from "@budibase/bbui"
|
||||
import { canBeSortColumn } from "@budibase/frontend-core"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
|
||||
const { sort, columns } = getContext("grid")
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
let popover
|
||||
|
||||
$: columnOptions = $columns
|
||||
.filter(col => canBeSortColumn(col.schema))
|
||||
|
@ -45,50 +45,35 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="SortOrderDown"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
disabled={!columnOptions.length}
|
||||
>
|
||||
Sort
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
<DetailPopover bind:this={popover} title="Sorting" width={300}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton
|
||||
icon="SortOrderDown"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={popover?.open}
|
||||
selected={open}
|
||||
disabled={!columnOptions.length}
|
||||
>
|
||||
Sort
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
<Select
|
||||
placeholder="Default"
|
||||
value={$sort.column}
|
||||
options={columnOptions}
|
||||
autoWidth
|
||||
on:change={updateSortColumn}
|
||||
label="Column"
|
||||
/>
|
||||
{#if $sort.column}
|
||||
<Select
|
||||
placeholder="Default"
|
||||
value={$sort.column}
|
||||
options={columnOptions}
|
||||
placeholder={null}
|
||||
value={$sort.order || "ascending"}
|
||||
options={orderOptions}
|
||||
autoWidth
|
||||
on:change={updateSortColumn}
|
||||
label="Column"
|
||||
on:change={updateSortOrder}
|
||||
label="Order"
|
||||
/>
|
||||
{#if $sort.column}
|
||||
<Select
|
||||
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>
|
||||
{/if}
|
||||
</DetailPopover>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<script>
|
||||
import {
|
||||
ActionButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
Icon,
|
||||
Multiselect,
|
||||
Button,
|
||||
} 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 { getContext } from "svelte"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
|
||||
const { definition, datasource, rows } = getContext("grid")
|
||||
const calculationTypeOptions = [
|
||||
|
@ -35,19 +35,20 @@
|
|||
},
|
||||
]
|
||||
|
||||
let modal
|
||||
let popover
|
||||
let calculations = []
|
||||
let groupBy = []
|
||||
let schema = {}
|
||||
let loading = false
|
||||
|
||||
$: schema = $definition?.schema || {}
|
||||
$: count = extractCalculations($definition?.schema || {}).length
|
||||
$: groupByOptions = getGroupByOptions(schema)
|
||||
|
||||
const open = () => {
|
||||
const openPopover = () => {
|
||||
calculations = extractCalculations(schema)
|
||||
groupBy = calculations.length ? extractGroupBy(schema) : []
|
||||
modal?.show()
|
||||
popover?.show()
|
||||
}
|
||||
|
||||
const extractCalculations = schema => {
|
||||
|
@ -90,10 +91,7 @@
|
|||
return Object.entries(schema)
|
||||
.filter(([field, fieldSchema]) => {
|
||||
// Only allow numeric fields that are not calculations themselves
|
||||
if (
|
||||
fieldSchema.calculationType ||
|
||||
fieldSchema.type !== FieldType.NUMBER
|
||||
) {
|
||||
if (fieldSchema.calculationType || !isNumeric(fieldSchema.type)) {
|
||||
return false
|
||||
}
|
||||
// Don't allow duplicates
|
||||
|
@ -135,6 +133,7 @@
|
|||
|
||||
const save = async () => {
|
||||
let newSchema = {}
|
||||
loading = true
|
||||
|
||||
// Add calculations
|
||||
for (let calc of calculations) {
|
||||
|
@ -168,76 +167,80 @@
|
|||
}
|
||||
|
||||
// Save changes
|
||||
await datasource.actions.saveDefinition({
|
||||
...$definition,
|
||||
primaryDisplay,
|
||||
schema: newSchema,
|
||||
})
|
||||
await rows.actions.refreshData()
|
||||
try {
|
||||
await datasource.actions.saveDefinition({
|
||||
...$definition,
|
||||
primaryDisplay,
|
||||
schema: newSchema,
|
||||
})
|
||||
await rows.actions.refreshData()
|
||||
} finally {
|
||||
loading = false
|
||||
popover.hide()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionButton icon="WebPage" quiet on:click={open}>
|
||||
Configure calculations{count ? `: ${count}` : ""}
|
||||
</ActionButton>
|
||||
<DetailPopover bind:this={popover} title="Configure calculations" width={480}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton icon="WebPage" quiet on:click={openPopover} selected={open}>
|
||||
Configure calculations{count ? `: ${count}` : ""}
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
title="Calculations"
|
||||
confirmText="Save"
|
||||
size="M"
|
||||
onConfirm={save}
|
||||
>
|
||||
{#if calculations.length}
|
||||
<div class="calculations">
|
||||
{#each calculations as calc, idx}
|
||||
<span>{idx === 0 ? "Calculate" : "and"} the</span>
|
||||
<Select
|
||||
options={getTypeOptions(calc, calculations)}
|
||||
bind:value={calc.type}
|
||||
placeholder={false}
|
||||
/>
|
||||
<span>of</span>
|
||||
<Select
|
||||
options={getFieldOptions(calc, calculations, schema)}
|
||||
bind:value={calc.field}
|
||||
placeholder="Column"
|
||||
/>
|
||||
<Icon
|
||||
hoverable
|
||||
name="Delete"
|
||||
size="S"
|
||||
on:click={() => deleteCalc(idx)}
|
||||
color="var(--spectrum-global-color-gray-700)"
|
||||
/>
|
||||
{/each}
|
||||
<span>Group by</span>
|
||||
<div class="group-by">
|
||||
<Multiselect
|
||||
options={groupByOptions}
|
||||
bind:value={groupBy}
|
||||
placeholder="None"
|
||||
/>
|
||||
</div>
|
||||
{#if calculations.length}
|
||||
<div class="calculations">
|
||||
{#each calculations as calc, idx}
|
||||
<span>{idx === 0 ? "Calculate" : "and"} the</span>
|
||||
<Select
|
||||
options={getTypeOptions(calc, calculations)}
|
||||
bind:value={calc.type}
|
||||
placeholder={false}
|
||||
/>
|
||||
<span>of</span>
|
||||
<Select
|
||||
options={getFieldOptions(calc, calculations, schema)}
|
||||
bind:value={calc.field}
|
||||
placeholder="Column"
|
||||
/>
|
||||
<Icon
|
||||
hoverable
|
||||
name="Delete"
|
||||
size="S"
|
||||
on:click={() => deleteCalc(idx)}
|
||||
color="var(--spectrum-global-color-gray-700)"
|
||||
/>
|
||||
{/each}
|
||||
<span>Group by</span>
|
||||
<div class="group-by">
|
||||
<Multiselect
|
||||
options={groupByOptions}
|
||||
bind:value={groupBy}
|
||||
placeholder="None"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Add"
|
||||
on:click={addCalc}
|
||||
disabled={calculations.length >= 5}
|
||||
>
|
||||
Add calculation
|
||||
</ActionButton>
|
||||
</div>
|
||||
<InfoDisplay
|
||||
icon="Help"
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<ActionButton
|
||||
quiet
|
||||
body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once."
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
icon="Add"
|
||||
on:click={addCalc}
|
||||
disabled={calculations.length >= 5}
|
||||
>
|
||||
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>
|
||||
.calculations {
|
||||
|
|
|
@ -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>
|
|
@ -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")
|
||||
})
|
||||
})
|
|
@ -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>
|
|
@ -4,7 +4,7 @@
|
|||
BBReferenceFieldSubType,
|
||||
SourceName,
|
||||
} 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 { API } from "api"
|
||||
import { parseFile } from "./utils"
|
||||
|
@ -140,84 +140,91 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="dropzone">
|
||||
<input
|
||||
disabled={!schema || loading}
|
||||
id="file-upload"
|
||||
accept="text/csv,application/json"
|
||||
type="file"
|
||||
on:change={handleFile}
|
||||
/>
|
||||
<label for="file-upload" class:uploaded={rows.length > 0}>
|
||||
{#if loading}
|
||||
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}
|
||||
<Layout gap="S" noPadding>
|
||||
<Layout noPadding gap="XS">
|
||||
<Label grey extraSmall>CSV or JSON file to import</Label>
|
||||
<div class="dropzone">
|
||||
<input
|
||||
disabled={!schema || loading}
|
||||
id="file-upload"
|
||||
accept="text/csv,application/json"
|
||||
type="file"
|
||||
on:change={handleFile}
|
||||
/>
|
||||
{:else}
|
||||
<p>Rows will be updated based on the table's primary key.</p>
|
||||
<label for="file-upload" class:uploaded={rows.length > 0}>
|
||||
{#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 invalidColumns.length > 0}
|
||||
<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}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
|
@ -228,11 +235,9 @@
|
|||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
|
@ -240,7 +245,6 @@
|
|||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-s);
|
||||
color: var(--ink);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
transition: all 0.2s ease 0s;
|
||||
display: inline-flex;
|
||||
|
@ -254,20 +258,14 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
background-color: var(--grey-2);
|
||||
font-size: var(--font-size-xs);
|
||||
background-color: var(--spectrum-global-color-gray-300);
|
||||
font-size: var(--font-size-s);
|
||||
line-height: normal;
|
||||
border: var(--border-transparent);
|
||||
}
|
||||
|
||||
.uploaded {
|
||||
color: var(--blue);
|
||||
color: var(--spectrum-global-color-blue-600);
|
||||
}
|
||||
|
||||
.schema-fields {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1fr auto;
|
||||
|
@ -276,23 +274,14 @@
|
|||
grid-gap: var(--spacing-m);
|
||||
font-size: var(--spectrum-global-dimension-font-size-75);
|
||||
}
|
||||
|
||||
.fieldStatusSuccess {
|
||||
color: var(--green);
|
||||
justify-self: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fieldStatusFailure {
|
||||
color: var(--red);
|
||||
justify-self: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ignoredList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--spectrum-global-dimension-font-size-75);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Select, Icon } from "@budibase/bbui"
|
||||
import { Select, Icon, Layout, Label } from "@budibase/bbui"
|
||||
import { FIELDS } from "constants/backend"
|
||||
import { utils } from "@budibase/shared-core"
|
||||
import { canBeDisplayColumn } from "@budibase/frontend-core"
|
||||
|
@ -184,70 +184,76 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="dropzone">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
disabled={loading}
|
||||
id="file-upload"
|
||||
accept="text/csv,application/json"
|
||||
type="file"
|
||||
on:change={handleFile}
|
||||
/>
|
||||
<label for="file-upload" class:uploaded={rawRows.length > 0}>
|
||||
{#if error}
|
||||
Error: {error}
|
||||
{:else if fileName}
|
||||
{fileName}
|
||||
{:else}
|
||||
Upload
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
{#if rawRows.length > 0 && !error}
|
||||
<div class="schema-fields">
|
||||
{#each Object.entries(schema) as [name, column]}
|
||||
<div class="field">
|
||||
<span>{column.name}</span>
|
||||
<Select
|
||||
bind:value={selectedColumnTypes[column.name]}
|
||||
on:change={e => handleChange(name, e)}
|
||||
options={Object.values(typeOptions)}
|
||||
placeholder={null}
|
||||
getOptionLabel={option => option.label}
|
||||
getOptionValue={option => option.value}
|
||||
/>
|
||||
<span
|
||||
class={validation[column.name]
|
||||
? "fieldStatusSuccess"
|
||||
: "fieldStatusFailure"}
|
||||
>
|
||||
{#if validation[column.name]}
|
||||
Success
|
||||
{:else}
|
||||
Failure
|
||||
{#if errors[column.name]}
|
||||
<Icon name="Help" tooltip={errors[column.name]} />
|
||||
<Layout noPadding gap="S">
|
||||
<Layout gap="XS" noPadding>
|
||||
<Label grey extraSmall>
|
||||
Create a Table from a CSV or JSON file (Optional)
|
||||
</Label>
|
||||
<div class="dropzone">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
disabled={loading}
|
||||
id="file-upload"
|
||||
accept="text/csv,application/json"
|
||||
type="file"
|
||||
on:change={handleFile}
|
||||
/>
|
||||
<label for="file-upload" class:uploaded={rawRows.length > 0}>
|
||||
{#if error}
|
||||
Error: {error}
|
||||
{:else if fileName}
|
||||
{fileName}
|
||||
{:else}
|
||||
Upload
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
{#if rawRows.length > 0 && !error}
|
||||
<div>
|
||||
{#each Object.entries(schema) as [name, column]}
|
||||
<div class="field">
|
||||
<span>{column.name}</span>
|
||||
<Select
|
||||
bind:value={selectedColumnTypes[column.name]}
|
||||
on:change={e => handleChange(name, e)}
|
||||
options={Object.values(typeOptions)}
|
||||
placeholder={null}
|
||||
getOptionLabel={option => option.label}
|
||||
getOptionValue={option => option.value}
|
||||
/>
|
||||
<span
|
||||
class={validation[column.name]
|
||||
? "fieldStatusSuccess"
|
||||
: "fieldStatusFailure"}
|
||||
>
|
||||
{#if validation[column.name]}
|
||||
Success
|
||||
{:else}
|
||||
Failure
|
||||
{#if errors[column.name]}
|
||||
<Icon name="Help" tooltip={errors[column.name]} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
<Icon
|
||||
size="S"
|
||||
name="Close"
|
||||
hoverable
|
||||
on:click={() => deleteColumn(column.name)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="display-column">
|
||||
</span>
|
||||
<Icon
|
||||
size="S"
|
||||
name="Close"
|
||||
hoverable
|
||||
on:click={() => deleteColumn(column.name)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Select
|
||||
label="Display Column"
|
||||
bind:value={displayColumn}
|
||||
options={displayColumnOptions}
|
||||
sort
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
|
@ -269,7 +275,6 @@
|
|||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-s);
|
||||
color: var(--ink);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
transition: all 0.2s ease 0s;
|
||||
display: inline-flex;
|
||||
|
@ -283,20 +288,14 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
background-color: var(--grey-2);
|
||||
font-size: var(--font-size-xs);
|
||||
background-color: var(--spectrum-global-color-gray-300);
|
||||
font-size: var(--font-size-s);
|
||||
line-height: normal;
|
||||
border: var(--border-transparent);
|
||||
}
|
||||
|
||||
.uploaded {
|
||||
color: var(--blue);
|
||||
color: var(--spectrum-global-color-blue-600);
|
||||
}
|
||||
|
||||
.schema-fields {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1fr auto;
|
||||
|
@ -322,8 +321,4 @@
|
|||
.fieldStatusFailure :global(.spectrum-Icon) {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.display-column {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
<script>
|
||||
import { goto, url } from "@roxi/routify"
|
||||
import { tables, datasources } from "stores/builder"
|
||||
import {
|
||||
notifications,
|
||||
Input,
|
||||
Label,
|
||||
ModalContent,
|
||||
Layout,
|
||||
} from "@budibase/bbui"
|
||||
import { notifications, Input, ModalContent } from "@budibase/bbui"
|
||||
import TableDataImport from "../TableDataImport.svelte"
|
||||
import {
|
||||
BUDIBASE_INTERNAL_DB_ID,
|
||||
|
@ -101,18 +95,11 @@
|
|||
bind:value={name}
|
||||
{error}
|
||||
/>
|
||||
<div>
|
||||
<Layout gap="XS" noPadding>
|
||||
<Label grey extraSmall
|
||||
>Create a Table from a CSV or JSON file (Optional)</Label
|
||||
>
|
||||
<TableDataImport
|
||||
{promptUpload}
|
||||
bind:rows
|
||||
bind:schema
|
||||
bind:allValid
|
||||
bind:displayColumn
|
||||
/>
|
||||
</Layout>
|
||||
</div>
|
||||
<TableDataImport
|
||||
{promptUpload}
|
||||
bind:rows
|
||||
bind:schema
|
||||
bind:allValid
|
||||
bind:displayColumn
|
||||
/>
|
||||
</ModalContent>
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
{showPopover}
|
||||
on:open
|
||||
on:close
|
||||
customZindex={100}
|
||||
>
|
||||
<div class="detail-popover">
|
||||
<div class="detail-popover__header">
|
||||
|
|
|
@ -60,10 +60,10 @@
|
|||
buttonsCollapsed
|
||||
>
|
||||
<svelte:fragment slot="controls">
|
||||
<GridManageAccessButton />
|
||||
{#if calculation}
|
||||
<GridViewCalculationButton />
|
||||
{/if}
|
||||
<GridManageAccessButton />
|
||||
<GridFilterButton />
|
||||
<GridSortButton />
|
||||
<GridSizeButton />
|
||||
|
|
|
@ -38,6 +38,10 @@
|
|||
let loaded = false
|
||||
$: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
|
||||
$: licensePlan = $auth.user?.license?.plan
|
||||
|
||||
// Reset the page every time that a filter gets updated
|
||||
$: pageInfo.reset(), automationId, status, timeRange
|
||||
|
||||
$: page = $pageInfo.page
|
||||
$: fetchLogs(automationId, status, page, timeRange)
|
||||
$: isCloud = $admin.cloud
|
||||
|
|
|
@ -206,7 +206,7 @@
|
|||
if (!user?._id) {
|
||||
$goto("./")
|
||||
}
|
||||
tenantOwner = await users.tenantOwner($auth.tenantId)
|
||||
tenantOwner = await users.getAccountHolder()
|
||||
}
|
||||
|
||||
async function toggleFlags(detail) {
|
||||
|
|
|
@ -71,7 +71,6 @@
|
|||
]
|
||||
let userData = []
|
||||
let invitesLoaded = false
|
||||
let tenantOwnerLoaded = false
|
||||
let pendingInvites = []
|
||||
let parsedInvites = []
|
||||
|
||||
|
@ -100,13 +99,9 @@
|
|||
$: pendingSchema = getPendingSchema(schema)
|
||||
$: userData = []
|
||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: setEnrichedUsers($fetch.rows, tenantOwnerLoaded)
|
||||
$: setEnrichedUsers($fetch.rows, tenantOwner)
|
||||
|
||||
const setEnrichedUsers = async rows => {
|
||||
if (!tenantOwnerLoaded) {
|
||||
enrichedUsers = []
|
||||
return
|
||||
}
|
||||
const setEnrichedUsers = async (rows, owner) => {
|
||||
enrichedUsers = rows?.map(user => {
|
||||
let userGroups = []
|
||||
$groups.forEach(group => {
|
||||
|
@ -118,7 +113,9 @@
|
|||
})
|
||||
}
|
||||
})
|
||||
user.tenantOwnerEmail = tenantOwner?.email
|
||||
if (owner) {
|
||||
user.tenantOwnerEmail = owner.email
|
||||
}
|
||||
const role = Constants.ExtendedBudibaseRoleOptions.find(
|
||||
x => x.value === users.getUserRole(user)
|
||||
)
|
||||
|
@ -280,7 +277,12 @@
|
|||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
await users.bulkDelete(ids)
|
||||
await users.bulkDelete(
|
||||
selectedRows.map(user => ({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedInvites.length > 0) {
|
||||
|
@ -317,13 +319,22 @@
|
|||
try {
|
||||
await groups.actions.init()
|
||||
groupsLoaded = true
|
||||
pendingInvites = await users.getInvites()
|
||||
invitesLoaded = true
|
||||
tenantOwner = await users.tenantOwner($auth.tenantId)
|
||||
tenantOwnerLoaded = true
|
||||
} catch (error) {
|
||||
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>
|
||||
|
||||
|
|
|
@ -112,8 +112,8 @@ export function createUsersStore() {
|
|||
return await API.getUserCountByApp({ appId })
|
||||
}
|
||||
|
||||
async function bulkDelete(userIds) {
|
||||
return API.deleteUsers(userIds)
|
||||
async function bulkDelete(users) {
|
||||
return API.deleteUsers(users)
|
||||
}
|
||||
|
||||
async function save(user) {
|
||||
|
@ -128,9 +128,8 @@ export function createUsersStore() {
|
|||
return await API.removeAppBuilder({ userId, appId })
|
||||
}
|
||||
|
||||
async function getTenantOwner(tenantId) {
|
||||
const tenantInfo = await API.getTenantInfo({ tenantId })
|
||||
return tenantInfo?.owner
|
||||
async function getAccountHolder() {
|
||||
return await API.getAccountHolder()
|
||||
}
|
||||
|
||||
const getUserRole = user => {
|
||||
|
@ -176,7 +175,7 @@ export function createUsersStore() {
|
|||
save: refreshUsage(save),
|
||||
bulkDelete: refreshUsage(bulkDelete),
|
||||
delete: refreshUsage(del),
|
||||
tenantOwner: getTenantOwner,
|
||||
getAccountHolder,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
const context = getContext("context")
|
||||
const component = getContext("component")
|
||||
const { API, fetchDatasourceSchema } = getContext("sdk")
|
||||
const { fetchDatasourceSchema, fetchDatasourceDefinition } = getContext("sdk")
|
||||
|
||||
const getInitialFormStep = () => {
|
||||
const parsedFormStep = parseInt(initialFormStep)
|
||||
|
@ -32,9 +32,9 @@
|
|||
return parsedFormStep
|
||||
}
|
||||
|
||||
let loaded = false
|
||||
let definition
|
||||
let schema
|
||||
let table
|
||||
let loaded = false
|
||||
let currentStep = getContext("current-step") || writable(getInitialFormStep())
|
||||
|
||||
$: fetchSchema(dataSource)
|
||||
|
@ -84,12 +84,10 @@
|
|||
|
||||
// Fetches the form schema from this form's dataSource
|
||||
const fetchSchema = async dataSource => {
|
||||
if (dataSource?.tableId && !dataSource?.type?.startsWith("query")) {
|
||||
try {
|
||||
table = await API.fetchTableDefinition(dataSource.tableId)
|
||||
} catch (error) {
|
||||
table = null
|
||||
}
|
||||
try {
|
||||
definition = await fetchDatasourceDefinition(dataSource)
|
||||
} catch (error) {
|
||||
definition = null
|
||||
}
|
||||
const res = await fetchDatasourceSchema(dataSource)
|
||||
schema = res || {}
|
||||
|
@ -121,7 +119,7 @@
|
|||
{readonly}
|
||||
{actionType}
|
||||
{schema}
|
||||
{table}
|
||||
{definition}
|
||||
{initialValues}
|
||||
{disableSchemaValidation}
|
||||
{editAutoColumns}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
export let initialValues
|
||||
export let size
|
||||
export let schema
|
||||
export let table
|
||||
export let definition
|
||||
export let disableSchemaValidation = false
|
||||
export let editAutoColumns = false
|
||||
|
||||
|
@ -164,7 +164,7 @@
|
|||
schemaConstraints,
|
||||
validationRules,
|
||||
field,
|
||||
table
|
||||
definition
|
||||
)
|
||||
|
||||
// Sanitise the default value to ensure it doesn't contain invalid data
|
||||
|
@ -338,7 +338,7 @@
|
|||
schemaConstraints,
|
||||
validationRules,
|
||||
field,
|
||||
table
|
||||
definition
|
||||
)
|
||||
|
||||
// Update validator
|
||||
|
|
|
@ -113,7 +113,10 @@
|
|||
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||
|
||||
const forceFetchRows = async () => {
|
||||
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
|
||||
optionsObj = {}
|
||||
fieldApi?.setValue([])
|
||||
selectedValue = []
|
||||
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||
}
|
||||
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
|
||||
|
|
|
@ -5,17 +5,17 @@ import { Helpers } from "@budibase/bbui"
|
|||
/**
|
||||
* Creates a validation function from a combination of schema-level constraints
|
||||
* 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 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
|
||||
*/
|
||||
export const createValidatorFromConstraints = (
|
||||
schemaConstraints,
|
||||
customRules,
|
||||
field,
|
||||
table
|
||||
definition
|
||||
) => {
|
||||
let rules = []
|
||||
|
||||
|
@ -23,7 +23,7 @@ export const createValidatorFromConstraints = (
|
|||
if (schemaConstraints) {
|
||||
// Required constraint
|
||||
if (
|
||||
field === table?.primaryDisplay ||
|
||||
field === definition?.primaryDisplay ||
|
||||
schemaConstraints.presence?.allowEmpty === false ||
|
||||
schemaConstraints.presence === true
|
||||
) {
|
||||
|
|
|
@ -26,7 +26,10 @@ import Provider from "components/context/Provider.svelte"
|
|||
import Block from "components/Block.svelte"
|
||||
import BlockComponent from "components/BlockComponent.svelte"
|
||||
import { ActionTypes } from "./constants"
|
||||
import { fetchDatasourceSchema } from "./utils/schema.js"
|
||||
import {
|
||||
fetchDatasourceSchema,
|
||||
fetchDatasourceDefinition,
|
||||
} from "./utils/schema.js"
|
||||
import { getAPIKey } from "./utils/api.js"
|
||||
import { enrichButtonActions } from "./utils/buttonActions.js"
|
||||
import { processStringSync, makePropSafe } from "@budibase/string-templates"
|
||||
|
@ -66,6 +69,7 @@ export default {
|
|||
linkable,
|
||||
getAction,
|
||||
fetchDatasourceSchema,
|
||||
fetchDatasourceDefinition,
|
||||
fetchData,
|
||||
QueryUtils,
|
||||
ContextScopes: Constants.ContextScopes,
|
||||
|
|
|
@ -10,16 +10,13 @@ import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js"
|
|||
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
|
||||
* schema of a datasource of their respective types.
|
||||
* @param datasource the datasource to fetch the schema for
|
||||
* @param options options for enriching the schema
|
||||
* @param datasource the datasource
|
||||
* @returns
|
||||
*/
|
||||
export const fetchDatasourceSchema = async (
|
||||
datasource,
|
||||
options = { enrichRelationships: false, formSchema: false }
|
||||
) => {
|
||||
const getDatasourceFetchInstance = datasource => {
|
||||
const handler = {
|
||||
table: TableFetch,
|
||||
view: ViewFetch,
|
||||
|
@ -34,10 +31,23 @@ export const fetchDatasourceSchema = async (
|
|||
if (!handler) {
|
||||
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
|
||||
let schema
|
||||
|
@ -75,6 +85,15 @@ export const fetchDatasourceSchema = async (
|
|||
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
|
||||
* @param schema the schema to enrich
|
||||
|
|
|
@ -122,14 +122,14 @@ export const buildUserEndpoints = API => ({
|
|||
|
||||
/**
|
||||
* 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({
|
||||
url: `/api/global/users/bulk`,
|
||||
body: {
|
||||
delete: {
|
||||
userIds,
|
||||
users,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -296,9 +296,9 @@ export const buildUserEndpoints = API => ({
|
|||
})
|
||||
},
|
||||
|
||||
getTenantInfo: async ({ tenantId }) => {
|
||||
getAccountHolder: async () => {
|
||||
return await API.get({
|
||||
url: `/api/global/tenant/${tenantId}`,
|
||||
url: `/api/global/users/accountholder`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -6,6 +6,7 @@ export const buildViewV2Endpoints = API => ({
|
|||
fetchDefinition: async viewId => {
|
||||
return await API.get({
|
||||
url: `/api/v2/views/${encodeURIComponent(viewId)}`,
|
||||
cache: true,
|
||||
})
|
||||
},
|
||||
/**
|
||||
|
|
|
@ -184,7 +184,7 @@ export default class DataFetch {
|
|||
|
||||
// Build the query
|
||||
let query = this.options.query
|
||||
if (!query && this.features.supportsSearch) {
|
||||
if (!query) {
|
||||
query = buildQuery(filter)
|
||||
}
|
||||
|
||||
|
|
|
@ -30,11 +30,10 @@ export default class UserFetch extends DataFetch {
|
|||
async getData() {
|
||||
const { limit, paginate } = this.options
|
||||
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
|
||||
: { string: { email: null } }
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit f6aebba94451ce47bba551926e5ad72bd75f71c6
|
||||
Subproject commit 2ab8536b6005576684810d774f1ac22239218546
|
|
@ -41,7 +41,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
|
|||
RUN ./scripts/removeWorkspaceDependencies.sh package.json
|
||||
|
||||
# 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 \
|
||||
&& apk del g++ make python3 jq \
|
||||
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp
|
||||
|
|
|
@ -763,12 +763,25 @@ describe.each([
|
|||
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 () => {
|
||||
const row = await config.api.row.save(table._id!, {
|
||||
food: ["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", () => {
|
||||
|
@ -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("string column", () => {
|
||||
beforeAll(async () => {
|
||||
|
|
|
@ -2268,58 +2268,118 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
describe("calculation views", () => {
|
||||
it("should not remove calculation columns when modifying table schema", async () => {
|
||||
let table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
!isLucene &&
|
||||
describe("calculation views", () => {
|
||||
it("should not remove calculation columns when modifying table schema", async () => {
|
||||
let table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
name: {
|
||||
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({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
type: ViewV2Type.CALCULATION,
|
||||
schema: {
|
||||
sum: {
|
||||
visible: true,
|
||||
calculationType: CalculationType.SUM,
|
||||
field: "age",
|
||||
table = await config.api.table.get(table._id!)
|
||||
await config.api.table.save({
|
||||
...table,
|
||||
schema: {
|
||||
...table.schema,
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
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!)
|
||||
await config.api.table.save({
|
||||
...table,
|
||||
schema: {
|
||||
...table.schema,
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
constraints: { presence: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
describe("bigints", () => {
|
||||
let table: Table
|
||||
let view: ViewV2
|
||||
|
||||
view = await config.api.viewV2.get(view.id)
|
||||
expect(Object.keys(view.schema!).sort()).toEqual([
|
||||
"age",
|
||||
"id",
|
||||
"name",
|
||||
"sum",
|
||||
])
|
||||
beforeEach(async () => {
|
||||
table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
bigint: {
|
||||
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", () => {
|
||||
|
@ -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)(
|
||||
|
|
|
@ -134,7 +134,12 @@ async function processDefaultValues(table: Table, row: Row) {
|
|||
}
|
||||
|
||||
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[]
|
||||
if (Array.isArray(schema.default)) {
|
||||
processed = schema.default.map(val => processStringSync(val, ctx))
|
||||
|
@ -440,19 +445,26 @@ export async function coreOutputProcessing(
|
|||
}
|
||||
|
||||
if (sdk.views.isView(source)) {
|
||||
const calculationFields = Object.keys(
|
||||
helpers.views.calculationFields(source)
|
||||
)
|
||||
|
||||
// We ensure all calculation fields are returned as numbers. During the
|
||||
// We ensure calculation fields are returned as numbers. During the
|
||||
// testing of this feature it was discovered that the COUNT operation
|
||||
// returns a string for MySQL, MariaDB, and Postgres. But given that all
|
||||
// calculation fields should be numbers, we blanket make sure of that
|
||||
// here.
|
||||
for (const key of calculationFields) {
|
||||
// calculation fields (except ones operating on BIGINTs) should be
|
||||
// numbers, we blanket make sure of that here.
|
||||
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) {
|
||||
if (typeof row[key] === "string") {
|
||||
row[key] = parseFloat(row[key])
|
||||
if (typeof row[name] === "string") {
|
||||
row[name] = parseFloat(row[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -488,7 +488,13 @@ export function buildQuery(
|
|||
if (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 {
|
||||
[operator]: { conditions: filters.map(buildCondition).filter(f => f) },
|
||||
}
|
||||
|
|
|
@ -57,12 +57,12 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
|
|||
[FieldType.STRING]: true,
|
||||
[FieldType.OPTIONS]: true,
|
||||
[FieldType.ARRAY]: true,
|
||||
[FieldType.BIGINT]: true,
|
||||
[FieldType.BOOLEAN]: true,
|
||||
|
||||
[FieldType.AUTO]: false,
|
||||
[FieldType.INTERNAL]: false,
|
||||
[FieldType.BARCODEQR]: false,
|
||||
[FieldType.BIGINT]: false,
|
||||
[FieldType.BOOLEAN]: false,
|
||||
[FieldType.FORMULA]: false,
|
||||
[FieldType.AI]: false,
|
||||
[FieldType.ATTACHMENTS]: false,
|
||||
|
|
|
@ -15,7 +15,10 @@ export interface UserDetails {
|
|||
|
||||
export interface BulkUserRequest {
|
||||
delete?: {
|
||||
userIds: string[]
|
||||
users: Array<{
|
||||
userId: string
|
||||
email: string
|
||||
}>
|
||||
}
|
||||
create?: {
|
||||
roles?: any[]
|
||||
|
|
|
@ -186,6 +186,16 @@ export interface ArrayFieldMetadata extends BaseFieldSchema {
|
|||
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 {
|
||||
type: FieldType
|
||||
name: string
|
||||
|
@ -214,6 +224,8 @@ interface OtherFieldMetadata extends BaseFieldSchema {
|
|||
| FieldType.STRING
|
||||
| FieldType.ARRAY
|
||||
| FieldType.OPTIONS
|
||||
| FieldType.BOOLEAN
|
||||
| FieldType.BIGINT
|
||||
>
|
||||
}
|
||||
|
||||
|
@ -233,6 +245,8 @@ export type FieldSchema =
|
|||
| BBReferenceSingleFieldMetadata
|
||||
| ArrayFieldMetadata
|
||||
| OptionsFieldMetadata
|
||||
| BooleanFieldMetadata
|
||||
| BigIntFieldMetadata
|
||||
|
||||
export interface TableSchema {
|
||||
[key: string]: FieldSchema
|
||||
|
|
|
@ -7,4 +7,3 @@ export * from "./schedule"
|
|||
export * from "./templates"
|
||||
export * from "./environmentVariables"
|
||||
export * from "./auditLogs"
|
||||
export * from "./tenantInfo"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -38,6 +38,11 @@ export function isSSOUser(user: User): user is SSOUser {
|
|||
|
||||
// USER
|
||||
|
||||
export interface UserIdentifier {
|
||||
userId: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface User extends Document {
|
||||
tenantId: string
|
||||
email: string
|
||||
|
|
|
@ -24,7 +24,7 @@ COPY packages/worker/dist/yarn.lock .
|
|||
|
||||
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
|
||||
RUN apk del .gyp \
|
||||
&& yarn cache clean
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -23,9 +23,11 @@ import {
|
|||
SearchUsersRequest,
|
||||
User,
|
||||
UserCtx,
|
||||
UserIdentifier,
|
||||
} from "@budibase/types"
|
||||
import {
|
||||
accounts,
|
||||
users,
|
||||
cache,
|
||||
ErrorCode,
|
||||
events,
|
||||
|
@ -55,8 +57,8 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
|
|||
const requestUser = ctx.request.body
|
||||
|
||||
// Do not allow the account holder role to be changed
|
||||
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId)
|
||||
if (tenantInfo?.owner.email === requestUser.email) {
|
||||
const accountMetadata = await users.getExistingAccounts([requestUser.email])
|
||||
if (accountMetadata?.length > 0) {
|
||||
if (
|
||||
requestUser.admin?.global !== true ||
|
||||
requestUser.builder?.global !== true
|
||||
|
@ -103,11 +105,14 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
|
|||
}
|
||||
}
|
||||
|
||||
const bulkDelete = async (userIds: string[], currentUserId: string) => {
|
||||
if (userIds?.indexOf(currentUserId) !== -1) {
|
||||
const bulkDelete = async (
|
||||
users: Array<UserIdentifier>,
|
||||
currentUserId: string
|
||||
) => {
|
||||
if (users.find(u => u.userId === currentUserId)) {
|
||||
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[]) => {
|
||||
|
@ -130,7 +135,7 @@ export const bulkUpdate = async (
|
|||
created = await bulkCreate(input.create.users, input.create.groups)
|
||||
}
|
||||
if (input.delete) {
|
||||
deleted = await bulkDelete(input.delete.userIds, currentUserId)
|
||||
deleted = await bulkDelete(input.delete.users, currentUserId)
|
||||
}
|
||||
} catch (err: any) {
|
||||
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.
|
||||
*/
|
||||
|
|
|
@ -71,10 +71,6 @@ const PUBLIC_ENDPOINTS = [
|
|||
route: "/api/global/users/invite",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/global/tenant",
|
||||
method: "POST",
|
||||
},
|
||||
]
|
||||
|
||||
const NO_TENANCY_ENDPOINTS = [
|
||||
|
@ -121,11 +117,7 @@ const NO_TENANCY_ENDPOINTS = [
|
|||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/global/tenant",
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
route: "/api/global/tenant/:id",
|
||||
route: "/api/global/users/accountholder",
|
||||
method: "GET",
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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"}')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -412,28 +412,6 @@ describe("/api/global/users", () => {
|
|||
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 () => {
|
||||
const user = await config.createUser(structures.users.builderUser())
|
||||
jest.clearAllMocks()
|
||||
|
@ -592,55 +570,21 @@ describe("/api/global/users", () => {
|
|||
|
||||
describe("POST /api/global/users/bulk (delete)", () => {
|
||||
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(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", () => {
|
||||
|
|
|
@ -136,6 +136,7 @@ router
|
|||
buildAdminInitValidation(),
|
||||
controller.adminUser
|
||||
)
|
||||
.get("/api/global/users/accountholder", controller.accountHolderLookup)
|
||||
.get("/api/global/users/tenant/:id", controller.tenantUserLookup)
|
||||
// global endpoint but needs to come at end (blocks other endpoints otherwise)
|
||||
.get("/api/global/users/:id", auth.builderOrAdmin, controller.find)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Router from "@koa/router"
|
||||
import { api as pro } from "@budibase/pro"
|
||||
import userRoutes from "./global/users"
|
||||
import tenantRoutes from "./global/tenant"
|
||||
import configRoutes from "./global/configs"
|
||||
import workspaceRoutes from "./global/workspaces"
|
||||
import templateRoutes from "./global/templates"
|
||||
|
@ -41,7 +40,6 @@ export const routes: Router[] = [
|
|||
accountRoutes,
|
||||
restoreRoutes,
|
||||
eventRoutes,
|
||||
tenantRoutes,
|
||||
pro.scim,
|
||||
]
|
||||
|
||||
|
|
|
@ -66,7 +66,14 @@ export const buildUserBulkUserValidation = (isSelf = false) => {
|
|||
users: Joi.array().items(Joi.object(schema).required().unknown(true)),
|
||||
}),
|
||||
delete: Joi.object({
|
||||
userIds: Joi.array().items(Joi.string()),
|
||||
users: Joi.array().items(
|
||||
Joi.object({
|
||||
email: Joi.string(),
|
||||
userId: Joi.string(),
|
||||
})
|
||||
.required()
|
||||
.unknown(true)
|
||||
),
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { TenantInfo } from "@budibase/types"
|
||||
import TestConfiguration from "../TestConfiguration"
|
||||
import { TestAPI, TestAPIOpts } from "./base"
|
||||
|
||||
|
@ -15,12 +14,4 @@ export class TenantAPI extends TestAPI {
|
|||
.set(opts?.headers)
|
||||
.expect(opts?.status ? opts.status : 204)
|
||||
}
|
||||
|
||||
saveTenantInfo = (tenantInfo: TenantInfo) => {
|
||||
return this.request
|
||||
.post("/api/global/tenant")
|
||||
.set(this.config.internalAPIHeaders())
|
||||
.send(tenantInfo)
|
||||
.expect(200)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,8 +81,14 @@ export class UserAPI extends TestAPI {
|
|||
return res.body as BulkUserResponse
|
||||
}
|
||||
|
||||
bulkDeleteUsers = async (userIds: string[], status?: number) => {
|
||||
const body: BulkUserRequest = { delete: { userIds } }
|
||||
bulkDeleteUsers = async (
|
||||
users: Array<{
|
||||
userId: string
|
||||
email: string
|
||||
}>,
|
||||
status?: number
|
||||
) => {
|
||||
const body: BulkUserRequest = { delete: { users } }
|
||||
const res = await this.request
|
||||
.post(`/api/global/users/bulk`)
|
||||
.send(body)
|
||||
|
|
Loading…
Reference in New Issue