diff --git a/lerna.json b/lerna.json
index 5988c50af4..76dc01dc26 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
{
- "version": "2.6.8-alpha.7",
+ "version": "2.6.8-alpha.14",
"npmClient": "yarn",
"packages": [
"packages/backend-core",
diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts
index aa40f13775..be49b9f261 100644
--- a/packages/backend-core/src/constants/db.ts
+++ b/packages/backend-core/src/constants/db.ts
@@ -21,7 +21,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
- USER_BY_GROUP = "by_group_user",
+ USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger",
}
diff --git a/packages/backend-core/src/utils/tests/utils.spec.ts b/packages/backend-core/src/utils/tests/utils.spec.ts
index ededa48628..5a0ac4f283 100644
--- a/packages/backend-core/src/utils/tests/utils.spec.ts
+++ b/packages/backend-core/src/utils/tests/utils.spec.ts
@@ -5,6 +5,7 @@ import * as db from "../../db"
import { Header } from "../../constants"
import { newid } from "../../utils"
import env from "../../environment"
+import { BBContext } from "@budibase/types"
describe("utils", () => {
const config = new DBTestConfiguration()
@@ -106,4 +107,85 @@ describe("utils", () => {
expect(actual).toBe(undefined)
})
})
+
+ describe("isServingBuilder", () => {
+ let ctx: BBContext
+
+ const expectResult = (result: boolean) =>
+ expect(utils.isServingBuilder(ctx)).toBe(result)
+
+ beforeEach(() => {
+ ctx = structures.koa.newContext()
+ })
+
+ it("returns true if current path is in builder", async () => {
+ ctx.path = "/builder/app/app_"
+ expectResult(true)
+ })
+
+ it("returns false if current path doesn't have '/' suffix", async () => {
+ ctx.path = "/builder/app"
+ expectResult(false)
+
+ ctx.path = "/xx"
+ expectResult(false)
+ })
+ })
+
+ describe("isServingBuilderPreview", () => {
+ let ctx: BBContext
+
+ const expectResult = (result: boolean) =>
+ expect(utils.isServingBuilderPreview(ctx)).toBe(result)
+
+ beforeEach(() => {
+ ctx = structures.koa.newContext()
+ })
+
+ it("returns true if current path is in builder preview", async () => {
+ ctx.path = "/app/preview/xx"
+ expectResult(true)
+ })
+
+ it("returns false if current path is not in builder preview", async () => {
+ ctx.path = "/builder"
+ expectResult(false)
+
+ ctx.path = "/xx"
+ expectResult(false)
+ })
+ })
+
+ describe("isPublicAPIRequest", () => {
+ let ctx: BBContext
+
+ const expectResult = (result: boolean) =>
+ expect(utils.isPublicApiRequest(ctx)).toBe(result)
+
+ beforeEach(() => {
+ ctx = structures.koa.newContext()
+ })
+
+ it("returns true if current path remains to public API", async () => {
+ ctx.path = "/api/public/v1/invoices"
+ expectResult(true)
+
+ ctx.path = "/api/public/v1"
+ expectResult(true)
+
+ ctx.path = "/api/public/v2"
+ expectResult(true)
+
+ ctx.path = "/api/public/v21"
+ expectResult(true)
+ })
+
+ it("returns false if current path doesn't remain to public API", async () => {
+ ctx.path = "/api/public"
+ expectResult(false)
+
+ ctx.path = "/xx"
+ expectResult(false)
+ })
+ })
})
diff --git a/packages/backend-core/src/utils/utils.ts b/packages/backend-core/src/utils/utils.ts
index 75b098093b..82da95983a 100644
--- a/packages/backend-core/src/utils/utils.ts
+++ b/packages/backend-core/src/utils/utils.ts
@@ -1,11 +1,5 @@
-import { getAllApps, queryGlobalView } from "../db"
-import {
- Header,
- MAX_VALID_DATE,
- DocumentType,
- SEPARATOR,
- ViewName,
-} from "../constants"
+import { getAllApps } from "../db"
+import { Header, MAX_VALID_DATE, DocumentType, SEPARATOR } from "../constants"
import env from "../environment"
import * as tenancy from "../tenancy"
import * as context from "../context"
@@ -23,7 +17,9 @@ const APP_PREFIX = DocumentType.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/"
const BUILDER_PREVIEW_PATH = "/app/preview"
-const BUILDER_REFERER_PREFIX = "/builder/app/"
+const BUILDER_PREFIX = "/builder"
+const BUILDER_APP_PREFIX = `${BUILDER_PREFIX}/app/`
+const PUBLIC_API_PREFIX = "/api/public/v"
function confirmAppId(possibleAppId: string | undefined) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
@@ -69,6 +65,18 @@ export function isServingApp(ctx: Ctx) {
return false
}
+export function isServingBuilder(ctx: Ctx): boolean {
+ return ctx.path.startsWith(BUILDER_APP_PREFIX)
+}
+
+export function isServingBuilderPreview(ctx: Ctx): boolean {
+ return ctx.path.startsWith(BUILDER_PREVIEW_PATH)
+}
+
+export function isPublicApiRequest(ctx: Ctx): boolean {
+ return ctx.path.startsWith(PUBLIC_API_PREFIX)
+}
+
/**
* Given a request tries to find the appId, which can be located in various places
* @param {object} ctx The main request body to look through.
@@ -110,7 +118,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
// make sure this is performed after prod app url resolution, in case the
// referer header is present from a builder redirect
const referer = ctx.request.headers.referer
- if (!appId && referer?.includes(BUILDER_REFERER_PREFIX)) {
+ if (!appId && referer?.includes(BUILDER_APP_PREFIX)) {
const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
appId = confirmAppId(refererId)
}
diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte
index 2c89a538a3..ad39136142 100644
--- a/packages/bbui/src/Form/Core/DatePicker.svelte
+++ b/packages/bbui/src/Form/Core/DatePicker.svelte
@@ -18,10 +18,14 @@
export let ignoreTimezones = false
export let time24hr = false
export let range = false
+ export let flatpickr
+ export let useKeyboardShortcuts = true
+
const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper`
+
let open = false
- let flatpickr, flatpickrOptions
+ let flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function
@@ -59,6 +63,8 @@
dispatch("change", timestamp.toISOString())
}
},
+ onOpen: () => dispatch("open"),
+ onClose: () => dispatch("close"),
}
$: redrawOptions = {
@@ -113,12 +119,16 @@
const onOpen = () => {
open = true
- document.addEventListener("keyup", clearDateOnBackspace)
+ if (useKeyboardShortcuts) {
+ document.addEventListener("keyup", clearDateOnBackspace)
+ }
}
const onClose = () => {
open = false
- document.removeEventListener("keyup", clearDateOnBackspace)
+ if (useKeyboardShortcuts) {
+ document.removeEventListener("keyup", clearDateOnBackspace)
+ }
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.
diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte
index 70cb56c77d..35c9c6ad6d 100644
--- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte
+++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte
@@ -61,11 +61,63 @@
$: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
+ /**
+ * TODO - Remove after November 2023
+ * *******************************
+ * Code added to provide backwards compatibility between Values 1,2,3,4,5
+ * and the new JSON body.
+ */
+ let deprecatedSchemaProperties
+ $: {
+ if (block?.stepId === "integromat" || block?.stepId === "zapier") {
+ deprecatedSchemaProperties = schemaProperties.filter(
+ prop => !prop[0].startsWith("value")
+ )
+ if (!deprecatedSchemaProperties.map(entry => entry[0]).includes("body")) {
+ deprecatedSchemaProperties.push([
+ "body",
+ {
+ title: "Payload",
+ type: "json",
+ },
+ ])
+ }
+ } else {
+ deprecatedSchemaProperties = schemaProperties
+ }
+ }
+ /****************************************************/
+
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
+
+ /**
+ * TODO - Remove after November 2023
+ * *******************************
+ * Code added to provide backwards compatibility between Values 1,2,3,4,5
+ * and the new JSON body.
+ */
+ if (
+ (block?.stepId === "integromat" || block?.stepId === "zapier") &&
+ !newInputData?.body?.value
+ ) {
+ let deprecatedValues = {
+ ...newInputData,
+ }
+ delete deprecatedValues.url
+ delete deprecatedValues.body
+ newInputData = {
+ url: newInputData.url,
+ body: {
+ value: JSON.stringify(deprecatedValues),
+ },
+ }
+ }
+ /**********************************/
+
inputData = newInputData
setDefaultEnumValues()
}
@@ -239,7 +291,7 @@
- {#each schemaProperties as [key, value]}
+ {#each deprecatedSchemaProperties as [key, value]}
{#if key !== "fields"}
(value.pretty ? value.pretty[idx] : x)}
/>
+ {:else if value.type === "json"}
+ {
+ /**
+ * TODO - Remove after November 2023
+ * *******************************
+ * Code added to provide backwards compatibility between Values 1,2,3,4,5
+ * and the new JSON body.
+ */
+ delete inputData.value1
+ delete inputData.value2
+ delete inputData.value3
+ delete inputData.value4
+ delete inputData.value5
+ /***********************/
+ onChange(e, key)
+ }}
+ />
{:else if value.customType === "column"}
onChange(e, key)}
diff --git a/packages/builder/src/components/backend/DataTable/Table.svelte b/packages/builder/src/components/backend/DataTable/Table.svelte
index 4df6e9a306..460a02a9b1 100644
--- a/packages/builder/src/components/backend/DataTable/Table.svelte
+++ b/packages/builder/src/components/backend/DataTable/Table.svelte
@@ -22,6 +22,7 @@
export let rowCount
export let disableSorting = false
export let customPlaceholder = false
+ export let allowClickRows
const dispatch = createEventDispatcher()
@@ -110,6 +111,7 @@
{disableSorting}
{customPlaceholder}
showAutoColumns={!hideAutocolumns}
+ {allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
>
diff --git a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
index 94b9020203..f1337e88d4 100644
--- a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
+++ b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
@@ -6,7 +6,8 @@
import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify"
- const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
+ const alphabetical = (a, b) =>
+ a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId
diff --git a/packages/builder/src/components/common/NavItem.svelte b/packages/builder/src/components/common/NavItem.svelte
index 1fd43e2c70..73a8d3d647 100644
--- a/packages/builder/src/components/common/NavItem.svelte
+++ b/packages/builder/src/components/common/NavItem.svelte
@@ -17,6 +17,7 @@
export let highlighted = false
export let rightAlignIcon = false
export let id
+ export let showTooltip = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@@ -84,7 +85,7 @@
{/if}
-
{text}
+
{text}
{#if withActions}
diff --git a/packages/builder/src/components/integration/QueryEditor.svelte b/packages/builder/src/components/integration/QueryEditor.svelte
index 3b4f1af96e..f543438b9c 100644
--- a/packages/builder/src/components/integration/QueryEditor.svelte
+++ b/packages/builder/src/components/integration/QueryEditor.svelte
@@ -18,6 +18,7 @@
export let tab = true
export let mode
export let editorHeight = 500
+ export let editorWidth = 640
// export let parameters = []
let width
@@ -169,7 +170,9 @@
{#if label}
{label}
{/if}
-
+
@@ -183,6 +186,7 @@
}
div :global(.CodeMirror) {
+ width: var(--code-mirror-width) !important;
height: var(--code-mirror-height) !important;
border-radius: var(--border-radius-s);
font-family: var(--font-mono);
diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
index 9a6d9ea1d3..65963b828d 100644
--- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
@@ -28,13 +28,16 @@
let inviting = false
let searchFocus = false
+ // Initially filter entities without app access
+ // Show all when false
+ let filterByAppAccess = true
+
let appInvites = []
let filteredInvites = []
let filteredUsers = []
let filteredGroups = []
let selectedGroup
let userOnboardResponse = null
-
let userLimitReachedModal
$: queryIsEmail = emailValidator(query) === true
@@ -52,15 +55,32 @@
}
const filterInvites = async query => {
- appInvites = await getInvites()
- if (!query || query == "") {
- filteredInvites = appInvites
+ if (!prodAppId) {
return
}
- filteredInvites = appInvites.filter(invite => invite.email.includes(query))
+
+ appInvites = await getInvites()
+
+ //On Focus behaviour
+ if (!filterByAppAccess && !query) {
+ filteredInvites =
+ appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
+ return
+ }
+
+ filteredInvites = appInvites.filter(invite => {
+ const inviteInfo = invite.info?.apps
+ if (!query && inviteInfo && prodAppId) {
+ return Object.keys(inviteInfo).includes(prodAppId)
+ }
+ return invite.email.includes(query)
+ })
}
- $: filterInvites(query)
+ $: filterByAppAccess, prodAppId, filterInvites(query)
+ $: if (searchFocus === true) {
+ filterByAppAccess = false
+ }
const usersFetch = fetchData({
API,
@@ -79,9 +99,9 @@
}
await usersFetch.update({
query: {
- appId: query ? null : prodAppId,
+ appId: query || !filterByAppAccess ? null : prodAppId,
email: query,
- paginated: query ? null : false,
+ paginated: query || !filterByAppAccess ? null : false,
},
})
await usersFetch.refresh()
@@ -107,7 +127,12 @@
}
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
- $: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
+ $: debouncedUpdateFetch(
+ query,
+ $store.builderSidePanel,
+ loaded,
+ filterByAppAccess
+ )
const updateAppUser = async (user, role) => {
if (!prodAppId) {
@@ -182,9 +207,10 @@
}
const searchGroups = (userGroups, query) => {
- let filterGroups = query?.length
- ? userGroups
- : getAppGroups(userGroups, prodAppId)
+ let filterGroups =
+ query?.length || !filterByAppAccess
+ ? userGroups
+ : getAppGroups(userGroups, prodAppId)
return filterGroups
.filter(group => {
if (!query?.length) {
@@ -214,7 +240,7 @@
}
// Adds the 'role' attribute and sets it to the current app.
- $: enrichedGroups = getEnrichedGroups($groups)
+ $: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
$: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers]
@@ -226,7 +252,7 @@
specific roles for the app.
*/
const buildGroupUsers = (userGroups, filteredUsers) => {
- if (query) {
+ if (query || !filterByAppAccess) {
return []
}
// Must exclude users who have explicit privileges
@@ -321,12 +347,12 @@
[prodAppId]: role,
},
})
- await filterInvites()
+ await filterInvites(query)
}
const onUninviteAppUser = async invite => {
await uninviteAppUser(invite)
- await filterInvites()
+ await filterInvites(query)
}
// Purge only the app from the invite or recind the invite if only 1 app remains?
@@ -351,7 +377,6 @@
onMount(() => {
rendered = true
- searchFocus = true
})
function handleKeyDown(evt) {
@@ -417,7 +442,6 @@
autocomplete="off"
disabled={inviting}
value={query}
- autofocus
on:input={e => {
query = e.target.value.trim()
}}
@@ -428,16 +452,20 @@
{
+ if (!filterByAppAccess) {
+ filterByAppAccess = true
+ }
if (!query) {
return
}
query = null
userOnboardResponse = null
+ filterByAppAccess = true
}}
>
-
+
@@ -555,7 +583,7 @@
{#if filteredUsers?.length}
-
$goto(`../users/${e.detail._id}`)}
>
- This user group doesn't have any users
+ {emailSearch
+ ? `No users found matching the email "${emailSearch}"`
+ : "This user group doesn't have any users"}
@@ -98,7 +108,7 @@
.header {
display: flex;
flex-direction: row;
- justify-content: flex-start;
+ justify-content: space-between;
align-items: center;
gap: var(--spacing-l);
}
@@ -109,4 +119,15 @@
width: 100%;
text-align: center;
}
+
+ .controls-right {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+ gap: var(--spacing-xl);
+ }
+ .controls-right :global(.spectrum-Search) {
+ width: 200px;
+ }
diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte
index 8006ae0294..3c4ed41839 100644
--- a/packages/builder/src/pages/builder/portal/users/users/index.svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte
@@ -88,6 +88,16 @@
},
}
+ const getPendingSchema = tblSchema => {
+ if (!tblSchema) {
+ return {}
+ }
+ let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
+ pendingSchema.email.displayName = "Pending Invites"
+ return pendingSchema
+ }
+
+ $: pendingSchema = getPendingSchema(schema)
$: userData = []
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: {
@@ -110,6 +120,24 @@
}
})
}
+ let invitesLoaded = false
+ let pendingInvites = []
+ let parsedInvites = []
+
+ const invitesToSchema = invites => {
+ return invites.map(invite => {
+ const { admin, builder, userGroups, apps } = invite.info
+
+ return {
+ email: invite.email,
+ builder,
+ admin,
+ userGroups: userGroups,
+ apps: apps ? [...new Set(Object.keys(apps))] : undefined,
+ }
+ })
+ }
+ $: parsedInvites = invitesToSchema(pendingInvites)
const updateFetch = email => {
fetch.update({
@@ -144,6 +172,7 @@
}))
try {
inviteUsersResponse = await users.invite(payload)
+ pendingInvites = await users.getInvites()
inviteConfirmationModal.show()
} catch (error) {
notifications.error("Error inviting user")
@@ -232,6 +261,9 @@
try {
await groups.actions.init()
groupsLoaded = true
+
+ pendingInvites = await users.getInvites()
+ invitesLoaded = true
} catch (error) {
notifications.error("Error fetching user group data")
}
@@ -324,6 +356,15 @@
goToNextPage={fetch.nextPage}
/>
+
diff --git a/packages/frontend-core/src/api/groups.js b/packages/frontend-core/src/api/groups.js
index cbc5bfd72a..72cbe30718 100644
--- a/packages/frontend-core/src/api/groups.js
+++ b/packages/frontend-core/src/api/groups.js
@@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => {
/**
* Gets a group users by the group id
*/
- getGroupUsers: async ({ id, bookmark }) => {
+ getGroupUsers: async ({ id, bookmark, emailSearch }) => {
let url = `/api/global/groups/${id}/users?`
if (bookmark) {
- url += `bookmark=${bookmark}`
+ url += `bookmark=${bookmark}&`
+ }
+ if (emailSearch) {
+ url += `emailSearch=${emailSearch}&`
}
return await API.get({
diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
index 0aa0cd54f4..f39b820632 100644
--- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
@@ -32,6 +32,7 @@
$: readonly =
column.schema.autocolumn ||
column.schema.disabled ||
+ column.schema.type === "formula" ||
(!$config.allowEditRows && row._id)
// Register this cell API if the row is focused
diff --git a/packages/frontend-core/src/components/grid/cells/DateCell.svelte b/packages/frontend-core/src/components/grid/cells/DateCell.svelte
index 0112bcda15..f5b1acb1c8 100644
--- a/packages/frontend-core/src/components/grid/cells/DateCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/DateCell.svelte
@@ -1,12 +1,17 @@
@@ -42,6 +67,10 @@
{timeOnly}
time24hr
ignoreTimezones={schema.ignoreTimezones}
+ bind:flatpickr
+ on:open={() => (isOpen = true)}
+ on:close={() => (isOpen = false)}
+ useKeyboardShortcuts={false}
/>
{/if}
diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte
index 89e4d3503b..15ca08dbdf 100644
--- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte
+++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte
@@ -1,6 +1,13 @@
{#if $menu.visible}
@@ -58,22 +72,38 @@
dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close}
>
Edit row in modal
+ copyToClipboard($focusedRow?._id)}
+ on:click={menu.actions.close}
+ >
+ Copy row _id
+
+ copyToClipboard($focusedRow?._rev)}
+ on:click={menu.actions.close}
+ >
+ Copy row _rev
+
Duplicate row
Delete row
diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js
index 694b66629b..b6dc8c05d0 100644
--- a/packages/frontend-core/src/components/grid/stores/rows.js
+++ b/packages/frontend-core/src/components/grid/stores/rows.js
@@ -338,15 +338,11 @@ export const deriveStores = context => {
...state,
[rowId]: true,
}))
- const newRow = { ...row, ...get(rowChangeCache)[rowId] }
- const saved = await API.saveRow(newRow)
+ const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
// Update state after a successful change
rows.update(state => {
- state[index] = {
- ...newRow,
- _rev: saved._rev,
- }
+ state[index] = saved
return state.slice()
})
rowChangeCache.update(state => {
diff --git a/packages/frontend-core/src/fetch/GroupUserFetch.js b/packages/frontend-core/src/fetch/GroupUserFetch.js
index b0ca9a5388..bd2cf264c5 100644
--- a/packages/frontend-core/src/fetch/GroupUserFetch.js
+++ b/packages/frontend-core/src/fetch/GroupUserFetch.js
@@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch {
try {
const res = await this.API.getGroupUsers({
id: query.groupId,
+ emailSearch: query.emailSearch,
bookmark: cursor,
})
diff --git a/packages/server/src/automations/steps/make.ts b/packages/server/src/automations/steps/make.ts
index d75b7f42e0..dc4c75d27b 100644
--- a/packages/server/src/automations/steps/make.ts
+++ b/packages/server/src/automations/steps/make.ts
@@ -27,6 +27,10 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING,
title: "Webhook URL",
},
+ body: {
+ type: AutomationIOType.JSON,
+ title: "Payload",
+ },
value1: {
type: AutomationIOType.STRING,
title: "Input Value 1",
@@ -71,7 +75,19 @@ export const definition: AutomationStepSchema = {
}
export async function run({ inputs }: AutomationStepInput) {
- const { url, value1, value2, value3, value4, value5 } = inputs
+ //TODO - Remove deprecated values 1,2,3,4,5 after November 2023
+ const { url, value1, value2, value3, value4, value5, body } = inputs
+
+ let payload = {}
+ try {
+ payload = body?.value ? JSON.parse(body?.value) : {}
+ } catch (err) {
+ return {
+ httpStatus: 400,
+ response: "Invalid payload JSON",
+ success: false,
+ }
+ }
if (!url?.trim()?.length) {
return {
@@ -90,6 +106,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3,
value4,
value5,
+ ...payload,
}),
headers: {
"Content-Type": "application/json",
diff --git a/packages/server/src/automations/steps/zapier.ts b/packages/server/src/automations/steps/zapier.ts
index dcb3947f1d..85f056e6dd 100644
--- a/packages/server/src/automations/steps/zapier.ts
+++ b/packages/server/src/automations/steps/zapier.ts
@@ -25,6 +25,10 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING,
title: "Webhook URL",
},
+ body: {
+ type: AutomationIOType.JSON,
+ title: "Payload",
+ },
value1: {
type: AutomationIOType.STRING,
title: "Payload Value 1",
@@ -64,7 +68,19 @@ export const definition: AutomationStepSchema = {
}
export async function run({ inputs }: AutomationStepInput) {
- const { url, value1, value2, value3, value4, value5 } = inputs
+ //TODO - Remove deprecated values 1,2,3,4,5 after November 2023
+ const { url, value1, value2, value3, value4, value5, body } = inputs
+
+ let payload = {}
+ try {
+ payload = body?.value ? JSON.parse(body?.value) : {}
+ } catch (err) {
+ return {
+ httpStatus: 400,
+ response: "Invalid payload JSON",
+ success: false,
+ }
+ }
if (!url?.trim()?.length) {
return {
@@ -86,6 +102,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3,
value4,
value5,
+ ...payload,
}),
headers: {
"Content-Type": "application/json",
diff --git a/packages/server/src/automations/tests/make.spec.ts b/packages/server/src/automations/tests/make.spec.ts
new file mode 100644
index 0000000000..ddf7dc3f44
--- /dev/null
+++ b/packages/server/src/automations/tests/make.spec.ts
@@ -0,0 +1,54 @@
+import { getConfig, afterAll, runStep, actions } from "./utilities"
+
+describe("test the outgoing webhook action", () => {
+ let config = getConfig()
+
+ beforeAll(async () => {
+ await config.init()
+ })
+
+ afterAll()
+
+ it("should be able to run the action", async () => {
+ const res = await runStep(actions.integromat.stepId, {
+ value1: "test",
+ url: "http://www.test.com",
+ })
+ expect(res.response.url).toEqual("http://www.test.com")
+ expect(res.response.method).toEqual("post")
+ expect(res.success).toEqual(true)
+ })
+
+ it("should add the payload props when a JSON string is provided", async () => {
+ const payload = `{"value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}`
+ const res = await runStep(actions.integromat.stepId, {
+ value1: "ONE",
+ value2: "TWO",
+ value3: "THREE",
+ value4: "FOUR",
+ value5: "FIVE",
+ body: {
+ value: payload,
+ },
+ url: "http://www.test.com",
+ })
+ expect(res.response.url).toEqual("http://www.test.com")
+ expect(res.response.method).toEqual("post")
+ expect(res.response.body).toEqual(payload)
+ expect(res.success).toEqual(true)
+ })
+
+ it("should return a 400 if the JSON payload string is malformed", async () => {
+ const payload = `{ value1 1 }`
+ const res = await runStep(actions.integromat.stepId, {
+ value1: "ONE",
+ body: {
+ value: payload,
+ },
+ url: "http://www.test.com",
+ })
+ expect(res.httpStatus).toEqual(400)
+ expect(res.response).toEqual("Invalid payload JSON")
+ expect(res.success).toEqual(false)
+ })
+})
diff --git a/packages/server/src/automations/tests/zapier.spec.js b/packages/server/src/automations/tests/zapier.spec.js
deleted file mode 100644
index 9d94a4c17f..0000000000
--- a/packages/server/src/automations/tests/zapier.spec.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const setup = require("./utilities")
-const fetch = require("node-fetch")
-
-jest.mock("node-fetch")
-
-describe("test the outgoing webhook action", () => {
- let inputs
- let config = setup.getConfig()
-
- beforeAll(async () => {
- await config.init()
- inputs = {
- value1: "test",
- url: "http://www.test.com",
- }
- })
-
- afterAll(setup.afterAll)
-
- it("should be able to run the action", async () => {
- const res = await setup.runStep(setup.actions.zapier.stepId, inputs)
- expect(res.response.url).toEqual("http://www.test.com")
- expect(res.response.method).toEqual("post")
- expect(res.success).toEqual(true)
- })
-
-})
diff --git a/packages/server/src/automations/tests/zapier.spec.ts b/packages/server/src/automations/tests/zapier.spec.ts
new file mode 100644
index 0000000000..a86185ccad
--- /dev/null
+++ b/packages/server/src/automations/tests/zapier.spec.ts
@@ -0,0 +1,56 @@
+import { getConfig, afterAll, runStep, actions } from "./utilities"
+
+describe("test the outgoing webhook action", () => {
+ let config = getConfig()
+
+ beforeAll(async () => {
+ await config.init()
+ })
+
+ afterAll()
+
+ it("should be able to run the action", async () => {
+ const res = await runStep(actions.zapier.stepId, {
+ value1: "test",
+ url: "http://www.test.com",
+ })
+ expect(res.response.url).toEqual("http://www.test.com")
+ expect(res.response.method).toEqual("post")
+ expect(res.success).toEqual(true)
+ })
+
+ it("should add the payload props when a JSON string is provided", async () => {
+ const payload = `{ "value1": 1, "value2": 2, "value3": 3, "value4": 4, "value5": 5, "name": "Adam", "age": 9 }`
+ const res = await runStep(actions.zapier.stepId, {
+ value1: "ONE",
+ value2: "TWO",
+ value3: "THREE",
+ value4: "FOUR",
+ value5: "FIVE",
+ body: {
+ value: payload,
+ },
+ url: "http://www.test.com",
+ })
+ expect(res.response.url).toEqual("http://www.test.com")
+ expect(res.response.method).toEqual("post")
+ expect(res.response.body).toEqual(
+ `{"platform":"budibase","value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}`
+ )
+ expect(res.success).toEqual(true)
+ })
+
+ it("should return a 400 if the JSON payload string is malformed", async () => {
+ const payload = `{ value1 1 }`
+ const res = await runStep(actions.zapier.stepId, {
+ value1: "ONE",
+ body: {
+ value: payload,
+ },
+ url: "http://www.test.com",
+ })
+ expect(res.httpStatus).toEqual(400)
+ expect(res.response).toEqual("Invalid payload JSON")
+ expect(res.success).toEqual(false)
+ })
+})
diff --git a/packages/types/src/documents/app/automation.ts b/packages/types/src/documents/app/automation.ts
index 97aa3ebf62..946e852a7b 100644
--- a/packages/types/src/documents/app/automation.ts
+++ b/packages/types/src/documents/app/automation.ts
@@ -7,6 +7,7 @@ export enum AutomationIOType {
BOOLEAN = "boolean",
NUMBER = "number",
ARRAY = "array",
+ JSON = "json",
}
export enum AutomationCustomIOType {
diff --git a/packages/worker/src/api/routes/global/tests/groups.spec.ts b/packages/worker/src/api/routes/global/tests/groups.spec.ts
index 5b7fb9db9e..afeaae952c 100644
--- a/packages/worker/src/api/routes/global/tests/groups.spec.ts
+++ b/packages/worker/src/api/routes/global/tests/groups.spec.ts
@@ -1,5 +1,9 @@
import { events } from "@budibase/backend-core"
+import { generator } from "@budibase/backend-core/tests"
import { structures, TestConfiguration, mocks } from "../../../../tests"
+import { UserGroup } from "@budibase/types"
+
+mocks.licenses.useGroups()
describe("/api/global/groups", () => {
const config = new TestConfiguration()
@@ -113,4 +117,118 @@ describe("/api/global/groups", () => {
})
})
})
+
+ describe("find users", () => {
+ describe("without users", () => {
+ let group: UserGroup
+ beforeAll(async () => {
+ group = structures.groups.UserGroup()
+ await config.api.groups.saveGroup(group)
+ })
+
+ it("should return empty", async () => {
+ const result = await config.api.groups.searchUsers(group._id!)
+ expect(result.body).toEqual({
+ users: [],
+ bookmark: undefined,
+ hasNextPage: false,
+ })
+ })
+ })
+
+ describe("existing users", () => {
+ let groupId: string
+ let users: { _id: string; email: string }[] = []
+
+ beforeAll(async () => {
+ groupId = (
+ await config.api.groups.saveGroup(structures.groups.UserGroup())
+ ).body._id
+
+ await Promise.all(
+ Array.from({ length: 30 }).map(async (_, i) => {
+ const email = `user${i}@${generator.domain()}`
+ const user = await config.api.users.saveUser({
+ ...structures.users.user(),
+ email,
+ })
+ users.push({ _id: user.body._id, email })
+ })
+ )
+ users = users.sort((a, b) => a._id.localeCompare(b._id))
+ await config.api.groups.updateGroupUsers(groupId, {
+ add: users.map(u => u._id),
+ remove: [],
+ })
+ })
+
+ describe("pagination", () => {
+ it("should return first page", async () => {
+ const result = await config.api.groups.searchUsers(groupId)
+ expect(result.body).toEqual({
+ users: users.slice(0, 10),
+ bookmark: users[10]._id,
+ hasNextPage: true,
+ })
+ })
+
+ it("given a bookmark, should return skip items", async () => {
+ const result = await config.api.groups.searchUsers(groupId, {
+ bookmark: users[7]._id,
+ })
+ expect(result.body).toEqual({
+ users: users.slice(7, 17),
+ bookmark: users[17]._id,
+ hasNextPage: true,
+ })
+ })
+
+ it("bookmarking the last page, should return last page info", async () => {
+ const result = await config.api.groups.searchUsers(groupId, {
+ bookmark: users[20]._id,
+ })
+ expect(result.body).toEqual({
+ users: users.slice(20),
+ bookmark: undefined,
+ hasNextPage: false,
+ })
+ })
+ })
+
+ describe("search by email", () => {
+ it('should be able to search "starting" by email', async () => {
+ const result = await config.api.groups.searchUsers(groupId, {
+ emailSearch: `user1`,
+ })
+
+ const matchedUsers = users
+ .filter(u => u.email.startsWith("user1"))
+ .sort((a, b) => a.email.localeCompare(b.email))
+
+ expect(result.body).toEqual({
+ users: matchedUsers.slice(0, 10),
+ bookmark: matchedUsers[10].email,
+ hasNextPage: true,
+ })
+ })
+
+ it("should be able to bookmark when searching by email", async () => {
+ const matchedUsers = users
+ .filter(u => u.email.startsWith("user1"))
+ .sort((a, b) => a.email.localeCompare(b.email))
+
+ const result = await config.api.groups.searchUsers(groupId, {
+ emailSearch: `user1`,
+ bookmark: matchedUsers[4].email,
+ })
+
+ expect(result.body).toEqual({
+ users: matchedUsers.slice(4),
+ bookmark: undefined,
+ hasNextPage: false,
+ })
+ })
+ })
+ })
+ })
})
diff --git a/packages/worker/src/tests/api/groups.ts b/packages/worker/src/tests/api/groups.ts
index 5524d2a811..91f7c92c7d 100644
--- a/packages/worker/src/tests/api/groups.ts
+++ b/packages/worker/src/tests/api/groups.ts
@@ -23,4 +23,34 @@ export class GroupsAPI extends TestAPI {
.expect("Content-Type", /json/)
.expect(200)
}
+
+ searchUsers = (
+ id: string,
+ params?: { bookmark?: string; emailSearch?: string }
+ ) => {
+ let url = `/api/global/groups/${id}/users?`
+ if (params?.bookmark) {
+ url += `bookmark=${params.bookmark}&`
+ }
+ if (params?.emailSearch) {
+ url += `emailSearch=${params.emailSearch}&`
+ }
+ return this.request
+ .get(url)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+ }
+
+ updateGroupUsers = (
+ id: string,
+ body: { add: string[]; remove: string[] }
+ ) => {
+ return this.request
+ .post(`/api/global/groups/${id}/users`)
+ .send(body)
+ .set(this.config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+ }
}
diff --git a/qa-core/src/account-api/api/apis/LicenseAPI.ts b/qa-core/src/account-api/api/apis/LicenseAPI.ts
index f726eb5682..e0601fe127 100644
--- a/qa-core/src/account-api/api/apis/LicenseAPI.ts
+++ b/qa-core/src/account-api/api/apis/LicenseAPI.ts
@@ -20,6 +20,12 @@ export default class LicenseAPI {
internal: true,
}
)
+
+ if (response.status !== 200) {
+ throw new Error(
+ `Could not update license for accountId=${accountId}: ${response.status}`
+ )
+ }
return [response, json]
}
}
diff --git a/qa-core/src/internal-api/tests/users/appSpecificRoles.spec.ts b/qa-core/src/internal-api/tests/users/appSpecificRoles.spec.ts
index c3c9395b78..325735b3fc 100644
--- a/qa-core/src/internal-api/tests/users/appSpecificRoles.spec.ts
+++ b/qa-core/src/internal-api/tests/users/appSpecificRoles.spec.ts
@@ -11,9 +11,6 @@ describe("Internal API - App Specific Roles & Permissions", () => {
await config.beforeAll()
})
- afterAll(async () => {
- await config.afterAll()
- })
afterAll(async () => {
await config.afterAll()
})