Merge branch 'v3-ui' of github.com:Budibase/budibase into new-rbac-ui
This commit is contained in:
commit
f1aca4c7df
|
@ -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/*",
|
||||||
|
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
<script>
|
|
||||||
</script>
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
}, {})
|
}, {})
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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}'`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue