Merge branch 'develop' into user-limit

This commit is contained in:
jvcalderon 2023-05-12 09:02:01 +02:00
commit db8f3a59c9
34 changed files with 759 additions and 280 deletions

View File

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

View File

@ -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
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
}
const onClose = () => {
open = false
if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.

View File

@ -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 @@
</script>
<div class="fields">
{#each schemaProperties as [key, value]}
{#each deprecatedSchemaProperties as [key, value]}
<div class="block-field">
{#if key !== "fields"}
<Label
@ -256,6 +308,28 @@
options={value.enum}
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"}
<Select
on:change={e => onChange(e, key)}

View File

@ -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 small>{label}</Label>
{/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} />
</div>
@ -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);

View File

@ -1,47 +1,28 @@
<script>
import { url, goto } from "@roxi/routify"
import {
Button,
Layout,
ActionMenu,
Heading,
Icon,
Popover,
notifications,
Table,
ActionMenu,
Layout,
MenuItem,
Modal,
Table,
notifications,
} from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
import { users, apps, groups, auth, features } from "stores/portal"
import { onMount, setContext } from "svelte"
import { roles } from "stores/backend"
import { goto, url } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Breadcrumb, Breadcrumbs } from "components/portal/page"
import { roles } from "stores/backend"
import { apps, auth, features, groups } from "stores/portal"
import { onMount, setContext } from "svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import GroupIcon from "./_components/GroupIcon.svelte"
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
import GroupUsers from "./_components/GroupUsers.svelte"
export let groupId
$: userSchema = {
email: {
width: "1fr",
},
...(readonly
? {}
: {
_id: {
displayName: "",
width: "auto",
borderLeft: true,
},
}),
}
const appSchema = {
name: {
width: "2fr",
@ -50,12 +31,6 @@
width: "1fr",
},
}
const customUserTableRenderers = [
{
column: "_id",
component: RemoveUserTableRenderer,
},
]
const customAppTableRenderers = [
{
column: "name",
@ -67,20 +42,12 @@
},
]
let popoverAnchor
let popover
let searchTerm = ""
let prevSearch = undefined
let pageInfo = createPaginationStore()
let loaded = false
let editModal, deleteModal
$: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled
$: page = $pageInfo.page
$: fetchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)
$: filtered = $users.data
$: groupApps = $apps
.filter(app =>
groups.actions
@ -97,25 +64,6 @@
}
}
async function fetchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
async function deleteGroup() {
try {
await groups.actions.delete(group)
@ -130,21 +78,17 @@
try {
await groups.actions.save(group)
} catch (error) {
if (error.message) {
notifications.error(error.message)
} else {
notifications.error(`Failed to save user group`)
}
}
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
}
const removeApp = async app => {
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
}
setContext("users", {
removeUser,
})
setContext("roles", {
updateRole: () => {},
removeRole: removeApp,
@ -186,41 +130,7 @@
</div>
<Layout noPadding gap="S">
<div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<div bind:this={popoverAnchor}>
<Button disabled={readonly} on:click={popover.show()} cta
>Add user</Button
>
</div>
{:else}
<ScimBanner />
{/if}
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
bind:searchTerm
labelKey="email"
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={e => groups.actions.addUser(groupId, e.detail)}
on:deselect={e => groups.actions.removeUser(groupId, e.detail)}
/>
</Popover>
</div>
<Table
schema={userSchema}
data={group?.users}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading>
</div>
</Table>
<GroupUsers {groupId} />
</Layout>
<Layout noPadding gap="S">

View File

@ -9,15 +9,23 @@
export let group
export let saveGroup
let nameError
</script>
<ModalContent
onConfirm={() => saveGroup(group)}
onConfirm={() => {
if (!group.name?.trim()) {
nameError = "Group name cannot be empty"
return false
}
saveGroup(group)
}}
size="M"
title={group?._rev ? "Edit group" : "Create group"}
confirmText="Save"
>
<Input bind:value={group.name} label="Name" />
<Input bind:value={group.name} label="Name" error={nameError} />
<div class="modal-format">
<div class="modal-inner">
<Body size="XS">Icon</Body>

View File

@ -0,0 +1,59 @@
<script>
import { Button, Popover, notifications } from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
import { auth, groups, users } from "stores/portal"
export let groupId
export let onUsersUpdated
let popoverAnchor
let popover
let searchTerm = ""
let prevSearch = undefined
let pageInfo = createPaginationStore()
$: readonly = !$auth.isAdmin
$: page = $pageInfo.page
$: searchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)
async function searchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
</script>
<div bind:this={popoverAnchor}>
<Button disabled={readonly} on:click={popover.show()} cta>Add user</Button>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
bind:searchTerm
labelKey="email"
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={async e => {
await groups.actions.addUser(groupId, e.detail)
onUsersUpdated()
}}
on:deselect={async e => {
await groups.actions.removeUser(groupId, e.detail)
onUsersUpdated()
}}
/>
</Popover>

View File

@ -0,0 +1,112 @@
<script>
import EditUserPicker from "./EditUserPicker.svelte"
import { Heading, Pagination, Table } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { goto } from "@roxi/routify"
import { API } from "api"
import { auth, features, groups } from "stores/portal"
import { setContext } from "svelte"
import ScimBanner from "../../_components/SCIMBanner.svelte"
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
export let groupId
const fetchGroupUsers = fetchData({
API,
datasource: {
type: "groupUser",
},
options: {
query: {
groupId,
},
},
})
$: userSchema = {
email: {
width: "1fr",
},
...(readonly
? {}
: {
_id: {
displayName: "",
width: "auto",
borderLeft: true,
},
}),
}
const customUserTableRenderers = [
{
column: "_id",
component: RemoveUserTableRenderer,
},
]
$: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
fetchGroupUsers.refresh()
}
setContext("users", {
removeUser,
})
</script>
<div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
{:else}
<ScimBanner />
{/if}
</div>
<Table
schema={userSchema}
data={$fetchGroupUsers?.rows}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading>
</div>
</Table>
<div class="pagination">
<Pagination
page={$fetchGroupUsers.pageNumber + 1}
hasPrevPage={$fetchGroupUsers.loading
? false
: $fetchGroupUsers.hasPrevPage}
hasNextPage={$fetchGroupUsers.loading
? false
: $fetchGroupUsers.hasNextPage}
goToPrevPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.prevPage}
goToNextPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.nextPage}
/>
</div>
<style>
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
}
.header :global(.spectrum-Heading) {
flex: 1 1 auto;
}
.placeholder {
width: 100%;
text-align: center;
}
</style>

View File

@ -66,6 +66,8 @@
} catch (error) {
if (error.status === 400) {
notifications.error(error.message)
} else if (error.message) {
notifications.error(error.message)
} else {
notifications.error(`Failed to save group`)
}

View File

@ -28,7 +28,7 @@ export function createGroupsStore() {
// on the backend anyway
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
store.set(groups)
store.set(groups.data)
}
},

View File

@ -52,6 +52,20 @@ export const buildGroupsEndpoints = API => {
})
},
/**
* Gets a group users by the group id
*/
getGroupUsers: async ({ id, bookmark }) => {
let url = `/api/global/groups/${id}/users?`
if (bookmark) {
url += `bookmark=${bookmark}`
}
return await API.get({
url,
})
},
/**
* Adds users to a group
* @param groupId The group to update

View File

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

View File

@ -1,12 +1,17 @@
<script>
import dayjs from "dayjs"
import { CoreDatePicker, Icon } from "@budibase/bbui"
import { onMount } from "svelte"
export let value
export let schema
export let onChange
export let focused = 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
// date, but will make actual ISO dates invalid
@ -19,6 +24,26 @@
? "MMM D YYYY"
: "MMM D YYYY, HH:mm"
$: 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>
<div class="container">
@ -42,6 +67,10 @@
{timeOnly}
time24hr
ignoreTimezones={schema.ignoreTimezones}
bind:flatpickr
on:open={() => (isOpen = true)}
on:close={() => (isOpen = false)}
useKeyboardShortcuts={false}
/>
</div>
{/if}

View File

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

View File

@ -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 => {

View File

@ -362,13 +362,35 @@ export default class DataFetch {
return
}
this.store.update($store => ({ ...$store, loading: true }))
const { rows, info, error } = await this.getPage()
const { rows, info, error, cursor } = await this.getPage()
let { cursors } = get(this.store)
const { pageNumber } = get(this.store)
if (!rows.length && pageNumber > 0) {
// If the full page is gone but we have previous pages, navigate to the previous page
this.store.update($store => ({
...$store,
loading: false,
cursors: cursors.slice(0, pageNumber),
}))
return await this.prevPage()
}
const currentNextCursor = cursors[pageNumber + 1]
if (currentNextCursor != cursor) {
// If the current cursor changed, all the next pages need to be updated, so we mark them as stale
cursors = cursors.slice(0, pageNumber + 1)
cursors[pageNumber + 1] = cursor
}
this.store.update($store => ({
...$store,
rows,
info,
loading: false,
error,
cursors,
}))
}

View File

@ -0,0 +1,50 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants"
export default class GroupUserFetch extends DataFetch {
constructor(opts) {
super({
...opts,
datasource: {
tableId: TableNames.USERS,
},
})
}
determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: false,
supportsPagination: true,
}
}
async getDefinition() {
return {
schema: {},
}
}
async getData() {
const { query, cursor } = get(this.store)
try {
const res = await this.API.getGroupUsers({
id: query.groupId,
bookmark: cursor,
})
return {
rows: res?.users || [],
hasNextPage: res?.hasNextPage || false,
cursor: res?.bookmark || null,
}
} catch (error) {
return {
rows: [],
hasNextPage: false,
error,
}
}
}
}

View File

@ -6,6 +6,7 @@ import NestedProviderFetch from "./NestedProviderFetch.js"
import FieldFetch from "./FieldFetch.js"
import JSONArrayFetch from "./JSONArrayFetch.js"
import UserFetch from "./UserFetch.js"
import GroupUserFetch from "./GroupUserFetch.js"
const DataFetchMap = {
table: TableFetch,
@ -13,6 +14,7 @@ const DataFetchMap = {
query: QueryFetch,
link: RelationshipFetch,
user: UserFetch,
groupUser: GroupUserFetch,
// Client specific datasource types
provider: NestedProviderFetch,

View File

@ -47,7 +47,7 @@
"@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "0.0.1",
"@budibase/client": "0.0.1",
"@budibase/pro": "develop",
"@budibase/pro": "0.0.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",

View File

@ -26,6 +26,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",
@ -70,7 +74,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 {
@ -89,6 +105,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3,
value4,
value5,
...payload,
}),
headers: {
"Content-Type": "application/json",

View File

@ -24,6 +24,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",
@ -63,7 +67,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 {
@ -85,6 +101,7 @@ export async function run({ inputs }: AutomationStepInput) {
value3,
value4,
value5,
...payload,
}),
headers: {
"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

@ -15,7 +15,7 @@ import {
} from "@budibase/types"
import { OAuth2Client } from "google-auth-library"
import { buildExternalTableId, finaliseExternalTables } from "./utils"
import { GoogleSpreadsheet } from "google-spreadsheet"
import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet"
import fetch from "node-fetch"
import { configs, HTTPError } from "@budibase/backend-core"
import { dataFilters } from "@budibase/shared-core"
@ -434,7 +434,20 @@ class GoogleSheetsIntegration implements DatasourcePlus {
try {
await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
let rows: GoogleSpreadsheetRow[] = []
if (query.paginate) {
const limit = query.paginate.limit || 100
let page: number =
typeof query.paginate.page === "number"
? query.paginate.page
: parseInt(query.paginate.page || "1")
rows = await sheet.getRows({
limit,
offset: (page - 1) * limit,
})
} else {
rows = await sheet.getRows()
}
const filtered = dataFilters.runLuceneQuery(rows, query.filters)
const headerValues = sheet.headerValues
let response = []

View File

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

View File

@ -1,3 +1,4 @@
import { PaginationResponse } from "../../api"
import { Document } from "../document"
export interface UserGroup extends Document {
@ -21,3 +22,15 @@ export interface GroupUser {
export interface UserGroupRoles {
[key: string]: string
}
export interface SearchGroupRequest {}
export interface SearchGroupResponse {
data: UserGroup[]
}
export interface SearchUserGroupResponse extends PaginationResponse {
users: {
_id: any
email: any
}[]
}

View File

@ -65,6 +65,7 @@ export type DatabaseQueryOpts = {
key?: string
keys?: string[]
group?: boolean
startkey_docid?: string
}
export const isDocument = (doc: any): doc is Document => {

View File

@ -38,7 +38,7 @@
"license": "GPL-3.0",
"dependencies": {
"@budibase/backend-core": "0.0.1",
"@budibase/pro": "develop",
"@budibase/pro": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",
"@koa/router": "8.0.8",

View File

@ -69,9 +69,11 @@ const bulkCreate = async (users: User[], groupIds: string[]) => {
return await userSdk.bulkCreate(users, groupIds)
}
export const bulkUpdate = async (ctx: any) => {
export const bulkUpdate = async (
ctx: Ctx<BulkUserRequest, BulkUserResponse>
) => {
const currentUserId = ctx.user._id
const input = ctx.request.body as BulkUserRequest
const input = ctx.request.body
let created, deleted
try {
if (input.create) {
@ -83,7 +85,7 @@ export const bulkUpdate = async (ctx: any) => {
} catch (err: any) {
ctx.throw(err.status || 400, err?.message || err)
}
ctx.body = { created, deleted } as BulkUserResponse
ctx.body = { created, deleted }
}
const parseBooleanParam = (param: any) => {
@ -184,15 +186,15 @@ export const destroy = async (ctx: any) => {
}
}
export const getAppUsers = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body
const users = await userSdk.getUsersByAppAccess(body?.appId)
ctx.body = { data: users }
}
export const search = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
export const search = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body
if (body.paginated === false) {
await getAppUsers(ctx)
@ -238,8 +240,8 @@ export const tenantUserLookup = async (ctx: any) => {
/*
Encapsulate the app user onboarding flows here.
*/
export const onboardUsers = async (ctx: any) => {
const request = ctx.request.body as InviteUsersRequest | BulkUserRequest
export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body
const isBulkCreate = "create" in request
const emailConfigured = await isEmailConfigured()
@ -255,7 +257,7 @@ export const onboardUsers = async (ctx: any) => {
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx)
} else if (!emailConfigured) {
const inviteRequest = ctx.request.body as InviteUsersRequest
const inviteRequest = ctx.request.body
let createdPasswords: any = {}
@ -295,10 +297,10 @@ export const onboardUsers = async (ctx: any) => {
}
}
export const invite = async (ctx: any) => {
const request = ctx.request.body as InviteUserRequest
export const invite = async (ctx: Ctx<InviteUserRequest>) => {
const request = ctx.request.body
let multiRequest = [request] as InviteUsersRequest
let multiRequest = [request]
const response = await userSdk.invite(multiRequest)
// explicitly throw for single user invite
@ -318,8 +320,8 @@ export const invite = async (ctx: any) => {
}
}
export const inviteMultiple = async (ctx: any) => {
const request = ctx.request.body as InviteUsersRequest
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body
ctx.body = await userSdk.invite(request)
}
@ -424,7 +426,6 @@ export const inviteAccept = async (
if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
// explicitly re-throw limit exceeded errors
ctx.throw(400, err)
return
}
console.warn("Error inviting user", err)
ctx.throw(400, "Unable to create new user, invitation invalid.")

View File

@ -13,6 +13,7 @@ describe("/api/global/groups", () => {
})
beforeEach(async () => {
jest.resetAllMocks()
mocks.licenses.useGroups()
})
@ -24,6 +25,63 @@ describe("/api/global/groups", () => {
expect(events.group.updated).not.toBeCalled()
expect(events.group.permissionsEdited).not.toBeCalled()
})
it("should not allow undefined names", async () => {
const group = { ...structures.groups.UserGroup(), name: undefined } as any
const response = await config.api.groups.saveGroup(group, { expect: 400 })
expect(JSON.parse(response.text).message).toEqual(
'Invalid body - "name" is required'
)
})
it("should not allow empty names", async () => {
const group = { ...structures.groups.UserGroup(), name: "" }
const response = await config.api.groups.saveGroup(group, { expect: 400 })
expect(JSON.parse(response.text).message).toEqual(
'Invalid body - "name" is not allowed to be empty'
)
})
it("should not allow whitespace names", async () => {
const group = { ...structures.groups.UserGroup(), name: " " }
const response = await config.api.groups.saveGroup(group, { expect: 400 })
expect(JSON.parse(response.text).message).toEqual(
'Invalid body - "name" is not allowed to be empty'
)
})
it("should trim names", async () => {
const group = { ...structures.groups.UserGroup(), name: " group name " }
await config.api.groups.saveGroup(group)
expect(events.group.created).toBeCalledWith(
expect.objectContaining({ name: "group name" })
)
})
describe("name max length", () => {
const maxLength = 50
it(`should allow names shorter than ${maxLength} characters`, async () => {
const group = {
...structures.groups.UserGroup(),
name: structures.generator.word({ length: maxLength }),
}
await config.api.groups.saveGroup(group, { expect: 200 })
})
it(`should not allow names longer than ${maxLength} characters`, async () => {
const group = {
...structures.groups.UserGroup(),
name: structures.generator.word({ length: maxLength + 1 }),
}
const response = await config.api.groups.saveGroup(group, {
expect: 400,
})
expect(JSON.parse(response.text).message).toEqual(
'Invalid body - "name" length must be less than or equal to 50 characters long'
)
})
})
})
describe("update", () => {

View File

@ -7,13 +7,13 @@ export class GroupsAPI extends TestAPI {
super(config)
}
saveGroup = (group: UserGroup) => {
saveGroup = (group: UserGroup, { expect } = { expect: 200 }) => {
return this.request
.post(`/api/global/groups`)
.send(group)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
.expect(expect)
}
deleteGroup = (id: string, rev: string) => {

View File

@ -1,10 +1,25 @@
import { generator } from "@budibase/backend-core/tests"
import { db } from "@budibase/backend-core"
import { UserGroupRoles } from "@budibase/types"
export const UserGroup = () => {
const appsCount = generator.integer({ min: 0, max: 3 })
const roles = Array.from({ length: appsCount }).reduce(
(p: UserGroupRoles, v) => {
return {
...p,
[db.generateAppID()]: generator.pickone(["ADMIN", "POWER", "BASIC"]),
}
},
{}
)
let group = {
apps: [],
color: "var(--spectrum-global-color-blue-600)",
icon: "UserGroup",
name: "New group",
roles: { app_uuid1: "ADMIN", app_uuid2: "POWER" },
color: generator.color(),
icon: generator.word(),
name: generator.word({ length: 2 }),
roles: roles,
users: [],
}
return group

131
yarn.lock
View File

@ -1386,47 +1386,6 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.5.10-alpha.3":
version "2.5.10-alpha.3"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.5.10-alpha.3.tgz#bd4a34a95ebbf1528f4315e2307309f677a06f4b"
integrity sha512-UZgZivQpJ02pPCZuY3bRjAK/tkoz6HP3F2AUu922U1RQdwZiIUfYUxyVIZK8Lo4UXhz3EnaThJGwOtWaEelyAQ==
dependencies:
"@budibase/nano" "10.1.2"
"@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.5.10-alpha.3"
"@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0"
aws-sdk "2.1030.0"
bcrypt "5.0.1"
bcryptjs "2.4.3"
bull "4.10.1"
correlation-id "4.0.0"
dotenv "16.0.1"
emitter-listener "1.1.2"
ioredis "4.28.0"
joi "17.6.0"
jsonwebtoken "9.0.0"
koa-passport "4.1.4"
koa-pino-logger "4.0.0"
lodash "4.17.21"
lodash.isarguments "3.1.0"
node-fetch "2.6.7"
passport-google-oauth "2.0.0"
passport-jwt "4.0.0"
passport-local "1.0.0"
passport-oauth2-refresh "^2.1.0"
pino "8.11.0"
pino-http "8.3.3"
posthog-node "1.3.0"
pouchdb "7.3.0"
pouchdb-find "7.2.2"
redlock "4.2.0"
sanitize-s3-objectkey "0.0.1"
semver "7.3.7"
tar-fs "2.1.1"
uuid "8.3.2"
"@budibase/bbui@^0.9.139":
version "0.9.190"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-0.9.190.tgz#e1ec400ac90f556bfbc80fc23a04506f1585ea81"
@ -1527,32 +1486,6 @@
pouchdb-promise "^6.0.4"
through2 "^2.0.0"
"@budibase/pro@develop":
version "2.5.10-alpha.3"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.5.10-alpha.3.tgz#6bf06be3e8f87b785d618847adf3ca235097a29b"
integrity sha512-w0CCBok8gvill38sPgN3EUztwQTnfZVw/I6pbzOQaRUaH4Hi0To94HarLHQ0Awu1HM8/xnke3V3zukPVhFrMYA==
dependencies:
"@budibase/backend-core" "2.5.10-alpha.3"
"@budibase/shared-core" "2.5.9"
"@budibase/string-templates" "2.5.9"
"@budibase/types" "2.5.10-alpha.3"
"@koa/router" "8.0.8"
bull "4.10.1"
joi "17.6.0"
jsonwebtoken "8.5.1"
lru-cache "^7.14.1"
memorystream "^0.3.1"
node-fetch "^2.6.1"
scim-patch "^0.7.0"
scim2-parse-filter "^0.2.8"
"@budibase/shared-core@2.5.9":
version "2.5.9"
resolved "https://registry.yarnpkg.com/@budibase/shared-core/-/shared-core-2.5.9.tgz#f22f22637fb7618ded1c7292b10793d7969827ce"
integrity sha512-l417Rb2+1tuXbjNL42wJHmqIQQyla2pPglOnapxfOdRuvzng+5GqlrTV1caLNf/53TS9U6ueGJdPOtxZTTFGUA==
dependencies:
"@budibase/types" "^2.5.9"
"@budibase/standard-components@^0.9.139":
version "0.9.139"
resolved "https://registry.yarnpkg.com/@budibase/standard-components/-/standard-components-0.9.139.tgz#cf8e2b759ae863e469e50272b3ca87f2827e66e3"
@ -1571,32 +1504,6 @@
svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0"
"@budibase/string-templates@2.5.9":
version "2.5.9"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-2.5.9.tgz#64582730421801e1e829b430136b449b1adc0314"
integrity sha512-Szu06M0JFHuUVIil2aHZWU8hvsROYpDx9raX9uIv4DcwBOAtyvVzD16wOCHzlmj8wWeV8fbKe4JF4q4GXnilJg==
dependencies:
"@budibase/handlebars-helpers" "^0.11.8"
dayjs "^1.10.4"
handlebars "^4.7.6"
handlebars-utils "^1.0.6"
lodash "^4.17.20"
vm2 "^3.9.15"
"@budibase/types@2.5.10-alpha.3":
version "2.5.10-alpha.3"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.5.10-alpha.3.tgz#9587a30cb5fbd05f7abe779a971a443920d5b0af"
integrity sha512-R8mlreITQb3FYsF9g0CLvzadMmg5PJc2+a5oobGhlxfheuyE+2IMNuyEC1yCeSfhh2JgzSnCwcIDiOq5ZYlT3g==
dependencies:
scim-patch "^0.7.0"
"@budibase/types@^2.5.9":
version "2.6.7"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.6.7.tgz#ee41bd22a8304d72412fb62b97fd2a81199bcf67"
integrity sha512-JIE1M5mkR8xdmdlmfvIPw4E6A95lL/LA1uXPRHv7+UAtHPQyjKcirkrNr18mgAb2dYWCYP0404+wgxRRGQg0GQ==
dependencies:
scim-patch "^0.7.0"
"@bull-board/api@3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-3.7.0.tgz#231f687187c0cb34e0b97f463917b6aaeb4ef6af"
@ -3057,7 +2964,7 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/sourcemap-codec@^1.4.10":
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
@ -3767,7 +3674,7 @@
dependencies:
slash "^3.0.0"
"@rollup/plugin-commonjs@^16.0.0":
"@rollup/plugin-commonjs@16.0.0", "@rollup/plugin-commonjs@^16.0.0":
version "16.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f"
integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw==
@ -3850,6 +3757,22 @@
"@rollup/pluginutils" "^3.1.0"
magic-string "^0.25.7"
"@rollup/plugin-replace@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz#45f53501b16311feded2485e98419acb8448c61d"
integrity sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA==
dependencies:
"@rollup/pluginutils" "^5.0.1"
magic-string "^0.27.0"
"@rollup/plugin-typescript@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.3.0.tgz#bc1077fa5897b980fc27e376c4e377882c63e68b"
integrity sha512-I5FpSvLbtAdwJ+naznv+B4sjXZUcIvLLceYpITAn7wAP8W0wqc5noLdGIp9HGVntNhRWXctwPYrSSFQxtl0FPA==
dependencies:
"@rollup/pluginutils" "^3.1.0"
resolve "^1.17.0"
"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
@ -11748,7 +11671,7 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.2:
fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.1, fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
@ -16738,6 +16661,13 @@ magic-string@^0.26.2:
dependencies:
sourcemap-codec "^1.4.8"
magic-string@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
make-dir@3.1.0, make-dir@^3.0.0, make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@ -21509,6 +21439,13 @@ rollup-pluginutils@^2.3.1, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0,
dependencies:
estree-walker "^0.6.1"
rollup@2.45.2:
version "2.45.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.45.2.tgz#8fb85917c9f35605720e92328f3ccbfba6f78b48"
integrity sha512-kRRU7wXzFHUzBIv0GfoFFIN3m9oteY4uAsKllIpQDId5cfnkWF2J130l+27dzDju0E6MScKiV0ZM5Bw8m4blYQ==
optionalDependencies:
fsevents "~2.3.1"
rollup@^2.36.2, rollup@^2.44.0, rollup@^2.45.2, rollup@^2.79.1:
version "2.79.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
@ -23485,7 +23422,7 @@ timed-out@^4.0.1:
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
integrity sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==
timekeeper@2.2.0:
timekeeper@2.2.0, timekeeper@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/timekeeper/-/timekeeper-2.2.0.tgz#9645731fce9e3280a18614a57a9d1b72af3ca368"
integrity sha512-W3AmPTJWZkRwu+iSNxPIsLZ2ByADsOLbbLxe46UJyWj3mlYLlwucKiq+/dPm0l9wTzqoF3/2PH0AGFCebjq23A==