Merge remote-tracking branch 'origin/develop' into feature/sync-automations

This commit is contained in:
Peter Clement 2023-05-15 15:19:44 +01:00
commit 007f5ca8a9
30 changed files with 693 additions and 91 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.6.8-alpha.7", "version": "2.6.8-alpha.14",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/backend-core", "packages/backend-core",

View File

@ -21,7 +21,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user", USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger", APP_BACKUP_BY_TRIGGER = "by_trigger",
} }

View File

@ -5,6 +5,7 @@ import * as db from "../../db"
import { Header } from "../../constants" import { Header } from "../../constants"
import { newid } from "../../utils" import { newid } from "../../utils"
import env from "../../environment" import env from "../../environment"
import { BBContext } from "@budibase/types"
describe("utils", () => { describe("utils", () => {
const config = new DBTestConfiguration() const config = new DBTestConfiguration()
@ -106,4 +107,85 @@ describe("utils", () => {
expect(actual).toBe(undefined) 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)
})
})
}) })

View File

@ -1,11 +1,5 @@
import { getAllApps, queryGlobalView } from "../db" import { getAllApps } from "../db"
import { import { Header, MAX_VALID_DATE, DocumentType, SEPARATOR } from "../constants"
Header,
MAX_VALID_DATE,
DocumentType,
SEPARATOR,
ViewName,
} from "../constants"
import env from "../environment" import env from "../environment"
import * as tenancy from "../tenancy" import * as tenancy from "../tenancy"
import * as context from "../context" import * as context from "../context"
@ -23,7 +17,9 @@ const APP_PREFIX = DocumentType.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/" const PROD_APP_PREFIX = "/app/"
const BUILDER_PREVIEW_PATH = "/app/preview" 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) { function confirmAppId(possibleAppId: string | undefined) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX) return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
@ -69,6 +65,18 @@ export function isServingApp(ctx: Ctx) {
return false 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 * 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. * @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 // make sure this is performed after prod app url resolution, in case the
// referer header is present from a builder redirect // referer header is present from a builder redirect
const referer = ctx.request.headers.referer 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) const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
appId = confirmAppId(refererId) appId = confirmAppId(refererId)
} }

View File

@ -18,10 +18,14 @@
export let ignoreTimezones = false export let ignoreTimezones = false
export let time24hr = false export let time24hr = false
export let range = false export let range = false
export let flatpickr
export let useKeyboardShortcuts = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper` const flatpickrId = `${uuid()}-wrapper`
let open = false let open = false
let flatpickr, flatpickrOptions let flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to // Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function // flatpickr internal code. Making sure that "destroy" is a valid function
@ -59,6 +63,8 @@
dispatch("change", timestamp.toISOString()) dispatch("change", timestamp.toISOString())
} }
}, },
onOpen: () => dispatch("open"),
onClose: () => dispatch("close"),
} }
$: redrawOptions = { $: redrawOptions = {
@ -113,12 +119,16 @@
const onOpen = () => { const onOpen = () => {
open = true open = true
document.addEventListener("keyup", clearDateOnBackspace) if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
} }
const onClose = () => { const onClose = () => {
open = false open = false
document.removeEventListener("keyup", clearDateOnBackspace) if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
// Manually blur all input fields since flatpickr creates a second // Manually blur all input fields since flatpickr creates a second
// duplicate input field. // duplicate input field.

View File

@ -61,11 +61,63 @@
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW $: 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) => { const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) { if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs) 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 inputData = newInputData
setDefaultEnumValues() setDefaultEnumValues()
} }
@ -239,7 +291,7 @@
</script> </script>
<div class="fields"> <div class="fields">
{#each schemaProperties as [key, value]} {#each deprecatedSchemaProperties as [key, value]}
<div class="block-field"> <div class="block-field">
{#if key !== "fields"} {#if key !== "fields"}
<Label <Label
@ -256,6 +308,28 @@
options={value.enum} options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)} getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/> />
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* 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"} {:else if value.customType === "column"}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}

View File

@ -22,6 +22,7 @@
export let rowCount export let rowCount
export let disableSorting = false export let disableSorting = false
export let customPlaceholder = false export let customPlaceholder = false
export let allowClickRows
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -110,6 +111,7 @@
{disableSorting} {disableSorting}
{customPlaceholder} {customPlaceholder}
showAutoColumns={!hideAutocolumns} showAutoColumns={!hideAutocolumns}
{allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)} on:clickrelationship={e => selectRelationship(e.detail)}
on:sort on:sort
> >

View File

@ -6,7 +6,8 @@
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify" 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 export let sourceId

View File

@ -17,6 +17,7 @@
export let highlighted = false export let highlighted = false
export let rightAlignIcon = false export let rightAlignIcon = false
export let id export let id
export let showTooltip = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -84,7 +85,7 @@
<Icon color={iconColor} size="S" name={icon} /> <Icon color={iconColor} size="S" name={icon} />
</div> </div>
{/if} {/if}
<div class="text">{text}</div> <div class="text" title={showTooltip ? text : null}>{text}</div>
{#if withActions} {#if withActions}
<div class="actions"> <div class="actions">
<slot /> <slot />

View File

@ -18,6 +18,7 @@
export let tab = true export let tab = true
export let mode export let mode
export let editorHeight = 500 export let editorHeight = 500
export let editorWidth = 640
// export let parameters = [] // export let parameters = []
let width let width
@ -169,7 +170,9 @@
{#if label} {#if label}
<Label small>{label}</Label> <Label small>{label}</Label>
{/if} {/if}
<div style={`--code-mirror-height: ${editorHeight}px`}> <div
style={`--code-mirror-height: ${editorHeight}px; --code-mirror-width: ${editorWidth}px;`}
>
<textarea tabindex="0" bind:this={refs.editor} readonly {value} /> <textarea tabindex="0" bind:this={refs.editor} readonly {value} />
</div> </div>
@ -183,6 +186,7 @@
} }
div :global(.CodeMirror) { div :global(.CodeMirror) {
width: var(--code-mirror-width) !important;
height: var(--code-mirror-height) !important; height: var(--code-mirror-height) !important;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@ -28,13 +28,16 @@
let inviting = false let inviting = false
let searchFocus = false let searchFocus = false
// Initially filter entities without app access
// Show all when false
let filterByAppAccess = true
let appInvites = [] let appInvites = []
let filteredInvites = [] let filteredInvites = []
let filteredUsers = [] let filteredUsers = []
let filteredGroups = [] let filteredGroups = []
let selectedGroup let selectedGroup
let userOnboardResponse = null let userOnboardResponse = null
let userLimitReachedModal let userLimitReachedModal
$: queryIsEmail = emailValidator(query) === true $: queryIsEmail = emailValidator(query) === true
@ -52,15 +55,32 @@
} }
const filterInvites = async query => { const filterInvites = async query => {
appInvites = await getInvites() if (!prodAppId) {
if (!query || query == "") {
filteredInvites = appInvites
return 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({ const usersFetch = fetchData({
API, API,
@ -79,9 +99,9 @@
} }
await usersFetch.update({ await usersFetch.update({
query: { query: {
appId: query ? null : prodAppId, appId: query || !filterByAppAccess ? null : prodAppId,
email: query, email: query,
paginated: query ? null : false, paginated: query || !filterByAppAccess ? null : false,
}, },
}) })
await usersFetch.refresh() await usersFetch.refresh()
@ -107,7 +127,12 @@
} }
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250) const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded) $: debouncedUpdateFetch(
query,
$store.builderSidePanel,
loaded,
filterByAppAccess
)
const updateAppUser = async (user, role) => { const updateAppUser = async (user, role) => {
if (!prodAppId) { if (!prodAppId) {
@ -182,9 +207,10 @@
} }
const searchGroups = (userGroups, query) => { const searchGroups = (userGroups, query) => {
let filterGroups = query?.length let filterGroups =
? userGroups query?.length || !filterByAppAccess
: getAppGroups(userGroups, prodAppId) ? userGroups
: getAppGroups(userGroups, prodAppId)
return filterGroups return filterGroups
.filter(group => { .filter(group => {
if (!query?.length) { if (!query?.length) {
@ -214,7 +240,7 @@
} }
// Adds the 'role' attribute and sets it to the current app. // Adds the 'role' attribute and sets it to the current app.
$: enrichedGroups = getEnrichedGroups($groups) $: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
$: filteredGroups = searchGroups(enrichedGroups, query) $: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers] $: allUsers = [...filteredUsers, ...groupUsers]
@ -226,7 +252,7 @@
specific roles for the app. specific roles for the app.
*/ */
const buildGroupUsers = (userGroups, filteredUsers) => { const buildGroupUsers = (userGroups, filteredUsers) => {
if (query) { if (query || !filterByAppAccess) {
return [] return []
} }
// Must exclude users who have explicit privileges // Must exclude users who have explicit privileges
@ -321,12 +347,12 @@
[prodAppId]: role, [prodAppId]: role,
}, },
}) })
await filterInvites() await filterInvites(query)
} }
const onUninviteAppUser = async invite => { const onUninviteAppUser = async invite => {
await uninviteAppUser(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? // Purge only the app from the invite or recind the invite if only 1 app remains?
@ -351,7 +377,6 @@
onMount(() => { onMount(() => {
rendered = true rendered = true
searchFocus = true
}) })
function handleKeyDown(evt) { function handleKeyDown(evt) {
@ -417,7 +442,6 @@
autocomplete="off" autocomplete="off"
disabled={inviting} disabled={inviting}
value={query} value={query}
autofocus
on:input={e => { on:input={e => {
query = e.target.value.trim() query = e.target.value.trim()
}} }}
@ -428,16 +452,20 @@
<span <span
class="search-input-icon" class="search-input-icon"
class:searching={query} class:searching={query || !filterByAppAccess}
on:click={() => { on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) { if (!query) {
return return
} }
query = null query = null
userOnboardResponse = null userOnboardResponse = null
filterByAppAccess = true
}} }}
> >
<Icon name={query ? "Close" : "Search"} /> <Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
</span> </span>
</div> </div>
@ -555,7 +583,7 @@
{#if filteredUsers?.length} {#if filteredUsers?.length}
<div class="auth-entity-section"> <div class="auth-entity-section">
<div class="auth-entity-header "> <div class="auth-entity-header">
<div class="auth-entity-title">Users</div> <div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div> <div class="auth-entity-access-title">Access</div>
</div> </div>
@ -696,7 +724,7 @@
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
background: var(--background); background: var(--background);
border-left: var(--border-light); border-left: var(--border-light);
z-index: 3; z-index: 999;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;

View File

@ -59,6 +59,7 @@
text={screen.routing.route} text={screen.routing.route}
on:click={() => store.actions.screens.select(screen._id)} on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon rightAlignIcon
showTooltip
> >
<ScreenDropdownMenu screenId={screen._id} /> <ScreenDropdownMenu screenId={screen._id} />
<RoleIndicator slot="right" roleId={screen.routing.roleId} /> <RoleIndicator slot="right" roleId={screen.routing.roleId} />

View File

@ -1,7 +1,7 @@
<script> <script>
import EditUserPicker from "./EditUserPicker.svelte" import EditUserPicker from "./EditUserPicker.svelte"
import { Heading, Pagination, Table } from "@budibase/bbui" import { Heading, Pagination, Table, Search } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core" import { fetchData } from "@budibase/frontend-core"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { API } from "api" import { API } from "api"
@ -12,7 +12,9 @@
export let groupId export let groupId
const fetchGroupUsers = fetchData({ let emailSearch
let fetchGroupUsers
$: fetchGroupUsers = fetchData({
API, API,
datasource: { datasource: {
type: "groupUser", type: "groupUser",
@ -20,6 +22,7 @@
options: { options: {
query: { query: {
groupId, groupId,
emailSearch,
}, },
}, },
}) })
@ -59,24 +62,31 @@
</script> </script>
<div class="header"> <div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled} {#if !scimEnabled}
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} /> <EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
{:else} {:else}
<ScimBanner /> <ScimBanner />
{/if} {/if}
</div>
<div class="controls-right">
<Search bind:value={emailSearch} placeholder="Search email" />
</div>
</div>
<Table <Table
schema={userSchema} schema={userSchema}
data={$fetchGroupUsers?.rows} data={$fetchGroupUsers?.rows}
loading={$fetchGroupUsers.loading}
allowEditRows={false} allowEditRows={false}
customPlaceholder customPlaceholder
customRenderers={customUserTableRenderers} customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)} on:click={e => $goto(`../users/${e.detail._id}`)}
> >
<div class="placeholder" slot="placeholder"> <div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading> <Heading size="S"
>{emailSearch
? `No users found matching the email "${emailSearch}"`
: "This user group doesn't have any users"}</Heading
>
</div> </div>
</Table> </Table>
@ -98,7 +108,7 @@
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--spacing-l); gap: var(--spacing-l);
} }
@ -109,4 +119,15 @@
width: 100%; width: 100%;
text-align: center; 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;
}
</style> </style>

View File

@ -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 = [] $: userData = []
$: inviteUsersResponse = { successful: [], unsuccessful: [] } $: 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 => { const updateFetch = email => {
fetch.update({ fetch.update({
@ -144,6 +172,7 @@
})) }))
try { try {
inviteUsersResponse = await users.invite(payload) inviteUsersResponse = await users.invite(payload)
pendingInvites = await users.getInvites()
inviteConfirmationModal.show() inviteConfirmationModal.show()
} catch (error) { } catch (error) {
notifications.error("Error inviting user") notifications.error("Error inviting user")
@ -232,6 +261,9 @@
try { try {
await groups.actions.init() await groups.actions.init()
groupsLoaded = true groupsLoaded = true
pendingInvites = await users.getInvites()
invitesLoaded = true
} catch (error) { } catch (error) {
notifications.error("Error fetching user group data") notifications.error("Error fetching user group data")
} }
@ -324,6 +356,15 @@
goToNextPage={fetch.nextPage} goToNextPage={fetch.nextPage}
/> />
</div> </div>
<Table
schema={pendingSchema}
data={parsedInvites}
allowEditColumns={false}
allowEditRows={false}
{customRenderers}
loading={!invitesLoaded}
allowClickRows={false}
/>
</Layout> </Layout>
<Modal bind:this={createUserModal}> <Modal bind:this={createUserModal}>

View File

@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => {
/** /**
* Gets a group users by the group id * Gets a group users by the group id
*/ */
getGroupUsers: async ({ id, bookmark }) => { getGroupUsers: async ({ id, bookmark, emailSearch }) => {
let url = `/api/global/groups/${id}/users?` let url = `/api/global/groups/${id}/users?`
if (bookmark) { if (bookmark) {
url += `bookmark=${bookmark}` url += `bookmark=${bookmark}&`
}
if (emailSearch) {
url += `emailSearch=${emailSearch}&`
} }
return await API.get({ return await API.get({

View File

@ -32,6 +32,7 @@
$: readonly = $: readonly =
column.schema.autocolumn || column.schema.autocolumn ||
column.schema.disabled || column.schema.disabled ||
column.schema.type === "formula" ||
(!$config.allowEditRows && row._id) (!$config.allowEditRows && row._id)
// Register this cell API if the row is focused // Register this cell API if the row is focused

View File

@ -1,12 +1,17 @@
<script> <script>
import dayjs from "dayjs" import dayjs from "dayjs"
import { CoreDatePicker, Icon } from "@budibase/bbui" import { CoreDatePicker, Icon } from "@budibase/bbui"
import { onMount } from "svelte"
export let value export let value
export let schema export let schema
export let onChange export let onChange
export let focused = false export let focused = false
export let readonly = false export let readonly = false
export let api
let flatpickr
let isOpen
// adding the 0- will turn a string like 00:00:00 into a valid ISO // adding the 0- will turn a string like 00:00:00 into a valid ISO
// date, but will make actual ISO dates invalid // date, but will make actual ISO dates invalid
@ -19,6 +24,26 @@
? "MMM D YYYY" ? "MMM D YYYY"
: "MMM D YYYY, HH:mm" : "MMM D YYYY, HH:mm"
$: editable = focused && !readonly $: editable = focused && !readonly
// Ensure we close flatpickr when unselected
$: {
if (!focused) {
flatpickr?.close()
}
}
const onKeyDown = () => {
return isOpen
}
onMount(() => {
api = {
onKeyDown,
focus: () => flatpickr?.open(),
blur: () => flatpickr?.close(),
isActive: () => isOpen,
}
})
</script> </script>
<div class="container"> <div class="container">
@ -42,6 +67,10 @@
{timeOnly} {timeOnly}
time24hr time24hr
ignoreTimezones={schema.ignoreTimezones} ignoreTimezones={schema.ignoreTimezones}
bind:flatpickr
on:open={() => (isOpen = true)}
on:close={() => (isOpen = false)}
useKeyboardShortcuts={false}
/> />
</div> </div>
{/if} {/if}

View File

@ -1,6 +1,13 @@
<script> <script>
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui" import {
clickOutside,
Menu,
MenuItem,
Helpers,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { NewRowID } from "../lib/constants"
const { const {
focusedRow, focusedRow,
@ -14,9 +21,11 @@
clipboard, clipboard,
dispatch, dispatch,
focusedCellAPI, focusedCellAPI,
focusedRowId,
} = getContext("grid") } = getContext("grid")
$: style = makeStyle($menu) $: style = makeStyle($menu)
$: isNewRow = $focusedRowId === NewRowID
const makeStyle = menu => { const makeStyle = menu => {
return `left:${menu.left}px; top:${menu.top}px;` return `left:${menu.left}px; top:${menu.top}px;`
@ -36,6 +45,11 @@
$focusedCellId = `${newRow._id}-${column}` $focusedCellId = `${newRow._id}-${column}`
} }
} }
const copyToClipboard = async value => {
await Helpers.copyToClipboard(value)
notifications.success("Copied to clipboard")
}
</script> </script>
{#if $menu.visible} {#if $menu.visible}
@ -58,22 +72,38 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Maximize" icon="Maximize"
disabled={!$config.allowEditRows} disabled={isNewRow || !$config.allowEditRows}
on:click={() => dispatch("edit-row", $focusedRow)} on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close} on:click={menu.actions.close}
> >
Edit row in modal Edit row in modal
</MenuItem> </MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._id}
on:click={() => copyToClipboard($focusedRow?._id)}
on:click={menu.actions.close}
>
Copy row _id
</MenuItem>
<MenuItem
icon="Copy"
disabled={isNewRow || !$focusedRow?._rev}
on:click={() => copyToClipboard($focusedRow?._rev)}
on:click={menu.actions.close}
>
Copy row _rev
</MenuItem>
<MenuItem <MenuItem
icon="Duplicate" icon="Duplicate"
disabled={!$config.allowAddRows} disabled={isNewRow || !$config.allowAddRows}
on:click={duplicate} on:click={duplicate}
> >
Duplicate row Duplicate row
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Delete" icon="Delete"
disabled={!$config.allowDeleteRows} disabled={isNewRow || !$config.allowDeleteRows}
on:click={deleteRow} on:click={deleteRow}
> >
Delete row Delete row

View File

@ -338,15 +338,11 @@ export const deriveStores = context => {
...state, ...state,
[rowId]: true, [rowId]: true,
})) }))
const newRow = { ...row, ...get(rowChangeCache)[rowId] } const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
const saved = await API.saveRow(newRow)
// Update state after a successful change // Update state after a successful change
rows.update(state => { rows.update(state => {
state[index] = { state[index] = saved
...newRow,
_rev: saved._rev,
}
return state.slice() return state.slice()
}) })
rowChangeCache.update(state => { rowChangeCache.update(state => {

View File

@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch {
try { try {
const res = await this.API.getGroupUsers({ const res = await this.API.getGroupUsers({
id: query.groupId, id: query.groupId,
emailSearch: query.emailSearch,
bookmark: cursor, bookmark: cursor,
}) })

View File

@ -27,6 +27,10 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "Webhook URL", title: "Webhook URL",
}, },
body: {
type: AutomationIOType.JSON,
title: "Payload",
},
value1: { value1: {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "Input Value 1", title: "Input Value 1",
@ -71,7 +75,19 @@ export const definition: AutomationStepSchema = {
} }
export async function run({ inputs }: AutomationStepInput) { 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) { if (!url?.trim()?.length) {
return { return {
@ -90,6 +106,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3, value3,
value4, value4,
value5, value5,
...payload,
}), }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -25,6 +25,10 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "Webhook URL", title: "Webhook URL",
}, },
body: {
type: AutomationIOType.JSON,
title: "Payload",
},
value1: { value1: {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "Payload Value 1", title: "Payload Value 1",
@ -64,7 +68,19 @@ export const definition: AutomationStepSchema = {
} }
export async function run({ inputs }: AutomationStepInput) { 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) { if (!url?.trim()?.length) {
return { return {
@ -86,6 +102,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3, value3,
value4, value4,
value5, value5,
...payload,
}), }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -7,6 +7,7 @@ export enum AutomationIOType {
BOOLEAN = "boolean", BOOLEAN = "boolean",
NUMBER = "number", NUMBER = "number",
ARRAY = "array", ARRAY = "array",
JSON = "json",
} }
export enum AutomationCustomIOType { export enum AutomationCustomIOType {

View File

@ -1,5 +1,9 @@
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import { generator } from "@budibase/backend-core/tests"
import { structures, TestConfiguration, mocks } from "../../../../tests" import { structures, TestConfiguration, mocks } from "../../../../tests"
import { UserGroup } from "@budibase/types"
mocks.licenses.useGroups()
describe("/api/global/groups", () => { describe("/api/global/groups", () => {
const config = new TestConfiguration() 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,
})
})
})
})
})
}) })

View File

@ -23,4 +23,34 @@ export class GroupsAPI extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .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)
}
} }

View File

@ -20,6 +20,12 @@ export default class LicenseAPI {
internal: true, internal: true,
} }
) )
if (response.status !== 200) {
throw new Error(
`Could not update license for accountId=${accountId}: ${response.status}`
)
}
return [response, json] return [response, json]
} }
} }

View File

@ -11,9 +11,6 @@ describe("Internal API - App Specific Roles & Permissions", () => {
await config.beforeAll() await config.beforeAll()
}) })
afterAll(async () => {
await config.afterAll()
})
afterAll(async () => { afterAll(async () => {
await config.afterAll() await config.afterAll()
}) })