Merge branch 'v3-ui' of github.com:Budibase/budibase into new-rbac-ui

This commit is contained in:
Andrew Kingston 2024-09-11 09:45:39 +01:00
commit f1aca4c7df
No known key found for this signature in database
24 changed files with 1555 additions and 305 deletions

View File

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

View File

@ -267,6 +267,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// default values set correctly and their types flow through the system. // default values set correctly and their types flow through the system.
export const flags = new FlagSet({ export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()), DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()), SQS: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false), [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false),
}) })

View File

@ -13,7 +13,7 @@
export let fromRelationshipField export let fromRelationshipField
export let canSetRelationshipSchemas export let canSetRelationshipSchemas
const { datasource, dispatch, cache } = getContext("grid") const { datasource, dispatch } = getContext("grid")
let relationshipPanelAnchor let relationshipPanelAnchor
let relationshipFieldName let relationshipFieldName
@ -113,29 +113,19 @@
return { ...c, options } return { ...c, options }
}) })
let relationshipPanelColumns = [] $: relationshipPanelColumns = Object.entries(
async function fetchRelationshipPanelColumns(relationshipField) { relationshipField?.columns || {}
relationshipPanelColumns = [] ).map(([name, column]) => {
if (!relationshipField) { return {
return name: name,
label: name,
schema: {
type: column.type,
visible: column.visible,
readonly: column.readonly,
},
} }
})
const table = await cache.actions.getTable(relationshipField.tableId)
relationshipPanelColumns = Object.entries(
relationshipField?.columns || {}
).map(([name, column]) => {
return {
name: name,
label: name,
schema: {
type: table.schema[name].type,
visible: column.visible,
readonly: column.readonly,
},
}
})
}
$: fetchRelationshipPanelColumns(relationshipField)
async function toggleColumn(column, permission) { async function toggleColumn(column, permission) {
const visible = permission !== FieldPermissions.HIDDEN const visible = permission !== FieldPermissions.HIDDEN
@ -216,7 +206,7 @@
on:close={() => (relationshipFieldName = null)} on:close={() => (relationshipFieldName = null)}
open={relationshipFieldName} open={relationshipFieldName}
anchor={relationshipPanelAnchor} anchor={relationshipPanelAnchor}
align="right-outside" align="left"
> >
{#if relationshipPanelColumns.length} {#if relationshipPanelColumns.length}
<div class="relationship-header"> <div class="relationship-header">

View File

@ -137,12 +137,10 @@
{/if} {/if}
{#if !isUsersTable} {#if !isUsersTable}
<GridRowActionsButton /> <GridRowActionsButton />
{/if} <GridScreensButton on:request-generate={() => generateButton?.show()} />
<GridScreensButton on:request-generate={() => generateButton?.show()} /> <GridAutomationsButton
<GridAutomationsButton on:request-generate={() => generateButton?.show()}
on:request-generate={() => generateButton?.show()} />
/>
{#if !isUsersTable}
<GridImportButton /> <GridImportButton />
{/if} {/if}
<GridExportButton /> <GridExportButton />

View File

@ -1,10 +1,12 @@
<script> <script>
import { import {
AbsTooltip,
Layout, Layout,
Heading, Heading,
Body, Body,
Button, Button,
Divider, Divider,
Icon,
Tags, Tags,
Tag, Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
@ -15,6 +17,8 @@
export let description export let description
export let enabled export let enabled
export let upgradeButtonClick export let upgradeButtonClick
$: upgradeDisabled = !$auth.accountPortalAccess && $admin.cloud
</script> </script>
<Layout noPadding> <Layout noPadding>
@ -36,8 +40,9 @@
{:else} {:else}
<div class="buttons"> <div class="buttons">
<Button <Button
primary primary={!upgradeDisabled}
disabled={!$auth.accountPortalAccess && $admin.cloud} secondary={upgradeDisabled}
disabled={upgradeDisabled}
on:click={async () => upgradeButtonClick()} on:click={async () => upgradeButtonClick()}
> >
Upgrade Upgrade
@ -51,6 +56,16 @@
> >
View Plans View Plans
</Button> </Button>
{#if upgradeDisabled}
<AbsTooltip
text={"Please contact the account holder to upgrade"}
position={"right"}
>
<div class="icon" on:focus>
<Icon name="InfoOutline" size="L" disabled hoverable />
</div>
</AbsTooltip>
{/if}
</div> </div>
{/if} {/if}
</Layout> </Layout>
@ -67,7 +82,11 @@
justify-content: flex-start; justify-content: flex-start;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.icon {
position: relative;
display: flex;
justify-content: center;
}
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -127,7 +127,10 @@
name: user.firstName ? user.firstName + " " + user.lastName : "", name: user.firstName ? user.firstName + " " + user.lastName : "",
userGroups, userGroups,
__selectable: __selectable:
role.value === Constants.BudibaseRoles.Owner ? false : undefined, role.value === Constants.BudibaseRoles.Owner ||
$auth.user?.email === user.email
? false
: true,
apps: [...new Set(Object.keys(user.roles))], apps: [...new Set(Object.keys(user.roles))],
access: role.sortOrder, access: role.sortOrder,
} }
@ -392,7 +395,7 @@
allowSelectRows={!readonly} allowSelectRows={!readonly}
{customRenderers} {customRenderers}
loading={!$fetch.loaded || !groupsLoaded} loading={!$fetch.loaded || !groupsLoaded}
defaultSortColumn={"access"} defaultSortColumn={"__selectable"}
/> />
<div class="pagination"> <div class="pagination">

View File

@ -76,7 +76,9 @@ export const ExtendedBudibaseRoleOptions = [
value: BudibaseRoles.Owner, value: BudibaseRoles.Owner,
sortOrder: 0, sortOrder: 0,
}, },
].concat(BudibaseRoleOptions) ]
.concat(BudibaseRoleOptions)
.concat(BudibaseRoleOptionsOld)
export const PlanType = { export const PlanType = {
FREE: "free", FREE: "free",

View File

@ -29,6 +29,9 @@ async function parseSchema(view: CreateViewRequest) {
acc[key] = { acc[key] = {
visible: fieldSchema.visible, visible: fieldSchema.visible,
readonly: fieldSchema.readonly, readonly: fieldSchema.readonly,
order: fieldSchema.order,
width: fieldSchema.width,
icon: fieldSchema.icon,
} }
return acc return acc
}, {}) }, {})

View File

@ -5,11 +5,10 @@ import {
CreateRowActionRequest, CreateRowActionRequest,
DocumentType, DocumentType,
PermissionLevel, PermissionLevel,
Row,
RowActionResponse, RowActionResponse,
} from "@budibase/types" } from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { Expectations } from "../../../tests/utilities/api/base" import { Expectations } from "../../../tests/utilities/api/base"
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { automations } from "@budibase/pro" import { automations } from "@budibase/pro"
@ -651,13 +650,27 @@ describe("/rowsActions", () => {
}) })
describe("trigger", () => { describe("trigger", () => {
let row: Row let viewId: string
let rowId: string
let rowAction: RowActionResponse let rowAction: RowActionResponse
beforeEach(async () => { beforeEach(async () => {
row = await config.api.row.save(tableId, {}) const row = await config.api.row.save(tableId, {})
rowId = row._id!
rowAction = await createRowAction(tableId, createRowActionRequest()) rowAction = await createRowAction(tableId, createRowActionRequest())
viewId = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
await config.api.rowAction.setViewPermission(
tableId,
viewId,
rowAction.id
)
await config.publish() await config.publish()
tk.travel(Date.now() + 100) tk.travel(Date.now() + 100)
}) })
@ -673,9 +686,7 @@ describe("/rowsActions", () => {
it("can trigger an automation given valid data", async () => { it("can trigger an automation given valid data", async () => {
expect(await getAutomationLogs()).toBeEmpty() expect(await getAutomationLogs()).toBeEmpty()
await config.api.rowAction.trigger(tableId, rowAction.id, { await config.api.rowAction.trigger(viewId, rowAction.id, { rowId })
rowId: row._id!,
})
const automationLogs = await getAutomationLogs() const automationLogs = await getAutomationLogs()
expect(automationLogs).toEqual([ expect(automationLogs).toEqual([
@ -687,8 +698,11 @@ describe("/rowsActions", () => {
inputs: null, inputs: null,
outputs: { outputs: {
fields: {}, fields: {},
row: await config.api.row.get(tableId, row._id!), row: await config.api.row.get(tableId, rowId),
table: await config.api.table.get(tableId), table: {
...(await config.api.table.get(tableId)),
views: expect.anything(),
},
automation: expect.objectContaining({ automation: expect.objectContaining({
_id: rowAction.automationId, _id: rowAction.automationId,
}), }),
@ -709,9 +723,7 @@ describe("/rowsActions", () => {
await config.api.rowAction.trigger( await config.api.rowAction.trigger(
viewId, viewId,
rowAction.id, rowAction.id,
{ { rowId },
rowId: row._id!,
},
{ {
status: 403, status: 403,
body: { body: {
@ -738,10 +750,9 @@ describe("/rowsActions", () => {
) )
await config.publish() await config.publish()
expect(await getAutomationLogs()).toBeEmpty() expect(await getAutomationLogs()).toBeEmpty()
await config.api.rowAction.trigger(viewId, rowAction.id, { await config.api.rowAction.trigger(viewId, rowAction.id, { rowId })
rowId: row._id!,
})
const automationLogs = await getAutomationLogs() const automationLogs = await getAutomationLogs()
expect(automationLogs).toEqual([ expect(automationLogs).toEqual([
@ -750,5 +761,170 @@ describe("/rowsActions", () => {
}), }),
]) ])
}) })
describe("role permission checks", () => {
beforeAll(() => {
mocks.licenses.useViewPermissions()
})
afterAll(() => {
mocks.licenses.useCloudFree()
})
function createUser(role: string) {
return config.createUser({
admin: { global: false },
builder: {},
roles: { [config.getProdAppId()]: role },
})
}
const allowedRoleConfig = (() => {
function getRolesLowerThan(role: string) {
const result = Object.values(roles.BUILTIN_ROLE_IDS).filter(
r => r !== role && roles.lowerBuiltinRoleID(r, role) === r
)
return result
}
return Object.values(roles.BUILTIN_ROLE_IDS).flatMap(r =>
[r, ...getRolesLowerThan(r)].map(p => [r, p])
)
})()
const disallowedRoleConfig = (() => {
function getRolesHigherThan(role: string) {
const result = Object.values(roles.BUILTIN_ROLE_IDS).filter(
r => r !== role && roles.lowerBuiltinRoleID(r, role) === role
)
return result
}
return Object.values(roles.BUILTIN_ROLE_IDS).flatMap(r =>
getRolesHigherThan(r).map(p => [r, p])
)
})()
describe.each([
[
"view (with implicit views)",
async () => {
const viewId = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
await config.api.rowAction.setViewPermission(
tableId,
viewId,
rowAction.id
)
return { permissionResource: viewId, triggerResouce: viewId }
},
],
[
"view (without implicit views)",
async () => {
const viewId = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
await config.api.rowAction.setViewPermission(
tableId,
viewId,
rowAction.id
)
return { permissionResource: tableId, triggerResouce: viewId }
},
],
])("checks for %s", (_, getResources) => {
it.each(allowedRoleConfig)(
"allows triggering if the user has read permission (user %s, table %s)",
async (userRole, resourcePermission) => {
const { permissionResource, triggerResouce } = await getResources()
await config.api.permission.add({
level: PermissionLevel.READ,
resourceId: permissionResource,
roleId: resourcePermission,
})
const normalUser = await createUser(userRole)
await config.withUser(normalUser, async () => {
await config.publish()
await config.api.rowAction.trigger(
triggerResouce,
rowAction.id,
{ rowId },
{ status: 200 }
)
})
}
)
it.each(disallowedRoleConfig)(
"rejects if the user does not have table read permission (user %s, table %s)",
async (userRole, resourcePermission) => {
const { permissionResource, triggerResouce } = await getResources()
await config.api.permission.add({
level: PermissionLevel.READ,
resourceId: permissionResource,
roleId: resourcePermission,
})
const normalUser = await createUser(userRole)
await config.withUser(normalUser, async () => {
await config.publish()
await config.api.rowAction.trigger(
triggerResouce,
rowAction.id,
{ rowId },
{
status: 403,
body: { message: "User does not have permission" },
}
)
const automationLogs = await getAutomationLogs()
expect(automationLogs).toBeEmpty()
})
}
)
})
it.each(allowedRoleConfig)(
"does not allow running row actions for tables by default even",
async (userRole, resourcePermission) => {
await config.api.permission.add({
level: PermissionLevel.READ,
resourceId: tableId,
roleId: resourcePermission,
})
const normalUser = await createUser(userRole)
await config.withUser(normalUser, async () => {
await config.publish()
await config.api.rowAction.trigger(
tableId,
rowAction.id,
{ rowId },
{
status: 403,
body: {
message: `Row action '${rowAction.id}' is not enabled for table '${tableId}'`,
},
}
)
const automationLogs = await getAutomationLogs()
expect(automationLogs).toBeEmpty()
})
}
)
})
}) })
}) })

View File

@ -1278,9 +1278,18 @@ describe.each([
schema: expect.objectContaining({ schema: expect.objectContaining({
aux: expect.objectContaining({ aux: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
name: { visible: true, readonly: true }, visible: false,
dob: { visible: true, readonly: true }, readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
}, },
}), }),
}), }),
@ -1323,16 +1332,34 @@ describe.each([
schema: expect.objectContaining({ schema: expect.objectContaining({
aux: expect.objectContaining({ aux: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
name: { visible: true, readonly: true }, visible: false,
dob: { visible: true, readonly: true }, readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
}, },
}), }),
aux2: expect.objectContaining({ aux2: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
name: { visible: true, readonly: true }, visible: false,
dob: { visible: true, readonly: true }, readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
}, },
}), }),
}), }),
@ -1375,16 +1402,34 @@ describe.each([
schema: expect.objectContaining({ schema: expect.objectContaining({
aux: expect.objectContaining({ aux: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
fullName: { visible: true, readonly: true }, visible: false,
age: { visible: false, readonly: false }, readonly: false,
}),
fullName: expect.objectContaining({
visible: true,
readonly: true,
}),
age: expect.objectContaining({
visible: false,
readonly: false,
}),
}, },
}), }),
aux2: expect.objectContaining({ aux2: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
name: { visible: true, readonly: true }, visible: false,
age: { visible: false, readonly: false }, readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
age: expect.objectContaining({
visible: false,
readonly: false,
}),
}, },
}), }),
}), }),
@ -1427,9 +1472,18 @@ describe.each([
schema: expect.objectContaining({ schema: expect.objectContaining({
aux: expect.objectContaining({ aux: expect.objectContaining({
columns: { columns: {
id: { visible: false, readonly: false }, id: expect.objectContaining({
name: { visible: true, readonly: true }, visible: false,
dob: { visible: true, readonly: true }, readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
}, },
}), }),
}), }),

View File

@ -16,6 +16,7 @@ import * as delay from "./steps/delay"
import * as queryRow from "./steps/queryRows" import * as queryRow from "./steps/queryRows"
import * as loop from "./steps/loop" import * as loop from "./steps/loop"
import * as collect from "./steps/collect" import * as collect from "./steps/collect"
import * as branch from "./steps/branch"
import * as triggerAutomationRun from "./steps/triggerAutomationRun" import * as triggerAutomationRun from "./steps/triggerAutomationRun"
import env from "../environment" import env from "../environment"
import { import {
@ -28,6 +29,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import sdk from "../sdk" import sdk from "../sdk"
import { getAutomationPlugin } from "../utilities/fileSystem" import { getAutomationPlugin } from "../utilities/fileSystem"
import { features } from "@budibase/backend-core"
type ActionImplType = ActionImplementations< type ActionImplType = ActionImplementations<
typeof env.SELF_HOSTED extends "true" ? Hosting.SELF : Hosting.CLOUD typeof env.SELF_HOSTED extends "true" ? Hosting.SELF : Hosting.CLOUD
@ -98,6 +100,9 @@ if (env.SELF_HOSTED) {
} }
export async function getActionDefinitions() { export async function getActionDefinitions() {
if (await features.flags.isEnabled("AUTOMATION_BRANCHING")) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
}
const actionDefinitions = BUILTIN_ACTION_DEFINITIONS const actionDefinitions = BUILTIN_ACTION_DEFINITIONS
if (env.SELF_HOSTED) { if (env.SELF_HOSTED) {
const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION) const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION)

View File

@ -31,7 +31,7 @@ import { cache, configs, context, HTTPError } from "@budibase/backend-core"
import { dataFilters, utils } from "@budibase/shared-core" import { dataFilters, utils } from "@budibase/shared-core"
import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants" import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants"
interface GoogleSheetsConfig { export interface GoogleSheetsConfig {
spreadsheetId: string spreadsheetId: string
auth: OAuthClientConfig auth: OAuthClientConfig
continueSetupId?: string continueSetupId?: string
@ -157,7 +157,7 @@ const SCHEMA: Integration = {
}, },
} }
class GoogleSheetsIntegration implements DatasourcePlus { export class GoogleSheetsIntegration implements DatasourcePlus {
private readonly config: GoogleSheetsConfig private readonly config: GoogleSheetsConfig
private readonly spreadsheetId: string private readonly spreadsheetId: string
private client: GoogleSpreadsheet = undefined! private client: GoogleSpreadsheet = undefined!
@ -378,6 +378,10 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return this.create({ sheet, row: json.body as Row }) return this.create({ sheet, row: json.body as Row })
case Operation.BULK_CREATE: case Operation.BULK_CREATE:
return this.createBulk({ sheet, rows: json.body as Row[] }) return this.createBulk({ sheet, rows: json.body as Row[] })
case Operation.BULK_UPSERT:
// This is technically not correct because it won't update existing
// rows, but it's better than not having this functionality at all.
return this.createBulk({ sheet, rows: json.body as Row[] })
case Operation.READ: case Operation.READ:
return this.read({ ...json, sheet }) return this.read({ ...json, sheet })
case Operation.UPDATE: case Operation.UPDATE:
@ -395,9 +399,19 @@ class GoogleSheetsIntegration implements DatasourcePlus {
sheet, sheet,
}) })
case Operation.CREATE_TABLE: case Operation.CREATE_TABLE:
return this.createTable(json?.table?.name) if (!json.table) {
throw new Error(
"attempted to create a table without specifying the table to create"
)
}
return this.createTable(json.table)
case Operation.UPDATE_TABLE: case Operation.UPDATE_TABLE:
return this.updateTable(json.table!) if (!json.table) {
throw new Error(
"attempted to create a table without specifying the table to create"
)
}
return this.updateTable(json.table)
case Operation.DELETE_TABLE: case Operation.DELETE_TABLE:
return this.deleteTable(json?.table?.name) return this.deleteTable(json?.table?.name)
default: default:
@ -422,13 +436,13 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return rowObject return rowObject
} }
private async createTable(name?: string) { private async createTable(table: Table) {
if (!name) {
throw new Error("Must provide name for new sheet.")
}
try { try {
await this.connect() await this.connect()
await this.client.addSheet({ title: name, headerValues: [name] }) await this.client.addSheet({
title: table.name,
headerValues: Object.keys(table.schema),
})
} catch (err) { } catch (err) {
console.error("Error creating new table in google sheets", err) console.error("Error creating new table in google sheets", err)
throw err throw err
@ -552,37 +566,16 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} else { } else {
rows = await sheet.getRows() rows = await sheet.getRows()
} }
// this is a special case - need to handle the _id, it doesn't exist
// we cannot edit the returned structure from google, it does not have
// setter functions and is immutable, easier to update the filters
// to look for the _rowNumber property rather than rowNumber
if (query.filters?.equal) {
const idFilterKeys = Object.keys(query.filters.equal).filter(filter =>
filter.includes(GOOGLE_SHEETS_PRIMARY_KEY)
)
for (let idFilterKey of idFilterKeys) {
const id = query.filters.equal[idFilterKey]
delete query.filters.equal[idFilterKey]
query.filters.equal[`_${GOOGLE_SHEETS_PRIMARY_KEY}`] = id
}
}
let filtered = dataFilters.runQuery(
rows,
query.filters || {},
(row: GoogleSpreadsheetRow, headerKey: string) => {
return row.get(headerKey)
}
)
if (hasFilters && query.paginate) { if (hasFilters && query.paginate) {
filtered = filtered.slice(offset, offset + limit) rows = rows.slice(offset, offset + limit)
} }
const headerValues = sheet.headerValues const headerValues = sheet.headerValues
let response = []
for (let row of filtered) { let response = rows.map(row =>
response.push( this.buildRowObject(headerValues, row.toObject(), row.rowNumber)
this.buildRowObject(headerValues, row.toObject(), row._rowNumber) )
) response = dataFilters.runQuery(response, query.filters || {})
}
if (query.sort) { if (query.sort) {
if (Object.keys(query.sort).length !== 1) { if (Object.keys(query.sort).length !== 1) {

View File

@ -1,50 +1,44 @@
import { setEnv as setCoreEnv } from "@budibase/backend-core" import { setEnv as setCoreEnv } from "@budibase/backend-core"
import type { GoogleSpreadsheetWorksheet } from "google-spreadsheet"
import nock from "nock" import nock from "nock"
jest.mock("google-auth-library")
const { OAuth2Client } = require("google-auth-library")
const setCredentialsMock = jest.fn()
const getAccessTokenMock = jest.fn()
OAuth2Client.mockImplementation(() => {
return {
setCredentials: setCredentialsMock,
getAccessToken: getAccessTokenMock,
}
})
jest.mock("google-spreadsheet")
const { GoogleSpreadsheet } = require("google-spreadsheet")
const sheetsByTitle: { [title: string]: GoogleSpreadsheetWorksheet } = {}
const sheetsByIndex: GoogleSpreadsheetWorksheet[] = []
const mockGoogleIntegration = {
useOAuth2Client: jest.fn(),
loadInfo: jest.fn(),
sheetsByTitle,
sheetsByIndex,
}
GoogleSpreadsheet.mockImplementation(() => mockGoogleIntegration)
import { structures } from "@budibase/backend-core/tests"
import TestConfiguration from "../../tests/utilities/TestConfiguration" import TestConfiguration from "../../tests/utilities/TestConfiguration"
import GoogleSheetsIntegration from "../googlesheets" import {
import { FieldType, Table, TableSchema, TableSourceType } from "@budibase/types" Datasource,
import { generateDatasourceID } from "../../db/utils" FieldType,
SourceName,
Table,
TableSourceType,
} from "@budibase/types"
import { GoogleSheetsMock } from "./utils/googlesheets"
describe("Google Sheets Integration", () => { describe("Google Sheets Integration", () => {
let integration: any, const config = new TestConfiguration()
config = new TestConfiguration()
let cleanupEnv: () => void
beforeAll(() => { let cleanupEnv: () => void
let datasource: Datasource
let mock: GoogleSheetsMock
beforeAll(async () => {
cleanupEnv = setCoreEnv({ cleanupEnv = setCoreEnv({
GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test", GOOGLE_CLIENT_SECRET: "test",
}) })
await config.init()
datasource = await config.api.datasource.create({
name: "Test Datasource",
type: "datasource",
source: SourceName.GOOGLE_SHEETS,
config: {
spreadsheetId: "randomId",
auth: {
appId: "appId",
accessToken: "accessToken",
refreshToken: "refreshToken",
},
},
})
}) })
afterAll(async () => { afterAll(async () => {
@ -53,125 +47,257 @@ describe("Google Sheets Integration", () => {
}) })
beforeEach(async () => { beforeEach(async () => {
integration = new GoogleSheetsIntegration.integration({
spreadsheetId: "randomId",
auth: {
appId: "appId",
accessToken: "accessToken",
refreshToken: "refreshToken",
},
})
await config.init()
jest.clearAllMocks()
nock.cleanAll() nock.cleanAll()
nock("https://www.googleapis.com/").post("/oauth2/v4/token").reply(200, { mock = GoogleSheetsMock.forDatasource(datasource)
grant_type: "client_credentials",
client_id: "your-client-id",
client_secret: "your-client-secret",
})
}) })
function createBasicTable(name: string, columns: string[]): Table { describe("create", () => {
return { it("creates a new table", async () => {
type: "table", const table = await config.api.table.save({
name, name: "Test Table",
sourceId: generateDatasourceID(), type: "table",
sourceType: TableSourceType.EXTERNAL, sourceId: datasource._id!,
schema: { sourceType: TableSourceType.EXTERNAL,
...columns.reduce((p, c) => { schema: {
p[c] = { name: {
name: c, name: "name",
type: FieldType.STRING, type: FieldType.STRING,
constraints: { constraints: {
type: "string", type: "string",
}, },
} },
return p description: {
}, {} as TableSchema), name: "description",
}, type: FieldType.STRING,
} constraints: {
} type: "string",
},
function createSheet({ },
headerValues, },
}: {
headerValues: string[]
}): GoogleSpreadsheetWorksheet {
return {
// to ignore the unmapped fields
...({} as any),
loadHeaderRow: jest.fn(),
headerValues,
setHeaderRow: jest.fn(),
}
}
describe("update table", () => {
it("adding a new field will be adding a new header row", async () => {
await config.doInContext(structures.uuid(), async () => {
const tableColumns = ["name", "description", "new field"]
const table = createBasicTable(structures.uuid(), tableColumns)
const sheet = createSheet({ headerValues: ["name", "description"] })
sheetsByTitle[table.name] = sheet
await integration.updateTable(table)
expect(sheet.loadHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledWith(tableColumns)
}) })
expect(table.name).toEqual("Test Table")
expect(mock.cell("A1")).toEqual("name")
expect(mock.cell("B1")).toEqual("description")
expect(mock.cell("A2")).toEqual(null)
expect(mock.cell("B2")).toEqual(null)
}) })
it("removing an existing field will remove the header from the google sheet", async () => { it("can handle multiple tables", async () => {
const sheet = await config.doInContext(structures.uuid(), async () => { const table1 = await config.api.table.save({
const tableColumns = ["name"] name: "Test Table 1",
const table = createBasicTable(structures.uuid(), tableColumns) type: "table",
sourceId: datasource._id!,
const sheet = createSheet({ sourceType: TableSourceType.EXTERNAL,
headerValues: ["name", "description", "location"], schema: {
}) one: {
sheetsByTitle[table.name] = sheet name: "one",
await integration.updateTable(table) type: FieldType.STRING,
return sheet constraints: {
type: "string",
},
},
},
}) })
expect(sheet.loadHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledTimes(1) const table2 = await config.api.table.save({
expect(sheet.setHeaderRow).toHaveBeenCalledWith([ name: "Test Table 2",
"name", type: "table",
"description", sourceId: datasource._id!,
"location", sourceType: TableSourceType.EXTERNAL,
]) schema: {
two: {
name: "two",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
expect(table1.name).toEqual("Test Table 1")
expect(table2.name).toEqual("Test Table 2")
expect(mock.cell("Test Table 1!A1")).toEqual("one")
expect(mock.cell("Test Table 1!A2")).toEqual(null)
expect(mock.cell("Test Table 2!A1")).toEqual("two")
expect(mock.cell("Test Table 2!A2")).toEqual(null)
}) })
}) })
describe("getTableNames", () => { describe("read", () => {
it("can fetch table names", async () => { let table: Table
await config.doInContext(structures.uuid(), async () => { beforeEach(async () => {
const sheetNames: string[] = [] table = await config.api.table.save({
for (let i = 0; i < 5; i++) { name: "Test Table",
const sheet = createSheet({ headerValues: [] }) type: "table",
sheetsByIndex.push(sheet) sourceId: datasource._id!,
sheetNames.push(sheet.title) sourceType: TableSourceType.EXTERNAL,
} schema: {
name: {
const res = await integration.getTableNames() name: "name",
type: FieldType.STRING,
expect(mockGoogleIntegration.loadInfo).toHaveBeenCalledTimes(1) constraints: {
expect(res).toEqual(sheetNames) type: "string",
},
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
}) })
await config.api.row.bulkImport(table._id!, {
rows: [
{
name: "Test Contact 1",
description: "original description 1",
},
{
name: "Test Contact 2",
description: "original description 2",
},
],
})
})
it("can read table details", async () => {
const response = await config.api.table.get(table._id!)
expect(response.name).toEqual("Test Table")
expect(response.schema).toEqual({
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
})
})
it("can read table rows", async () => {
const rows = await config.api.row.fetch(table._id!)
expect(rows.length).toEqual(2)
expect(rows[0].name).toEqual("Test Contact 1")
expect(rows[0].description).toEqual("original description 1")
expect(rows[0]._id).toEqual("%5B2%5D")
expect(rows[1].name).toEqual("Test Contact 2")
expect(rows[1].description).toEqual("original description 2")
expect(rows[1]._id).toEqual("%5B3%5D")
})
it("can get a specific row", async () => {
const row1 = await config.api.row.get(table._id!, "2")
expect(row1.name).toEqual("Test Contact 1")
expect(row1.description).toEqual("original description 1")
const row2 = await config.api.row.get(table._id!, "3")
expect(row2.name).toEqual("Test Contact 2")
expect(row2.description).toEqual("original description 2")
}) })
}) })
describe("testConnection", () => { describe("update", () => {
it("can test successful connections", async () => { let table: Table
await config.doInContext(structures.uuid(), async () => { beforeEach(async () => {
const res = await integration.testConnection() table = await config.api.table.save({
name: "Test Table",
expect(mockGoogleIntegration.loadInfo).toHaveBeenCalledTimes(1) type: "table",
expect(res).toEqual({ connected: true }) sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
},
}) })
}) })
it("should be able to add a new row", async () => {
const row = await config.api.row.save(table._id!, {
name: "Test Contact",
description: "original description",
})
expect(row.name).toEqual("Test Contact")
expect(row.description).toEqual("original description")
expect(mock.cell("A2")).toEqual("Test Contact")
expect(mock.cell("B2")).toEqual("original description")
const row2 = await config.api.row.save(table._id!, {
name: "Test Contact 2",
description: "original description 2",
})
expect(row2.name).toEqual("Test Contact 2")
expect(row2.description).toEqual("original description 2")
// Notable that adding a new row adds it at the top, not the bottom. Not
// entirely sure if this is the intended behaviour or an incorrect
// implementation of the GoogleSheetsMock.
expect(mock.cell("A2")).toEqual("Test Contact 2")
expect(mock.cell("B2")).toEqual("original description 2")
expect(mock.cell("A3")).toEqual("Test Contact")
expect(mock.cell("B3")).toEqual("original description")
})
it("should be able to add multiple rows", async () => {
await config.api.row.bulkImport(table._id!, {
rows: [
{
name: "Test Contact 1",
description: "original description 1",
},
{
name: "Test Contact 2",
description: "original description 2",
},
],
})
expect(mock.cell("A2")).toEqual("Test Contact 1")
expect(mock.cell("B2")).toEqual("original description 1")
expect(mock.cell("A3")).toEqual("Test Contact 2")
expect(mock.cell("B3")).toEqual("original description 2")
})
it("should be able to update a row", async () => {
const row = await config.api.row.save(table._id!, {
name: "Test Contact",
description: "original description",
})
expect(mock.cell("A2")).toEqual("Test Contact")
expect(mock.cell("B2")).toEqual("original description")
await config.api.row.save(table._id!, {
...row,
name: "Test Contact Updated",
description: "original description updated",
})
expect(mock.cell("A2")).toEqual("Test Contact Updated")
expect(mock.cell("B2")).toEqual("original description updated")
})
}) })
}) })

View File

@ -0,0 +1,863 @@
// In this file is a mock implementation of the Google Sheets API. It is used
// to test the Google Sheets integration, and it keeps track of a single
// spreadsheet with many sheets. It aims to be a faithful recreation of the
// Google Sheets API, but it is not a perfect recreation. Some fields are
// missing if they aren't relevant to our use of the API. It's possible that
// this will cause problems for future feature development, but the original
// development of these tests involved hitting Google's APIs directly and
// examining the responses. If we couldn't find a good example of something in
// use, it wasn't included.
import { Datasource } from "@budibase/types"
import nock from "nock"
import { GoogleSheetsConfig } from "../../googlesheets"
import type {
SpreadsheetProperties,
ExtendedValue,
WorksheetDimension,
WorksheetDimensionProperties,
WorksheetProperties,
CellData,
CellBorder,
CellFormat,
CellPadding,
Color,
} from "google-spreadsheet/src/lib/types/sheets-types"
const BLACK: Color = { red: 0, green: 0, blue: 0 }
const WHITE: Color = { red: 1, green: 1, blue: 1 }
const NO_PADDING: CellPadding = { top: 0, right: 0, bottom: 0, left: 0 }
const DEFAULT_BORDER: CellBorder = {
style: "SOLID",
width: 1,
color: BLACK,
colorStyle: { rgbColor: BLACK },
}
const DEFAULT_CELL_FORMAT: CellFormat = {
hyperlinkDisplayType: "PLAIN_TEXT",
horizontalAlignment: "LEFT",
verticalAlignment: "BOTTOM",
wrapStrategy: "OVERFLOW_CELL",
textDirection: "LEFT_TO_RIGHT",
textRotation: { angle: 0, vertical: false },
padding: NO_PADDING,
backgroundColorStyle: { rgbColor: BLACK },
borders: {
top: DEFAULT_BORDER,
bottom: DEFAULT_BORDER,
left: DEFAULT_BORDER,
right: DEFAULT_BORDER,
},
numberFormat: {
type: "NUMBER",
pattern: "General",
},
backgroundColor: WHITE,
textFormat: {
foregroundColor: BLACK,
fontFamily: "Arial",
fontSize: 10,
bold: false,
italic: false,
strikethrough: false,
underline: false,
},
}
// https://protobuf.dev/reference/protobuf/google.protobuf/#value
type Value = string | number | boolean | null
interface Range {
row: number
column: number
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values#ValueRange
interface ValueRange {
range: string
majorDimension: WorksheetDimension
values: Value[][]
}
// https://developers.google.com/sheets/api/reference/rest/v4/UpdateValuesResponse
interface UpdateValuesResponse {
spreadsheetId: string
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
updatedData: ValueRange
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response#AddSheetResponse
interface AddSheetResponse {
properties: WorksheetProperties
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response
interface BatchUpdateResponse {
spreadsheetId: string
replies: {
addSheet?: AddSheetResponse
}[]
updatedSpreadsheet: Spreadsheet
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest
interface AddSheetRequest {
properties: WorksheetProperties
}
interface Request {
addSheet?: AddSheetRequest
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request
interface BatchUpdateRequest {
requests: Request[]
includeSpreadsheetInResponse: boolean
responseRanges: string[]
responseIncludeGridData: boolean
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData
interface RowData {
values: CellData[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData
interface GridData {
startRow: number
startColumn: number
rowData: RowData[]
rowMetadata: WorksheetDimensionProperties[]
columnMetadata: WorksheetDimensionProperties[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet
interface Sheet {
properties: WorksheetProperties
data: GridData[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#Spreadsheet
interface Spreadsheet {
properties: SpreadsheetProperties
spreadsheetId: string
sheets: Sheet[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption
type ValueInputOption =
| "USER_ENTERED"
| "RAW"
| "INPUT_VALUE_OPTION_UNSPECIFIED"
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption
type InsertDataOption = "OVERWRITE" | "INSERT_ROWS"
// https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption
type ValueRenderOption = "FORMATTED_VALUE" | "UNFORMATTED_VALUE" | "FORMULA"
// https://developers.google.com/sheets/api/reference/rest/v4/DateTimeRenderOption
type DateTimeRenderOption = "SERIAL_NUMBER" | "FORMATTED_STRING"
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#query-parameters
interface AppendParams {
valueInputOption?: ValueInputOption
insertDataOption?: InsertDataOption
includeValuesInResponse?: boolean
responseValueRenderOption?: ValueRenderOption
responseDateTimeRenderOption?: DateTimeRenderOption
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#query-parameters
interface BatchGetParams {
ranges: string[]
majorDimension?: WorksheetDimension
valueRenderOption?: ValueRenderOption
dateTimeRenderOption?: DateTimeRenderOption
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#response-body
interface BatchGetResponse {
spreadsheetId: string
valueRanges: ValueRange[]
}
interface AppendRequest {
range: string
params: AppendParams
body: ValueRange
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#response-body
interface AppendResponse {
spreadsheetId: string
tableRange: string
updates: UpdateValuesResponse
}
export class GoogleSheetsMock {
private config: GoogleSheetsConfig
private spreadsheet: Spreadsheet
static forDatasource(datasource: Datasource): GoogleSheetsMock {
return new GoogleSheetsMock(datasource.config as GoogleSheetsConfig)
}
private constructor(config: GoogleSheetsConfig) {
this.config = config
this.spreadsheet = {
properties: {
title: "Test Spreadsheet",
locale: "en_US",
autoRecalc: "ON_CHANGE",
timeZone: "America/New_York",
defaultFormat: {},
iterativeCalculationSettings: {},
spreadsheetTheme: {},
},
spreadsheetId: config.spreadsheetId,
sheets: [],
}
this.mockAuth()
this.mockAPI()
}
private route(
method: "get" | "put" | "post",
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
const headers = { reqheaders: { authorization: "Bearer test" } }
const scope = nock("https://sheets.googleapis.com/", headers)
return scope[method](path).reply(200, handler).persist()
}
private get(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("get", path, handler)
}
private put(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("put", path, handler)
}
private post(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("post", path, handler)
}
private mockAuth() {
nock("https://www.googleapis.com/")
.post("/oauth2/v4/token")
.reply(200, {
grant_type: "client_credentials",
client_id: "your-client-id",
client_secret: "your-client-secret",
})
.persist()
nock("https://oauth2.googleapis.com/")
.post("/token", {
client_id: "test",
client_secret: "test",
grant_type: "refresh_token",
refresh_token: "refreshToken",
})
.reply(200, {
access_token: "test",
expires_in: 3600,
token_type: "Bearer",
scopes: "https://www.googleapis.com/auth/spreadsheets",
})
.persist()
}
private mockAPI() {
const spreadsheetId = this.config.spreadsheetId
this.get(`/v4/spreadsheets/${spreadsheetId}/`, () =>
this.handleGetSpreadsheet()
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate
this.post(
`/v4/spreadsheets/${spreadsheetId}/:batchUpdate`,
(_uri, request) => this.handleBatchUpdate(request as BatchUpdateRequest)
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update
this.put(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`),
(_uri, request) => this.handleValueUpdate(request as ValueRange)
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet
this.get(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values:batchGet.*`),
uri => {
const url = new URL(uri, "https://sheets.googleapis.com/")
const params: BatchGetParams = {
ranges: url.searchParams.getAll("ranges"),
majorDimension:
(url.searchParams.get("majorDimension") as WorksheetDimension) ||
"ROWS",
valueRenderOption:
(url.searchParams.get("valueRenderOption") as ValueRenderOption) ||
undefined,
dateTimeRenderOption:
(url.searchParams.get(
"dateTimeRenderOption"
) as DateTimeRenderOption) || undefined,
}
return this.handleBatchGet(params as unknown as BatchGetParams)
}
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get
this.get(new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), uri => {
const range = uri.split("/").pop()
if (!range) {
throw new Error("No range provided")
}
return this.getValueRange(decodeURIComponent(range))
})
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append
this.post(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*:append`),
(_uri, request) => {
const url = new URL(_uri, "https://sheets.googleapis.com/")
const params: Record<string, any> = Object.fromEntries(
url.searchParams.entries()
)
if (params.includeValuesInResponse === "true") {
params.includeValuesInResponse = true
} else {
params.includeValuesInResponse = false
}
let range = url.pathname.split("/").pop()
if (!range) {
throw new Error("No range provided")
}
if (range.endsWith(":append")) {
range = range.slice(0, -7)
}
range = decodeURIComponent(range)
return this.handleValueAppend({
range,
params,
body: request as ValueRange,
})
}
)
}
private handleValueAppend(request: AppendRequest): AppendResponse {
const { range, params, body } = request
const { sheet, bottomRight } = this.parseA1Notation(range)
const newRows = body.values.map(v => this.valuesToRowData(v))
const toDelete =
params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0
sheet.data[0].rowData.splice(bottomRight.row + 1, toDelete, ...newRows)
sheet.data[0].rowMetadata.splice(bottomRight.row + 1, toDelete, {
hiddenByUser: false,
hiddenByFilter: false,
pixelSize: 100,
developerMetadata: [],
})
// It's important to give back a correct updated range because the API
// library we use makes use of it to assign the correct row IDs to rows.
const updatedRange = this.createA1FromRanges(
sheet,
{
row: bottomRight.row + 1,
column: 0,
},
{
row: bottomRight.row + newRows.length,
column: 0,
}
)
return {
spreadsheetId: this.spreadsheet.spreadsheetId,
tableRange: range,
updates: {
spreadsheetId: this.spreadsheet.spreadsheetId,
updatedRange,
updatedRows: body.values.length,
updatedColumns: body.values[0].length,
updatedCells: body.values.length * body.values[0].length,
updatedData: body,
},
}
}
private handleBatchGet(params: BatchGetParams): BatchGetResponse {
const { ranges, majorDimension } = params
if (majorDimension && majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
return {
spreadsheetId: this.spreadsheet.spreadsheetId,
valueRanges: ranges.map(range => this.getValueRange(range)),
}
}
private handleBatchUpdate(
batchUpdateRequest: BatchUpdateRequest
): BatchUpdateResponse {
const response: BatchUpdateResponse = {
spreadsheetId: this.spreadsheet.spreadsheetId,
replies: [],
updatedSpreadsheet: this.spreadsheet,
}
for (const request of batchUpdateRequest.requests) {
if (request.addSheet) {
response.replies.push({
addSheet: this.handleAddSheet(request.addSheet),
})
}
}
return response
}
private handleAddSheet(request: AddSheetRequest): AddSheetResponse {
const properties: Omit<WorksheetProperties, "dataSourceSheetProperties"> = {
index: this.spreadsheet.sheets.length,
hidden: false,
rightToLeft: false,
tabColor: BLACK,
tabColorStyle: { rgbColor: BLACK },
sheetType: "GRID",
title: request.properties.title,
sheetId: this.spreadsheet.sheets.length,
gridProperties: {
rowCount: 100,
columnCount: 26,
frozenRowCount: 0,
frozenColumnCount: 0,
hideGridlines: false,
rowGroupControlAfter: false,
columnGroupControlAfter: false,
},
}
this.spreadsheet.sheets.push({
properties: properties as WorksheetProperties,
data: [this.createEmptyGrid(100, 26)],
})
// dataSourceSheetProperties is only returned by the API if the sheet type is
// DATA_SOURCE, which we aren't using, so sadly we need to cast here.
return { properties: properties as WorksheetProperties }
}
private handleGetSpreadsheet(): Spreadsheet {
return this.spreadsheet
}
private handleValueUpdate(valueRange: ValueRange): UpdateValuesResponse {
this.iterateCells(valueRange, (cell, value) => {
cell.userEnteredValue = this.createValue(value)
})
const response: UpdateValuesResponse = {
spreadsheetId: this.spreadsheet.spreadsheetId,
updatedRange: valueRange.range,
updatedRows: valueRange.values.length,
updatedColumns: valueRange.values[0].length,
updatedCells: valueRange.values.length * valueRange.values[0].length,
updatedData: valueRange,
}
return response
}
private iterateCells(
valueRange: ValueRange,
cb: (cell: CellData, value: Value) => void
) {
if (valueRange.majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
const { sheet, topLeft, bottomRight } = this.parseA1Notation(
valueRange.range
)
for (let row = topLeft.row; row <= bottomRight.row; row++) {
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
const value = valueRange.values[row - topLeft.row][col - topLeft.column]
cb(cell, value)
}
}
}
private getValueRange(range: string): ValueRange {
const { sheet, topLeft, bottomRight } = this.parseA1Notation(range)
const valueRange: ValueRange = {
range,
majorDimension: "ROWS",
values: [],
}
for (let row = topLeft.row; row <= bottomRight.row; row++) {
const values: Value[] = []
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
values.push(this.cellValue(cell))
}
valueRange.values.push(values)
}
return this.trimValueRange(valueRange)
}
// When Google Sheets returns a value range, it will trim the data down to the
// smallest possible size. It does all of the following:
//
// 1. Converts cells in non-empty rows up to the first value to empty strings.
// 2. Removes all cells after the last non-empty cell in a row.
// 3. Removes all rows after the last non-empty row.
// 4. Rows that are before the first non-empty row that are empty are replaced with [].
//
// We replicate this behaviour here.
private trimValueRange(valueRange: ValueRange): ValueRange {
for (const row of valueRange.values) {
if (row.every(v => v == null)) {
row.splice(0, row.length)
continue
}
for (let i = row.length - 1; i >= 0; i--) {
const cell = row[i]
if (cell == null) {
row.pop()
} else {
break
}
}
for (let i = 0; i < row.length; i++) {
const cell = row[i]
if (cell == null) {
row[i] = ""
} else {
break
}
}
}
for (let i = valueRange.values.length - 1; i >= 0; i--) {
const row = valueRange.values[i]
if (row.length === 0) {
valueRange.values.pop()
} else {
break
}
}
return valueRange
}
private valuesToRowData(values: Value[]): RowData {
return {
values: values.map(v => {
return this.createCellData(v)
}),
}
}
private unwrapValue(from: ExtendedValue): Value {
if ("stringValue" in from) {
return from.stringValue
} else if ("numberValue" in from) {
return from.numberValue
} else if ("boolValue" in from) {
return from.boolValue
} else if ("formulaValue" in from) {
return from.formulaValue
} else {
return null
}
}
private cellValue(from: CellData): Value {
return this.unwrapValue(from.userEnteredValue)
}
private createValue(from: Value): ExtendedValue {
if (from == null) {
return {} as ExtendedValue
} else if (typeof from === "string") {
return {
stringValue: from,
}
} else if (typeof from === "number") {
return {
numberValue: from,
}
} else if (typeof from === "boolean") {
return {
boolValue: from,
}
} else {
throw new Error("Unsupported value type")
}
}
/**
* Because the structure of a CellData is very nested and contains a lot of
* extraneous formatting information, this function abstracts it away and just
* lets you create a cell containing a given value.
*
* When you want to read the value back out, use {@link cellValue}.
*
* @param value value to store in the returned cell
* @returns a CellData containing the given value. Read it back out with
* {@link cellValue}
*/
private createCellData(value: Value): CellData {
return {
userEnteredValue: this.createValue(value),
effectiveValue: this.createValue(value),
formattedValue: value?.toString() || "",
userEnteredFormat: DEFAULT_CELL_FORMAT,
effectiveFormat: DEFAULT_CELL_FORMAT,
}
}
private createEmptyGrid(numRows: number, numCols: number): GridData {
const rowData: RowData[] = []
for (let row = 0; row < numRows; row++) {
const cells: CellData[] = []
for (let col = 0; col < numCols; col++) {
cells.push(this.createCellData(null))
}
rowData.push({ values: cells })
}
const rowMetadata: WorksheetDimensionProperties[] = []
for (let row = 0; row < numRows; row++) {
rowMetadata.push({
hiddenByFilter: false,
hiddenByUser: false,
pixelSize: 100,
developerMetadata: [],
})
}
const columnMetadata: WorksheetDimensionProperties[] = []
for (let col = 0; col < numCols; col++) {
columnMetadata.push({
hiddenByFilter: false,
hiddenByUser: false,
pixelSize: 100,
developerMetadata: [],
})
}
return {
startRow: 0,
startColumn: 0,
rowData,
rowMetadata,
columnMetadata,
}
}
private cellData(cell: string): CellData | undefined {
const {
sheet,
topLeft: { row, column },
} = this.parseA1Notation(cell)
return this.getCellNumericIndexes(sheet, row, column)
}
cell(cell: string): Value | undefined {
const cellData = this.cellData(cell)
if (!cellData) {
return undefined
}
return this.cellValue(cellData)
}
private getCellNumericIndexes(
sheet: Sheet,
row: number,
column: number
): CellData | undefined {
const data = sheet.data[0]
const rowData = data.rowData[row]
if (!rowData) {
return undefined
}
const cell = rowData.values[column]
if (!cell) {
return undefined
}
return cell
}
// https://developers.google.com/sheets/api/guides/concepts#cell
//
// Examples from
// https://code.luasoftware.com/tutorials/google-sheets-api/google-sheets-api-range-parameter-a1-notation
//
// "Sheet1!A1" -> First cell on Row 1 Col 1
// "Sheet1!A1:C1" -> Col 1-3 (A, B, C) on Row 1 = A1, B1, C1
// "A1" -> First visible sheet (if sheet name is ommitted)
// "'My Sheet'!A1" -> If sheet name which contain space or start with a bracket.
// "Sheet1" -> All cells in Sheet1.
// "Sheet1!A:A" -> All cells on Col 1.
// "Sheet1!A:B" -> All cells on Col 1 and 2.
// "Sheet1!1:1" -> All cells on Row 1.
// "Sheet1!1:2" -> All cells on Row 1 and 2.
//
// How that translates to our code below, omitting the `sheet` property:
//
// "Sheet1!A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1!A1:C1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 2 } }
// "A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 100, column: 25 } }
// -> This is because we default to having a 100x26 grid.
// "Sheet1!A:A" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 0 } }
// "Sheet1!A:B" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 1 } }
// "Sheet1!1:1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 25 } }
// "Sheet1!1:2" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 1, column: 25 } }
private parseA1Notation(range: string): {
sheet: Sheet
topLeft: Range
bottomRight: Range
} {
let sheet: Sheet
let rest: string
if (!range.includes("!")) {
sheet = this.spreadsheet.sheets[0]
rest = range
} else {
let sheetName = range.split("!")[0]
if (sheetName.startsWith("'") && sheetName.endsWith("'")) {
sheetName = sheetName.slice(1, -1)
}
const foundSheet = this.getSheetByName(sheetName)
if (!foundSheet) {
throw new Error(`Sheet ${sheetName} not found`)
}
sheet = foundSheet
rest = range.split("!")[1]
}
const [topLeft, bottomRight] = rest.split(":")
const parsedTopLeft = topLeft ? this.parseCell(topLeft) : undefined
let parsedBottomRight = bottomRight
? this.parseCell(bottomRight)
: undefined
if (!parsedTopLeft && !parsedBottomRight) {
throw new Error("No range provided")
}
if (!parsedTopLeft) {
throw new Error("No top left cell provided")
}
if (!parsedBottomRight) {
parsedBottomRight = parsedTopLeft
}
if (parsedTopLeft && parsedTopLeft.row === undefined) {
parsedTopLeft.row = 0
}
if (parsedTopLeft && parsedTopLeft.column === undefined) {
parsedTopLeft.column = 0
}
if (parsedBottomRight && parsedBottomRight.row === undefined) {
parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1
}
if (parsedBottomRight && parsedBottomRight.column === undefined) {
parsedBottomRight.column = sheet.properties.gridProperties.columnCount - 1
}
return {
sheet,
topLeft: parsedTopLeft as Range,
bottomRight: parsedBottomRight as Range,
}
}
private createA1FromRanges(sheet: Sheet, topLeft: Range, bottomRight: Range) {
let title = sheet.properties.title
if (title.includes(" ")) {
title = `'${title}'`
}
const topLeftLetter = this.numberToLetter(topLeft.column)
const bottomRightLetter = this.numberToLetter(bottomRight.column)
const topLeftRow = topLeft.row + 1
const bottomRightRow = bottomRight.row + 1
return `${title}!${topLeftLetter}${topLeftRow}:${bottomRightLetter}${bottomRightRow}`
}
/**
* Parses a cell reference into a row and column.
* @param cell a string of the form A1, B2, etc.
* @returns
*/
private parseCell(cell: string): Partial<Range> {
const firstChar = cell.slice(0, 1)
if (this.isInteger(firstChar)) {
return { row: parseInt(cell) - 1 }
}
const column = this.letterToNumber(firstChar)
if (cell.length === 1) {
return { column }
}
const number = cell.slice(1)
return { row: parseInt(number) - 1, column }
}
private isInteger(value: string): boolean {
return !isNaN(parseInt(value))
}
private letterToNumber(letter: string): number {
return letter.charCodeAt(0) - 65
}
private numberToLetter(number: number): string {
return String.fromCharCode(number + 65)
}
private getSheetByName(name: string): Sheet | undefined {
return this.spreadsheet.sheets.find(
sheet => sheet.properties.title === name
)
}
}

View File

@ -88,7 +88,7 @@ const authorized =
opts = { schema: false }, opts = { schema: false },
resourcePath?: string resourcePath?: string
) => ) =>
async (ctx: any, next: any) => { async (ctx: UserCtx, next: any) => {
// webhooks don't need authentication, each webhook unique // webhooks don't need authentication, each webhook unique
// also internal requests (between services) don't need authorized // also internal requests (between services) don't need authorized
if (isWebhookEndpoint(ctx) || ctx.internal) { if (isWebhookEndpoint(ctx) || ctx.internal) {

View File

@ -1,40 +1,52 @@
import { Next } from "koa" import { Next } from "koa"
import { Ctx } from "@budibase/types" import { PermissionLevel, PermissionType, UserCtx } from "@budibase/types"
import { paramSubResource } from "./resourceId"
import { docIds } from "@budibase/backend-core" import { docIds } from "@budibase/backend-core"
import * as utils from "../db/utils" import * as utils from "../db/utils"
import sdk from "../sdk" import sdk from "../sdk"
import { authorizedResource } from "./authorized"
export function triggerRowActionAuthorised( export function triggerRowActionAuthorised(
sourcePath: string, sourcePath: string,
actionPath: string actionPath: string
) { ) {
return async (ctx: Ctx, next: Next) => { return async (ctx: UserCtx, next: Next) => {
// Reusing the existing middleware to extract the value async function getResourceIds() {
paramSubResource(sourcePath, actionPath)(ctx, () => {}) const sourceId: string = ctx.params[sourcePath]
const { resourceId: sourceId, subResourceId: rowActionId } = ctx const rowActionId: string = ctx.params[actionPath]
const isTableId = docIds.isTableId(sourceId) const isTableId = docIds.isTableId(sourceId)
const isViewId = utils.isViewID(sourceId) const isViewId = utils.isViewID(sourceId)
if (!isTableId && !isViewId) { if (!isTableId && !isViewId) {
ctx.throw(400, `'${sourceId}' is not a valid source id`) ctx.throw(400, `'${sourceId}' is not a valid source id`)
}
const tableId = isTableId
? sourceId
: utils.extractViewInfoFromID(sourceId).tableId
const viewId = isTableId ? undefined : sourceId
return { tableId, viewId, rowActionId }
} }
const tableId = isTableId const { tableId, viewId, rowActionId } = await getResourceIds()
? sourceId
: utils.extractViewInfoFromID(sourceId).tableId
// Check if the user has permissions to the table/view
await authorizedResource(
!viewId ? PermissionType.TABLE : PermissionType.VIEW,
PermissionLevel.READ,
sourcePath
)(ctx, () => {})
// Check is the row action can run for the given view/table
const rowAction = await sdk.rowActions.get(tableId, rowActionId) const rowAction = await sdk.rowActions.get(tableId, rowActionId)
if (!viewId && !rowAction.permissions.table.runAllowed) {
if (isTableId && !rowAction.permissions.table.runAllowed) {
ctx.throw( ctx.throw(
403, 403,
`Row action '${rowActionId}' is not enabled for table '${sourceId}'` `Row action '${rowActionId}' is not enabled for table '${tableId}'`
) )
} else if (isViewId && !rowAction.permissions.views[sourceId]?.runAllowed) { } else if (viewId && !rowAction.permissions.views[viewId]?.runAllowed) {
ctx.throw( ctx.throw(
403, 403,
`Row action '${rowActionId}' is not enabled for view '${sourceId}'` `Row action '${rowActionId}' is not enabled for view '${viewId}'`
) )
} }

View File

@ -75,7 +75,7 @@ export async function create(tableId: string, rowAction: { name: string }) {
name: action.name, name: action.name,
automationId: automation._id!, automationId: automation._id!,
permissions: { permissions: {
table: { runAllowed: true }, table: { runAllowed: false },
views: {}, views: {},
}, },
} }

View File

@ -7,6 +7,7 @@ import {
View, View,
ViewFieldMetadata, ViewFieldMetadata,
ViewV2, ViewV2,
ViewV2ColumnEnriched,
ViewV2Enriched, ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { HTTPError } from "@budibase/backend-core" import { HTTPError } from "@budibase/backend-core"
@ -176,7 +177,7 @@ export async function enrichSchema(
} }
const relTable = tableCache[tableId] const relTable = tableCache[tableId]
const result: Record<string, RelationSchemaField> = {} const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) { for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName] const relTableField = relTable.schema[relTableFieldName]
@ -188,9 +189,13 @@ export async function enrichSchema(
continue continue
} }
const isVisible = !!viewFields[relTableFieldName]?.visible const viewFieldSchema = viewFields[relTableFieldName]
const isReadonly = !!viewFields[relTableFieldName]?.readonly const isVisible = !!viewFieldSchema?.visible
const isReadonly = !!viewFieldSchema?.readonly
result[relTableFieldName] = { result[relTableFieldName] = {
...relTableField,
...viewFieldSchema,
name: relTableField.name,
visible: isVisible, visible: isVisible,
readonly: isReadonly, readonly: isReadonly,
} }
@ -211,6 +216,7 @@ export async function enrichSchema(
...tableSchema[key], ...tableSchema[key],
...ui, ...ui,
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order, order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order,
columns: undefined,
} }
if (schema[key].type === FieldType.LINK) { if (schema[key].type === FieldType.LINK) {

View File

@ -355,10 +355,14 @@ describe("table sdk", () => {
visible: true, visible: true,
columns: { columns: {
title: { title: {
name: "title",
type: "string",
visible: true, visible: true,
readonly: true, readonly: true,
}, },
age: { age: {
name: "age",
type: "number",
visible: false, visible: false,
readonly: false, readonly: false,
}, },

View File

@ -448,10 +448,10 @@ export function fixupFilterArrays(filters: SearchFilters) {
return filters return filters
} }
export const search = ( export function search<T>(
docs: Record<string, any>[], docs: Record<string, T>[],
query: RowSearchParams query: RowSearchParams
): SearchResponse<Record<string, any>> => { ): SearchResponse<Record<string, T>> {
let result = runQuery(docs, query.query) let result = runQuery(docs, query.query)
if (query.sort) { if (query.sort) {
result = sort(result, query.sort, query.sortOrder || SortOrder.ASCENDING) result = sort(result, query.sort, query.sortOrder || SortOrder.ASCENDING)
@ -471,15 +471,11 @@ export const search = (
* Performs a client-side search on an array of data * Performs a client-side search on an array of data
* @param docs the data * @param docs the data
* @param query the JSON query * @param query the JSON query
* @param findInDoc optional fn when trying to extract a value
* from custom doc type e.g. Google Sheets
*
*/ */
export const runQuery = ( export function runQuery<T extends Record<string, any>>(
docs: Record<string, any>[], docs: T[],
query: SearchFilters, query: SearchFilters
findInDoc: Function = deepGet ): T[] {
) => {
if (!docs || !Array.isArray(docs)) { if (!docs || !Array.isArray(docs)) {
return [] return []
} }
@ -502,11 +498,11 @@ export const runQuery = (
type: SearchFilterOperator, type: SearchFilterOperator,
test: (docValue: any, testValue: any) => boolean test: (docValue: any, testValue: any) => boolean
) => ) =>
(doc: Record<string, any>) => { (doc: T) => {
for (const [key, testValue] of Object.entries(query[type] || {})) { for (const [key, testValue] of Object.entries(query[type] || {})) {
const valueToCheck = isLogicalSearchOperator(type) const valueToCheck = isLogicalSearchOperator(type)
? doc ? doc
: findInDoc(doc, removeKeyNumbering(key)) : deepGet(doc, removeKeyNumbering(key))
const result = test(valueToCheck, testValue) const result = test(valueToCheck, testValue)
if (query.allOr && result) { if (query.allOr && result) {
return true return true
@ -749,11 +745,8 @@ export const runQuery = (
} }
) )
const docMatch = (doc: Record<string, any>) => { const docMatch = (doc: T) => {
const filterFunctions: Record< const filterFunctions: Record<SearchFilterOperator, (doc: T) => boolean> = {
SearchFilterOperator,
(doc: Record<string, any>) => boolean
> = {
string: stringMatch, string: stringMatch,
fuzzy: fuzzyMatch, fuzzy: fuzzyMatch,
range: rangeMatch, range: rangeMatch,
@ -797,12 +790,12 @@ export const runQuery = (
* @param sortOrder the sort order ("ascending" or "descending") * @param sortOrder the sort order ("ascending" or "descending")
* @param sortType the type of sort ("string" or "number") * @param sortType the type of sort ("string" or "number")
*/ */
export const sort = ( export function sort<T extends Record<string, any>>(
docs: any[], docs: T[],
sort: string, sort: keyof T,
sortOrder: SortOrder, sortOrder: SortOrder,
sortType = SortType.STRING sortType = SortType.STRING
) => { ): T[] {
if (!sort || !sortOrder || !sortType) { if (!sort || !sortOrder || !sortType) {
return docs return docs
} }
@ -817,19 +810,17 @@ export const sort = (
return parseFloat(x) return parseFloat(x)
} }
return docs return docs.slice().sort((a, b) => {
.slice() const colA = parse(a[sort])
.sort((a: { [x: string]: any }, b: { [x: string]: any }) => { const colB = parse(b[sort])
const colA = parse(a[sort])
const colB = parse(b[sort])
const result = colB == null || colA > colB ? 1 : -1 const result = colB == null || colA > colB ? 1 : -1
if (sortOrder.toLowerCase() === "descending") { if (sortOrder.toLowerCase() === "descending") {
return result * -1 return result * -1
} }
return result return result
}) })
} }
/** /**
@ -838,7 +829,7 @@ export const sort = (
* @param docs the data * @param docs the data
* @param limit the number of docs to limit to * @param limit the number of docs to limit to
*/ */
export const limit = (docs: any[], limit: string) => { export function limit<T>(docs: T[], limit: string): T[] {
const numLimit = parseFloat(limit) const numLimit = parseFloat(limit)
if (isNaN(numLimit)) { if (isNaN(numLimit)) {
return docs return docs

View File

@ -10,6 +10,11 @@ export enum AutoReason {
FOREIGN_KEY = "foreign_key", FOREIGN_KEY = "foreign_key",
} }
export type FieldSubType =
| AutoFieldSubType
| JsonFieldSubType
| BBReferenceFieldSubType
export enum AutoFieldSubType { export enum AutoFieldSubType {
CREATED_BY = "createdBy", CREATED_BY = "createdBy",
CREATED_AT = "createdAt", CREATED_AT = "createdAt",

View File

@ -38,8 +38,7 @@ export type ViewFieldMetadata = UIFieldMetadata & {
columns?: Record<string, RelationSchemaField> columns?: Record<string, RelationSchemaField>
} }
export type RelationSchemaField = { export type RelationSchemaField = UIFieldMetadata & {
visible?: boolean
readonly?: boolean readonly?: boolean
} }

View File

@ -3,7 +3,9 @@ import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents"
export interface ViewV2Enriched extends ViewV2 { export interface ViewV2Enriched extends ViewV2 {
schema?: { schema?: {
[key: string]: FieldSchema & { [key: string]: FieldSchema & {
columns?: Record<string, RelationSchemaField> columns?: Record<string, ViewV2ColumnEnriched>
} }
} }
} }
export type ViewV2ColumnEnriched = RelationSchemaField & FieldSchema