Merge remote-tracking branch 'origin/v3-ui' into feature/automation-branching-ux
This commit is contained in:
commit
071a5b1d46
|
@ -213,6 +213,22 @@ export function getBuiltinRole(roleId: string): Role | undefined {
|
|||
return cloneDeep(role)
|
||||
}
|
||||
|
||||
export function validInherits(
|
||||
allRoles: RoleDoc[],
|
||||
inherits?: string | string[]
|
||||
): boolean {
|
||||
if (!inherits) {
|
||||
return false
|
||||
}
|
||||
const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id))
|
||||
if (Array.isArray(inherits)) {
|
||||
const filtered = inherits.filter(roleId => find(roleId))
|
||||
return inherits.length !== 0 && filtered.length === inherits.length
|
||||
} else {
|
||||
return !!find(inherits)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
||||
*/
|
||||
|
@ -290,7 +306,7 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
|
|||
: roleId1
|
||||
}
|
||||
|
||||
export function compareRoleIds(roleId1: string, roleId2: string) {
|
||||
export function roleIDsAreEqual(roleId1: string, roleId2: string) {
|
||||
// make sure both role IDs are prefixed correctly
|
||||
return prefixRoleID(roleId1) === prefixRoleID(roleId2)
|
||||
}
|
||||
|
@ -323,7 +339,7 @@ export function findRole(
|
|||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
const dbRole = roles.find(
|
||||
role => role._id && compareRoleIds(role._id, roleId)
|
||||
role => role._id && roleIDsAreEqual(role._id, roleId)
|
||||
)
|
||||
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
|
@ -557,7 +573,7 @@ export class AccessController {
|
|||
}
|
||||
|
||||
return (
|
||||
roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !==
|
||||
roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
export let onConfirm = undefined
|
||||
export let buttonText = ""
|
||||
export let cta = false
|
||||
|
||||
$: icon = selectIcon(type)
|
||||
// if newlines used, convert them to different elements
|
||||
$: split = message.split("\n")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import StatusLight from "../StatusLight/StatusLight.svelte"
|
||||
|
||||
export let icon = null
|
||||
export let iconColor = null
|
||||
|
@ -8,6 +9,7 @@
|
|||
export let url = null
|
||||
export let hoverable = false
|
||||
export let showArrow = false
|
||||
export let selected = false
|
||||
</script>
|
||||
|
||||
<a
|
||||
|
@ -15,9 +17,12 @@
|
|||
class="list-item"
|
||||
class:hoverable={hoverable || url != null}
|
||||
on:click
|
||||
class:selected
|
||||
>
|
||||
<div class="left">
|
||||
{#if icon}
|
||||
{#if icon === "StatusLight"}
|
||||
<StatusLight square size="L" color={iconColor} />
|
||||
{:else if icon}
|
||||
<Icon name={icon} color={iconColor} />
|
||||
{/if}
|
||||
<div class="list-item__text">
|
||||
|
@ -43,7 +48,7 @@
|
|||
|
||||
<style>
|
||||
.list-item {
|
||||
padding: var(--spacing-m);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
background: var(--spectrum-global-color-gray-75);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -66,15 +71,20 @@
|
|||
}
|
||||
.hoverable:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.hoverable:not(.selected):hover {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
.selected {
|
||||
background: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.left {
|
||||
width: 0;
|
||||
|
|
|
@ -21,6 +21,7 @@ export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
|
|||
export { default as Multiselect } from "./Form/Multiselect.svelte"
|
||||
export { default as Search } from "./Form/Search.svelte"
|
||||
export { default as RichTextField } from "./Form/RichTextField.svelte"
|
||||
export { default as FieldLabel } from "./Form/FieldLabel.svelte"
|
||||
export { default as Slider } from "./Form/Slider.svelte"
|
||||
export { default as File } from "./Form/File.svelte"
|
||||
|
||||
|
|
|
@ -59,12 +59,14 @@
|
|||
"@codemirror/state": "^6.2.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.11.2",
|
||||
"@dagrejs/dagre": "1.1.4",
|
||||
"@fontsource/source-sans-pro": "^5.0.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
"@xyflow/svelte": "^0.1.18",
|
||||
"@zerodevx/svelte-json-view": "^1.0.7",
|
||||
"codemirror": "^5.65.16",
|
||||
"cron-parser": "^4.9.0",
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import { sdk } from "@budibase/shared-core"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte"
|
||||
import UpdateRowActionModal from "components/automation/AutomationPanel/UpdateRowActionModal.svelte"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
|
||||
export let automation
|
||||
|
@ -17,7 +16,6 @@
|
|||
|
||||
let confirmDeleteDialog
|
||||
let updateAutomationDialog
|
||||
let updateRowActionDialog
|
||||
|
||||
$: isRowAction = sdk.automations.isRowAction(automation)
|
||||
|
||||
|
@ -92,7 +90,7 @@
|
|||
name: "Edit",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
callback: updateRowActionDialog.show,
|
||||
callback: updateAutomationDialog.show,
|
||||
},
|
||||
del,
|
||||
]
|
||||
|
@ -135,8 +133,4 @@
|
|||
This action cannot be undone.
|
||||
</ConfirmDialog>
|
||||
|
||||
{#if isRowAction}
|
||||
<UpdateRowActionModal {automation} bind:this={updateRowActionDialog} />
|
||||
{:else}
|
||||
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
|
||||
{/if}
|
||||
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
<script>
|
||||
import { rowActions } from "stores/builder"
|
||||
import {
|
||||
notifications,
|
||||
Icon,
|
||||
Input,
|
||||
ModalContent,
|
||||
Modal,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
export let automation
|
||||
export let onCancel = undefined
|
||||
|
||||
let name
|
||||
let error = ""
|
||||
let modal
|
||||
|
||||
export const show = () => {
|
||||
name = automation?.displayName
|
||||
modal.show()
|
||||
}
|
||||
export const hide = () => {
|
||||
modal.hide()
|
||||
}
|
||||
|
||||
async function saveAutomation() {
|
||||
try {
|
||||
await rowActions.rename(
|
||||
automation.definition.trigger.inputs.tableId,
|
||||
automation.definition.trigger.inputs.rowActionId,
|
||||
name
|
||||
)
|
||||
notifications.success(`Row action updated successfully`)
|
||||
hide()
|
||||
} catch (error) {
|
||||
notifications.error("Error saving row action")
|
||||
}
|
||||
}
|
||||
|
||||
function checkValid(evt) {
|
||||
name = evt.target.value
|
||||
if (!name) {
|
||||
error = "Name is required"
|
||||
return
|
||||
}
|
||||
error = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} on:hide={onCancel}>
|
||||
<ModalContent
|
||||
title="Edit Row Action"
|
||||
confirmText="Save"
|
||||
size="L"
|
||||
onConfirm={saveAutomation}
|
||||
disabled={error}
|
||||
>
|
||||
<Input bind:value={name} label="Name" on:input={checkValid} {error} />
|
||||
<a
|
||||
slot="footer"
|
||||
target="_blank"
|
||||
href="https://docs.budibase.com/docs/automation-steps"
|
||||
>
|
||||
<Icon name="InfoOutline" />
|
||||
<span>Learn about automations</span>
|
||||
</a>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
a {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
a span {
|
||||
text-decoration: underline;
|
||||
margin-left: var(--spectrum-alias-item-padding-s);
|
||||
}
|
||||
</style>
|
|
@ -1,15 +0,0 @@
|
|||
<script>
|
||||
import { Button, Modal } from "@budibase/bbui"
|
||||
import EditRolesModal from "../modals/EditRoles.svelte"
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Button secondary icon="UsersLock" on:click on:click={modal.show}>
|
||||
Edit roles
|
||||
</Button>
|
||||
</div>
|
||||
<Modal bind:this={modal} on:show on:hide>
|
||||
<EditRolesModal />
|
||||
</Modal>
|
|
@ -1,31 +1,195 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { permissions } from "stores/builder"
|
||||
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
|
||||
import {
|
||||
ActionButton,
|
||||
Input,
|
||||
Select,
|
||||
Label,
|
||||
List,
|
||||
ListItem,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { permissions as permissionsStore, roles } from "stores/builder"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
import EditRolesButton from "./EditRolesButton.svelte"
|
||||
import { PermissionSource } from "@budibase/types"
|
||||
import { capitalise } from "helpers"
|
||||
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
||||
import { Roles } from "constants/backend"
|
||||
|
||||
export let resourceId
|
||||
|
||||
let resourcePermissions
|
||||
const inheritedRoleId = "inherited"
|
||||
const builtins = [Roles.ADMIN, Roles.POWER, Roles.BASIC, Roles.PUBLIC]
|
||||
|
||||
let permissions
|
||||
let showPopover = true
|
||||
let dependantsInfoMessage
|
||||
|
||||
$: fetchPermissions(resourceId)
|
||||
$: loadDependantInfo(resourceId)
|
||||
$: roleMismatch = checkRoleMismatch(permissions)
|
||||
$: selectedRole = roleMismatch ? null : permissions?.[0]?.value
|
||||
$: readableRole = selectedRole
|
||||
? $roles.find(x => x._id === selectedRole)?.uiMetadata.displayName
|
||||
: null
|
||||
$: buttonLabel = readableRole ? `Access: ${readableRole}` : "Access"
|
||||
$: highlight = roleMismatch || selectedRole === Roles.PUBLIC
|
||||
|
||||
$: builtInRoles = builtins.map(roleId => $roles.find(x => x._id === roleId))
|
||||
$: customRoles = $roles
|
||||
.filter(x => !builtins.includes(x._id))
|
||||
.slice()
|
||||
.toSorted((a, b) => {
|
||||
const aName = a.uiMetadata.displayName || a.name
|
||||
const bName = b.uiMetadata.displayName || b.name
|
||||
return aName < bName ? -1 : 1
|
||||
})
|
||||
|
||||
const fetchPermissions = async id => {
|
||||
resourcePermissions = await permissions.forResourceDetailed(id)
|
||||
const res = await permissionsStore.forResourceDetailed(id)
|
||||
permissions = Object.entries(res?.permissions || {}).map(([perm, info]) => {
|
||||
let enriched = {
|
||||
permission: perm,
|
||||
value:
|
||||
info.permissionType === PermissionSource.INHERITED
|
||||
? inheritedRoleId
|
||||
: info.role,
|
||||
options: [...$roles],
|
||||
}
|
||||
if (info.inheritablePermission) {
|
||||
enriched.options.unshift({
|
||||
_id: inheritedRoleId,
|
||||
name: `Inherit (${
|
||||
$roles.find(x => x._id === info.inheritablePermission).name
|
||||
})`,
|
||||
})
|
||||
}
|
||||
return enriched
|
||||
})
|
||||
}
|
||||
|
||||
const checkRoleMismatch = permissions => {
|
||||
if (!permissions || permissions.length < 2) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
permissions[0].value !== permissions[1].value ||
|
||||
permissions[0].value === inheritedRoleId
|
||||
)
|
||||
}
|
||||
|
||||
const loadDependantInfo = async resourceId => {
|
||||
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
|
||||
const resourceByType = dependantsInfo?.resourceByType
|
||||
if (resourceByType) {
|
||||
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
|
||||
let resourceDisplay =
|
||||
Object.keys(resourceByType).length === 1 && resourceByType.view
|
||||
? "view"
|
||||
: "resource"
|
||||
|
||||
if (total === 1) {
|
||||
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access`
|
||||
} else if (total > 1) {
|
||||
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access`
|
||||
} else {
|
||||
dependantsInfoMessage = null
|
||||
}
|
||||
} else {
|
||||
dependantsInfoMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
const changePermission = async role => {
|
||||
try {
|
||||
await permissionsStore.save({
|
||||
level: "read",
|
||||
role,
|
||||
resource: resourceId,
|
||||
})
|
||||
await permissionsStore.save({
|
||||
level: "write",
|
||||
role,
|
||||
resource: resourceId,
|
||||
})
|
||||
await fetchPermissions(resourceId)
|
||||
notifications.success("Updated permissions")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error updating permissions")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetailPopover title="Manage access" {showPopover}>
|
||||
<DetailPopover title="Select access role" {showPopover}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton icon="LockClosed" selected={open} quiet>Access</ActionButton>
|
||||
<ActionButton
|
||||
icon="LockClosed"
|
||||
selected={open || highlight}
|
||||
quiet
|
||||
accentColor={highlight ? "#ff0000" : null}
|
||||
>
|
||||
{buttonLabel}
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
{#if resourcePermissions}
|
||||
<ManageAccessModal {resourceId} permissions={resourcePermissions} />
|
||||
|
||||
{#if roleMismatch}
|
||||
<div class="row">
|
||||
<Label extraSmall grey>Level</Label>
|
||||
<Label extraSmall grey>Role</Label>
|
||||
{#each permissions as permission}
|
||||
<Input value={capitalise(permission.permission)} disabled />
|
||||
<Select
|
||||
placeholder={false}
|
||||
value={permission.value}
|
||||
on:change={e => changePermission(e.detail)}
|
||||
disabled
|
||||
options={permission.options}
|
||||
getOptionLabel={x => x.name}
|
||||
getOptionValue={x => x._id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<InfoDisplay
|
||||
error
|
||||
icon="Alert"
|
||||
body="Your previous configuration is shown above.<br/> Please choose a single role for read and write access."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<List>
|
||||
{#each builtInRoles as role}
|
||||
<ListItem
|
||||
title={role.uiMetadata.displayName}
|
||||
subtitle={role.uiMetadata.description}
|
||||
hoverable
|
||||
selected={selectedRole === role._id}
|
||||
icon="StatusLight"
|
||||
iconColor={role.uiMetadata.color}
|
||||
on:click={() => changePermission(role._id)}
|
||||
/>
|
||||
{/each}
|
||||
{#each customRoles as role}
|
||||
<ListItem
|
||||
title={role.uiMetadata.displayName}
|
||||
subtitle={role.uiMetadata.description}
|
||||
hoverable
|
||||
selected={selectedRole === role._id}
|
||||
icon="StatusLight"
|
||||
iconColor={role.uiMetadata.color}
|
||||
on:click={() => changePermission(role._id)}
|
||||
/>
|
||||
{/each}
|
||||
</List>
|
||||
|
||||
{#if dependantsInfoMessage}
|
||||
<InfoDisplay info body={dependantsInfoMessage} />
|
||||
{/if}
|
||||
<EditRolesButton
|
||||
on:show={() => (showPopover = false)}
|
||||
on:hide={() => (showPopover = true)}
|
||||
/>
|
||||
</DetailPopover>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
const generateAutomation = () => {
|
||||
popover?.hide()
|
||||
dispatch("request-generate")
|
||||
dispatch("generate")
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
|
||||
<DetailPopover title="Generate" bind:this={popover}>
|
||||
<svelte:fragment slot="anchor" let:open>
|
||||
<ActionButton selected={open}>
|
||||
<ActionButton quiet selected={open}>
|
||||
<div class="center">
|
||||
<img height={16} alt="magic wand" src={MagicWand} />
|
||||
Generate
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
const generateScreen = () => {
|
||||
popover?.hide()
|
||||
dispatch("request-generate")
|
||||
dispatch("generate")
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -125,7 +125,7 @@
|
|||
label="Role"
|
||||
bind:value={row.roleId}
|
||||
options={$roles}
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionLabel={role => role.uiMetadata.displayName}
|
||||
getOptionValue={role => role._id}
|
||||
disabled={!creating}
|
||||
/>
|
||||
|
|
|
@ -1,174 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
keepOpen,
|
||||
ModalContent,
|
||||
Select,
|
||||
Input,
|
||||
Button,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { API } from "api"
|
||||
import ErrorsBox from "components/common/ErrorsBox.svelte"
|
||||
import { roles } from "stores/builder"
|
||||
|
||||
const BASE_ROLE = { _id: "", inherits: "BASIC", permissionId: "write" }
|
||||
|
||||
let basePermissions = []
|
||||
let selectedRole = BASE_ROLE
|
||||
let errors = []
|
||||
let builtInRoles = ["Admin", "Power", "Basic", "Public"]
|
||||
let validRegex = /^[a-zA-Z0-9_]*$/
|
||||
// Don't allow editing of public role
|
||||
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
|
||||
$: selectedRoleId = selectedRole._id
|
||||
$: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
|
||||
$: isCreating = selectedRoleId == null || selectedRoleId === ""
|
||||
|
||||
$: roleNameError = getRoleNameError(selectedRole.name)
|
||||
|
||||
$: valid =
|
||||
selectedRole.name &&
|
||||
selectedRole.inherits &&
|
||||
selectedRole.permissionId &&
|
||||
!builtInRoles.includes(selectedRole.name)
|
||||
|
||||
$: shouldDisableRoleInput =
|
||||
builtInRoles.includes(selectedRole.name) &&
|
||||
selectedRole.name?.toLowerCase() === selectedRoleId?.toLowerCase()
|
||||
|
||||
const fetchBasePermissions = async () => {
|
||||
try {
|
||||
basePermissions = await API.getBasePermissions()
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching base permission options")
|
||||
basePermissions = []
|
||||
}
|
||||
}
|
||||
|
||||
// Changes the selected role
|
||||
const changeRole = event => {
|
||||
const id = event?.detail
|
||||
const role = $roles.find(role => role._id === id)
|
||||
if (role) {
|
||||
selectedRole = {
|
||||
...role,
|
||||
inherits: role.inherits ?? "",
|
||||
permissionId: role.permissionId ?? "",
|
||||
}
|
||||
} else {
|
||||
selectedRole = BASE_ROLE
|
||||
}
|
||||
errors = []
|
||||
}
|
||||
|
||||
// Saves or creates the selected role
|
||||
const saveRole = async () => {
|
||||
errors = []
|
||||
|
||||
// Clean up empty strings
|
||||
const keys = ["_id", "inherits", "permissionId"]
|
||||
keys.forEach(key => {
|
||||
if (selectedRole[key] === "") {
|
||||
delete selectedRole[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Validation
|
||||
if (!selectedRole.name || selectedRole.name.trim() === "") {
|
||||
errors.push({ message: "Please enter a role name" })
|
||||
}
|
||||
if (!selectedRole.permissionId) {
|
||||
errors.push({ message: "Please choose permissions" })
|
||||
}
|
||||
if (errors.length) {
|
||||
return keepOpen
|
||||
}
|
||||
|
||||
// Save/create the role
|
||||
try {
|
||||
await roles.save(selectedRole)
|
||||
notifications.success("Role saved successfully")
|
||||
} catch (error) {
|
||||
notifications.error(`Error saving role - ${error.message}`)
|
||||
return keepOpen
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes the selected role
|
||||
const deleteRole = async () => {
|
||||
try {
|
||||
await roles.delete(selectedRole)
|
||||
changeRole()
|
||||
notifications.success("Role deleted successfully")
|
||||
} catch (error) {
|
||||
notifications.error(`Error deleting role - ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleNameError = name => {
|
||||
const hasUniqueRoleName = !otherRoles
|
||||
?.map(role => role.name)
|
||||
?.includes(name)
|
||||
const invalidRoleName = !validRegex.test(name)
|
||||
if (!hasUniqueRoleName) {
|
||||
return "Select a unique role name."
|
||||
} else if (invalidRoleName) {
|
||||
return "Please enter a role name consisting of only alphanumeric symbols and underscores"
|
||||
}
|
||||
}
|
||||
|
||||
onMount(fetchBasePermissions)
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
title="Edit Roles"
|
||||
confirmText={isCreating ? "Create" : "Save"}
|
||||
onConfirm={saveRole}
|
||||
disabled={!valid || roleNameError}
|
||||
>
|
||||
{#if errors.length}
|
||||
<ErrorsBox {errors} />
|
||||
{/if}
|
||||
<Select
|
||||
thin
|
||||
secondary
|
||||
label="Role"
|
||||
value={selectedRoleId}
|
||||
on:change={changeRole}
|
||||
options={editableRoles}
|
||||
placeholder="Create new role"
|
||||
getOptionValue={role => role._id}
|
||||
getOptionLabel={role => role.name}
|
||||
/>
|
||||
{#if selectedRole}
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={selectedRole.name}
|
||||
disabled={!!selectedRoleId}
|
||||
error={roleNameError}
|
||||
/>
|
||||
<Select
|
||||
label="Inherits Role"
|
||||
bind:value={selectedRole.inherits}
|
||||
options={selectedRole._id === "BASIC" ? $roles : otherRoles}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionLabel={role => role.name}
|
||||
disabled={shouldDisableRoleInput}
|
||||
/>
|
||||
<Select
|
||||
label="Base Permissions"
|
||||
bind:value={selectedRole.permissionId}
|
||||
options={basePermissions}
|
||||
getOptionValue={x => x._id}
|
||||
getOptionLabel={x => x.name}
|
||||
disabled={shouldDisableRoleInput}
|
||||
/>
|
||||
{/if}
|
||||
<div slot="footer">
|
||||
{#if !isCreating && !builtInRoles.includes(selectedRole.name)}
|
||||
<Button warning on:click={deleteRole}>Delete</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalContent>
|
|
@ -79,6 +79,8 @@ describe("Export Modal", () => {
|
|||
props: propsCfg,
|
||||
})
|
||||
|
||||
expect(propsCfg.filters[0].field).toBe("1:Cost")
|
||||
|
||||
expect(screen.getByTestId("filters-applied")).toBeVisible()
|
||||
expect(screen.getByTestId("filters-applied").textContent).toBe(
|
||||
"Filters applied"
|
||||
|
|
|
@ -1,127 +0,0 @@
|
|||
<script>
|
||||
import { PermissionSource } from "@budibase/types"
|
||||
import { roles, permissions as permissionsStore } from "stores/builder"
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Select,
|
||||
notifications,
|
||||
Body,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
import { capitalise } from "helpers"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export let resourceId
|
||||
export let permissions
|
||||
|
||||
const inheritedRoleId = "inherited"
|
||||
|
||||
let dependantsInfoMessage
|
||||
|
||||
$: loadDependantInfo(resourceId)
|
||||
$: computedPermissions = Object.entries(permissions.permissions).reduce(
|
||||
(p, [level, roleInfo]) => {
|
||||
p[level] = {
|
||||
selectedValue:
|
||||
roleInfo.permissionType === PermissionSource.INHERITED
|
||||
? inheritedRoleId
|
||||
: roleInfo.role,
|
||||
options: [...$roles],
|
||||
}
|
||||
if (roleInfo.inheritablePermission) {
|
||||
p[level].inheritOption = roleInfo.inheritablePermission
|
||||
p[level].options.unshift({
|
||||
_id: inheritedRoleId,
|
||||
name: `Inherit (${
|
||||
get(roles).find(x => x._id === roleInfo.inheritablePermission).name
|
||||
})`,
|
||||
})
|
||||
}
|
||||
return p
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
async function changePermission(level, role) {
|
||||
try {
|
||||
if (role === inheritedRoleId) {
|
||||
await permissionsStore.remove({
|
||||
level,
|
||||
role,
|
||||
resource: resourceId,
|
||||
})
|
||||
} else {
|
||||
await permissionsStore.save({
|
||||
level,
|
||||
role,
|
||||
resource: resourceId,
|
||||
})
|
||||
}
|
||||
|
||||
// Show updated permissions in UI: REMOVE
|
||||
permissions = await permissionsStore.forResourceDetailed(resourceId)
|
||||
notifications.success("Updated permissions")
|
||||
} catch (error) {
|
||||
notifications.error("Error updating permissions")
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDependantInfo(resourceId) {
|
||||
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
|
||||
const resourceByType = dependantsInfo?.resourceByType
|
||||
if (resourceByType) {
|
||||
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
|
||||
let resourceDisplay =
|
||||
Object.keys(resourceByType).length === 1 && resourceByType.view
|
||||
? "view"
|
||||
: "resource"
|
||||
|
||||
if (total === 1) {
|
||||
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.`
|
||||
} else if (total > 1) {
|
||||
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Body size="S">Specify the minimum access level role for this data.</Body>
|
||||
<div class="row">
|
||||
<Label extraSmall grey>Level</Label>
|
||||
<Label extraSmall grey>Role</Label>
|
||||
{#each Object.keys(computedPermissions) as level}
|
||||
<Input value={capitalise(level)} disabled />
|
||||
<Select
|
||||
placeholder={false}
|
||||
value={computedPermissions[level].selectedValue}
|
||||
on:change={e => changePermission(level, e.detail)}
|
||||
options={computedPermissions[level].options}
|
||||
getOptionLabel={x => x.name}
|
||||
getOptionValue={x => x._id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if dependantsInfoMessage}
|
||||
<div class="inheriting-resources">
|
||||
<Icon name="Alert" />
|
||||
<Body size="S">
|
||||
<i>
|
||||
{dependantsInfoMessage}
|
||||
</i>
|
||||
</Body>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: var(--spacing-s);
|
||||
}
|
||||
.inheriting-resources {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -76,6 +76,13 @@
|
|||
selectedBy={$userSelectedResourceMap[TableNames.USERS]}
|
||||
/>
|
||||
{/if}
|
||||
<NavItem
|
||||
icon="UserAdmin"
|
||||
text="Manage roles"
|
||||
selected={$isActive("./roles")}
|
||||
on:click={() => $goto("./roles")}
|
||||
selectedBy={$userSelectedResourceMap.roles}
|
||||
/>
|
||||
{#each enrichedDataSources.filter(ds => ds.show) as datasource}
|
||||
<DatasourceNavItem
|
||||
{datasource}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
<script>
|
||||
import { BaseEdge } from "@xyflow/svelte"
|
||||
import { NodeWidth, GridResolution } from "./constants"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let sourceX
|
||||
export let sourceY
|
||||
|
||||
const { bounds } = getContext("flow")
|
||||
|
||||
$: bracketWidth = GridResolution * 3
|
||||
$: bracketHeight = $bounds.height / 2 + GridResolution * 2
|
||||
$: path = getCurlyBracePath(
|
||||
sourceX + bracketWidth,
|
||||
sourceY - bracketHeight,
|
||||
sourceX + bracketWidth,
|
||||
sourceY + bracketHeight
|
||||
)
|
||||
|
||||
const getCurlyBracePath = (x1, y1, x2, y2) => {
|
||||
const w = 2 // Thickness
|
||||
const q = 1 // Intensity
|
||||
const i = 28 // Inner radius strenth (lower is stronger)
|
||||
const j = 32 // Outer radius strength (higher is stronger)
|
||||
|
||||
// Calculate unit vector
|
||||
var dx = x1 - x2
|
||||
var dy = y1 - y2
|
||||
var len = Math.sqrt(dx * dx + dy * dy)
|
||||
dx = dx / len
|
||||
dy = dy / len
|
||||
|
||||
// Path control points
|
||||
const qx1 = x1 + q * w * dy - j
|
||||
const qy1 = y1 - q * w * dx
|
||||
const qx2 = x1 - 0.25 * len * dx + (1 - q) * w * dy - i
|
||||
const qy2 = y1 - 0.25 * len * dy - (1 - q) * w * dx
|
||||
const tx1 = x1 - 0.5 * len * dx + w * dy - bracketWidth
|
||||
const ty1 = y1 - 0.5 * len * dy - w * dx
|
||||
const qx3 = x2 + q * w * dy - j
|
||||
const qy3 = y2 - q * w * dx
|
||||
const qx4 = x1 - 0.75 * len * dx + (1 - q) * w * dy - i
|
||||
const qy4 = y1 - 0.75 * len * dy - (1 - q) * w * dx
|
||||
|
||||
return `M ${x1} ${y1} Q ${qx1} ${qy1} ${qx2} ${qy2} T ${tx1} ${ty1} M ${x2} ${y2} Q ${qx3} ${qy3} ${qx4} ${qy4} T ${tx1} ${ty1}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseEdge
|
||||
{...$$props}
|
||||
{path}
|
||||
style="--width:{NodeWidth}px; --x:{sourceX}px; --y:{sourceY}px;"
|
||||
/>
|
||||
|
||||
<style>
|
||||
:global(#basic-bracket) {
|
||||
animation-timing-function: linear(1, 0);
|
||||
}
|
||||
:global(#admin-bracket) {
|
||||
transform: scale(-1, 1) translateX(calc(var(--width) + 8px));
|
||||
transform-origin: var(--x) var(--y);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,74 @@
|
|||
<script>
|
||||
import { Button, ActionButton } from "@budibase/bbui"
|
||||
import { useSvelteFlow } from "@xyflow/svelte"
|
||||
import { getContext } from "svelte"
|
||||
import { ZoomDuration } from "./constants"
|
||||
|
||||
const { createRole, layoutAndFit } = getContext("flow")
|
||||
const flow = useSvelteFlow()
|
||||
</script>
|
||||
|
||||
<div class="control top-right">
|
||||
<div class="group">
|
||||
<ActionButton
|
||||
icon="Add"
|
||||
quiet
|
||||
on:click={() => flow.zoomIn({ duration: ZoomDuration })}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="Remove"
|
||||
quiet
|
||||
on:click={() => flow.zoomOut({ duration: ZoomDuration })}
|
||||
/>
|
||||
</div>
|
||||
<Button secondary on:click={layoutAndFit}>Auto layout</Button>
|
||||
</div>
|
||||
<div class="control bottom-right">
|
||||
<Button icon="Add" cta on:click={createRole}>Add role</Button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.control {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.top-right {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
.bottom-right {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
.top-right :global(.spectrum-Button),
|
||||
.top-right :global(.spectrum-ActionButton),
|
||||
.top-right :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-900) !important;
|
||||
}
|
||||
.top-right :global(.spectrum-Button),
|
||||
.top-right :global(.spectrum-ActionButton) {
|
||||
background: var(--spectrum-global-color-gray-200) !important;
|
||||
}
|
||||
.top-right :global(.spectrum-Button:hover),
|
||||
.top-right :global(.spectrum-ActionButton:hover) {
|
||||
background: var(--spectrum-global-color-gray-300) !important;
|
||||
}
|
||||
.group {
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.group :global(> *:not(:first-child)) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 2px solid var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.group :global(> *:not(:last-child)) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import { NodeWidth, NodeHeight } from "./constants"
|
||||
</script>
|
||||
|
||||
<div class="node" style={`--width:${NodeWidth}px; --height:${NodeHeight}px;`}>
|
||||
Add custom roles for more granular control over permissions
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.node {
|
||||
border-radius: 4px;
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
text-shadow: 4px 4px 10px var(--background-color),
|
||||
4px -4px 10px var(--background-color),
|
||||
-4px 4px 10px var(--background-color),
|
||||
-4px -4px 10px var(--background-color);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,123 @@
|
|||
<script>
|
||||
import { getBezierPath, BaseEdge, EdgeLabelRenderer } from "@xyflow/svelte"
|
||||
import { Icon, TooltipPosition } from "@budibase/bbui"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { roles } from "stores/builder"
|
||||
|
||||
export let sourceX
|
||||
export let sourceY
|
||||
export let sourcePosition
|
||||
export let targetX
|
||||
export let targetY
|
||||
export let targetPosition
|
||||
export let id
|
||||
export let source
|
||||
export let target
|
||||
|
||||
const { deleteEdge, selectedNodes } = getContext("flow")
|
||||
|
||||
let iconHovered = false
|
||||
let edgeHovered = false
|
||||
|
||||
$: hovered = iconHovered || edgeHovered
|
||||
$: active =
|
||||
hovered ||
|
||||
$selectedNodes.includes(source) ||
|
||||
$selectedNodes.includes(target)
|
||||
$: edgeClasses = getEdgeClasses(active, iconHovered)
|
||||
$: [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
$: sourceRole = $roles.find(x => x._id === source)
|
||||
$: targetRole = $roles.find(x => x._id === target)
|
||||
$: tooltip =
|
||||
sourceRole && targetRole
|
||||
? `Stop ${targetRole.uiMetadata.displayName} from inheriting ${sourceRole.uiMetadata.displayName}`
|
||||
: null
|
||||
|
||||
const getEdgeClasses = (active, iconHovered) => {
|
||||
let classes = ""
|
||||
if (active) classes += `active `
|
||||
if (iconHovered) classes += `delete `
|
||||
return classes
|
||||
}
|
||||
|
||||
const onEdgeMouseOver = () => {
|
||||
edgeHovered = true
|
||||
}
|
||||
|
||||
const onEdgeMouseOut = () => {
|
||||
edgeHovered = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const edge = document.querySelector(`.svelte-flow__edge[data-id="${id}"]`)
|
||||
if (edge) {
|
||||
edge.addEventListener("mouseover", onEdgeMouseOver)
|
||||
edge.addEventListener("mouseout", onEdgeMouseOut)
|
||||
}
|
||||
return () => {
|
||||
if (edge) {
|
||||
edge.removeEventListener("mouseover", onEdgeMouseOver)
|
||||
edge.removeEventListener("mouseout", onEdgeMouseOut)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<BaseEdge path={edgePath} class={edgeClasses} />
|
||||
<EdgeLabelRenderer>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
||||
<div
|
||||
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
|
||||
class="edge-label nodrag nopan"
|
||||
class:active
|
||||
on:click={() => deleteEdge(id)}
|
||||
on:mouseover={() => (iconHovered = true)}
|
||||
on:mouseout={() => (iconHovered = false)}
|
||||
>
|
||||
<Icon
|
||||
name="Delete"
|
||||
size="S"
|
||||
{tooltip}
|
||||
tooltipPosition={TooltipPosition.Top}
|
||||
/>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
|
||||
<style>
|
||||
.edge-label {
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge-label.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edge-label:hover :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
.edge-label :global(.spectrum-Icon) {
|
||||
background: var(--background-color);
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
.edge-label :global(svg) {
|
||||
padding: 4px;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.active) {
|
||||
stroke: var(--spectrum-global-color-blue-400);
|
||||
}
|
||||
:global(.svelte-flow__edge-path.active.delete) {
|
||||
stroke: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import { SvelteFlowProvider } from "@xyflow/svelte"
|
||||
import RoleFlow from "./RoleFlow.svelte"
|
||||
</script>
|
||||
|
||||
<SvelteFlowProvider>
|
||||
<RoleFlow />
|
||||
</SvelteFlowProvider>
|
|
@ -0,0 +1,234 @@
|
|||
<script>
|
||||
import { Heading, Helpers, notifications } from "@budibase/bbui"
|
||||
import { writable, derived } from "svelte/store"
|
||||
import {
|
||||
SvelteFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
useSvelteFlow,
|
||||
} from "@xyflow/svelte"
|
||||
import "@xyflow/svelte/dist/style.css"
|
||||
import RoleNode from "./RoleNode.svelte"
|
||||
import EmptyStateNode from "./EmptyStateNode.svelte"
|
||||
import RoleEdge from "./RoleEdge.svelte"
|
||||
import BracketEdge from "./BracketEdge.svelte"
|
||||
import {
|
||||
autoLayout,
|
||||
getAdminPosition,
|
||||
getBasicPosition,
|
||||
rolesToLayout,
|
||||
nodeToRole,
|
||||
getBounds,
|
||||
} from "./utils"
|
||||
import { setContext, tick } from "svelte"
|
||||
import Controls from "./Controls.svelte"
|
||||
import { GridResolution, MaxAutoZoom, ZoomDuration } from "./constants"
|
||||
import { roles } from "stores/builder"
|
||||
import { Roles } from "constants/backend"
|
||||
import { getSequentialName } from "helpers/duplicate"
|
||||
import { derivedMemo } from "@budibase/frontend-core"
|
||||
|
||||
const flow = useSvelteFlow()
|
||||
const edges = writable([])
|
||||
const nodes = writable([])
|
||||
const dragging = writable(false)
|
||||
|
||||
// Derive the list of selected nodes
|
||||
const selectedNodes = derived(nodes, $nodes => {
|
||||
return $nodes.filter(node => node.selected).map(node => node.id)
|
||||
})
|
||||
|
||||
// Derive the bounds of all custom role nodes
|
||||
const bounds = derivedMemo(nodes, getBounds)
|
||||
|
||||
$: handleExternalRoleChanges($roles)
|
||||
$: updateBuiltins($bounds)
|
||||
|
||||
// Updates nodes and edges based on external changes to roles
|
||||
const handleExternalRoleChanges = roles => {
|
||||
const currentNodes = $nodes
|
||||
const newLayout = autoLayout(rolesToLayout(roles))
|
||||
edges.set(newLayout.edges)
|
||||
|
||||
// For nodes we want to persist some metadata if possible
|
||||
nodes.set(
|
||||
newLayout.nodes.map(node => {
|
||||
const currentNode = currentNodes.find(x => x.id === node.id)
|
||||
if (!currentNode) {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
position: currentNode.position || node.position,
|
||||
selected: currentNode.selected || node.selected,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Positions the basic and admin role at either edge of the flow
|
||||
const updateBuiltins = bounds => {
|
||||
flow.updateNode(Roles.BASIC, {
|
||||
position: getBasicPosition(bounds),
|
||||
})
|
||||
flow.updateNode(Roles.ADMIN, {
|
||||
position: getAdminPosition(bounds),
|
||||
})
|
||||
}
|
||||
|
||||
// Automatically lays out all roles and edges and zooms to fit them
|
||||
const layoutAndFit = () => {
|
||||
const layout = autoLayout({ nodes: $nodes, edges: $edges })
|
||||
nodes.set(layout.nodes)
|
||||
edges.set(layout.edges)
|
||||
flow.fitView({ maxZoom: MaxAutoZoom, duration: ZoomDuration })
|
||||
}
|
||||
|
||||
const createRole = async () => {
|
||||
const roleId = Helpers.uuid()
|
||||
await roles.save({
|
||||
name: roleId,
|
||||
uiMetadata: {
|
||||
displayName: getSequentialName($roles, "New role ", {
|
||||
getName: role => role.uiMetadata.displayName,
|
||||
}),
|
||||
color: "var(--spectrum-global-color-gray-700)",
|
||||
description: "Custom role",
|
||||
},
|
||||
inherits: [Roles.BASIC],
|
||||
})
|
||||
await tick()
|
||||
layoutAndFit()
|
||||
|
||||
// Select the new node
|
||||
nodes.update($nodes => {
|
||||
return $nodes.map(node => ({
|
||||
...node,
|
||||
selected: node.id === roleId,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const updateRole = async (roleId, metadata) => {
|
||||
const node = $nodes.find(node => node.id === roleId)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
// Update metadata immediately, before saving
|
||||
if (metadata) {
|
||||
flow.updateNodeData(roleId, metadata)
|
||||
}
|
||||
try {
|
||||
await roles.save(nodeToRole({ node, edges: $edges }))
|
||||
layoutAndFit()
|
||||
} catch (error) {
|
||||
notifications.error(error?.message || error || "Failed to update role")
|
||||
handleExternalRoleChanges($roles)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRole = async roleId => {
|
||||
nodes.set($nodes.filter(node => node.id !== roleId))
|
||||
layoutAndFit()
|
||||
const role = $roles.find(role => role._id === roleId)
|
||||
if (role) {
|
||||
roles.delete(role)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEdge = async edgeId => {
|
||||
const edge = $edges.find(edge => edge.id === edgeId)
|
||||
edges.set($edges.filter(edge => edge.id !== edgeId))
|
||||
await updateRole(edge.target)
|
||||
}
|
||||
|
||||
const onConnect = async connection => {
|
||||
await updateRole(connection.target)
|
||||
}
|
||||
|
||||
setContext("flow", {
|
||||
nodes,
|
||||
edges,
|
||||
dragging,
|
||||
selectedNodes,
|
||||
bounds,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
deleteEdge,
|
||||
layoutAndFit,
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="title">
|
||||
<div class="heading" />
|
||||
</div>
|
||||
<div class="flow">
|
||||
<SvelteFlow
|
||||
fitView
|
||||
{nodes}
|
||||
{edges}
|
||||
snapGrid={[GridResolution, GridResolution]}
|
||||
nodeTypes={{ role: RoleNode, empty: EmptyStateNode }}
|
||||
edgeTypes={{ role: RoleEdge, bracket: BracketEdge }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitViewOptions={{ maxZoom: MaxAutoZoom }}
|
||||
defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}
|
||||
onconnectstart={() => dragging.set(true)}
|
||||
onconnectend={() => dragging.set(false)}
|
||||
onconnect={onConnect}
|
||||
deleteKey={null}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} />
|
||||
<Controls />
|
||||
<div class="title">
|
||||
<Heading size="S">Manage roles</Heading>
|
||||
</div>
|
||||
<div class="footer">Roles inherit permissions from each other</div>
|
||||
</SvelteFlow>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flow {
|
||||
margin: -28px -40px -40px -40px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
--background-color: var(--spectrum-global-color-gray-50);
|
||||
--border-color: var(--spectrum-global-color-gray-300);
|
||||
--edge-color: var(--spectrum-global-color-gray-500);
|
||||
--handle-color: var(--spectrum-global-color-gray-600);
|
||||
--selected-color: var(--spectrum-global-color-blue-400);
|
||||
}
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Customise svelte-flow theme */
|
||||
.flow :global(.svelte-flow) {
|
||||
/* Panel */
|
||||
--xy-background-color: var(--background-color);
|
||||
|
||||
/* Controls */
|
||||
--xy-controls-button-border-color: var(--border-color);
|
||||
|
||||
/* Handles */
|
||||
--xy-handle-background-color: var(--handle-color);
|
||||
--xy-handle-border-color: var(--handle-color);
|
||||
|
||||
/* Edges */
|
||||
--xy-edge-stroke: var(--edge-color);
|
||||
--xy-edge-stroke-selected: var(--edge-color);
|
||||
--xy-edge-stroke-width: 2px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,231 @@
|
|||
<script>
|
||||
import { Handle, Position } from "@xyflow/svelte"
|
||||
import {
|
||||
Icon,
|
||||
Input,
|
||||
ColorPicker,
|
||||
Modal,
|
||||
ModalContent,
|
||||
FieldLabel,
|
||||
} from "@budibase/bbui"
|
||||
import { NodeWidth, NodeHeight } from "./constants"
|
||||
import { getContext } from "svelte"
|
||||
import { roles } from "stores/builder"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
|
||||
export let data
|
||||
export let id
|
||||
export let selected
|
||||
export let isConnectable
|
||||
|
||||
const { dragging, updateRole, deleteRole } = getContext("flow")
|
||||
|
||||
let anchor
|
||||
let modal
|
||||
let tempDisplayName
|
||||
let tempDescription
|
||||
let tempColor
|
||||
let deleteModal
|
||||
|
||||
$: nameError = validateName(tempDisplayName, $roles)
|
||||
$: descriptionError = validateDescription(tempDescription)
|
||||
$: invalid = nameError || descriptionError
|
||||
|
||||
const validateName = (name, roles) => {
|
||||
if (!name?.length) {
|
||||
return "Please enter a name"
|
||||
}
|
||||
if (roles.some(x => x.uiMetadata.displayName === name && x._id !== id)) {
|
||||
return "That name is already used by another role"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const validateDescription = description => {
|
||||
if (!description?.length) {
|
||||
return "Please enter a name"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const openPopover = e => {
|
||||
e.stopPropagation()
|
||||
tempDisplayName = data.displayName
|
||||
tempDescription = data.description
|
||||
tempColor = data.color
|
||||
modal.show()
|
||||
}
|
||||
|
||||
const saveChanges = () => {
|
||||
updateRole(id, {
|
||||
displayName: tempDisplayName,
|
||||
description: tempDescription,
|
||||
color: tempColor,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="node"
|
||||
class:dragging={$dragging}
|
||||
class:selected
|
||||
class:interactive={data.interactive}
|
||||
class:custom={data.custom}
|
||||
class:selectable={isConnectable}
|
||||
style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`}
|
||||
bind:this={anchor}
|
||||
>
|
||||
<div class="color" />
|
||||
<div class="content">
|
||||
<div class="text">
|
||||
<div class="name">
|
||||
{data.displayName}
|
||||
</div>
|
||||
{#if data.description}
|
||||
<div class="description" title={data.description}>
|
||||
{data.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.custom}
|
||||
<div class="buttons">
|
||||
<Icon size="S" name="Edit" hoverable on:click={openPopover} />
|
||||
<Icon size="S" name="Delete" hoverable on:click={deleteModal?.show} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={isConnectable && $dragging && data.custom}
|
||||
/>
|
||||
<Handle type="source" position={Position.Right} {isConnectable} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={deleteModal}
|
||||
title={`Delete ${data.displayName}`}
|
||||
body="Are you sure you want to delete this role? This can't be undone."
|
||||
okText="Delete"
|
||||
onOk={async () => await deleteRole(id)}
|
||||
/>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
title={`Edit ${data.displayName}`}
|
||||
confirmText="Save"
|
||||
onConfirm={saveChanges}
|
||||
disabled={invalid}
|
||||
>
|
||||
<Input
|
||||
label="Name"
|
||||
value={tempDisplayName}
|
||||
error={nameError}
|
||||
on:change={e => (tempDisplayName = e.detail)}
|
||||
/>
|
||||
<Input
|
||||
label="Description"
|
||||
value={tempDescription}
|
||||
error={descriptionError}
|
||||
on:change={e => (tempDescription = e.detail)}
|
||||
/>
|
||||
<div>
|
||||
<FieldLabel label="Color" />
|
||||
<ColorPicker value={tempColor} on:change={e => (tempColor = e.detail)} />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
/* Node styles */
|
||||
.node {
|
||||
position: relative;
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
border-radius: 4px;
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
transition: background 130ms ease-out;
|
||||
}
|
||||
.node.selectable:hover {
|
||||
cursor: pointer;
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
.node.selectable.selected {
|
||||
background: var(--spectrum-global-color-blue-100);
|
||||
cursor: grab;
|
||||
}
|
||||
.color {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
height: 100%;
|
||||
width: 10px;
|
||||
flex: 0 0 10px;
|
||||
background: var(--color);
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--border-color);
|
||||
border-left-width: 0;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
padding: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
.node.selected .content {
|
||||
border-color: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
|
||||
/* Text */
|
||||
.text {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
.name,
|
||||
.description {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.description {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.buttons :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
|
||||
/* Handles */
|
||||
.node :global(.svelte-flow__handle) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-width: 2px;
|
||||
}
|
||||
.node :global(.svelte-flow__handle.target) {
|
||||
background: var(--background-color);
|
||||
}
|
||||
.node:not(.dragging) :global(.svelte-flow__handle.target),
|
||||
.node:not(.interactive) :global(.svelte-flow__handle),
|
||||
.node:not(.custom) :global(.svelte-flow__handle.target) {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,9 @@
|
|||
export const ZoomDuration = 300
|
||||
export const MaxAutoZoom = 1.2
|
||||
export const GridResolution = 20
|
||||
export const NodeHeight = GridResolution * 3
|
||||
export const NodeWidth = GridResolution * 12
|
||||
export const NodeHSpacing = GridResolution * 6
|
||||
export const NodeVSpacing = GridResolution * 2
|
||||
export const MinHeight = GridResolution * 10
|
||||
export const EmptyStateID = "empty"
|
|
@ -0,0 +1,245 @@
|
|||
import dagre from "@dagrejs/dagre"
|
||||
import {
|
||||
NodeWidth,
|
||||
NodeHeight,
|
||||
GridResolution,
|
||||
NodeHSpacing,
|
||||
NodeVSpacing,
|
||||
MinHeight,
|
||||
EmptyStateID,
|
||||
} from "./constants"
|
||||
import { getNodesBounds, Position } from "@xyflow/svelte"
|
||||
import { Roles } from "constants/backend"
|
||||
import { roles } from "stores/builder"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
// Calculates the bounds of all custom nodes
|
||||
export const getBounds = nodes => {
|
||||
const interactiveNodes = nodes.filter(node => node.data.interactive)
|
||||
|
||||
// Empty state bounds which line up with bounds after adding first node
|
||||
if (!interactiveNodes.length) {
|
||||
return {
|
||||
x: 0,
|
||||
y: -3.5 * GridResolution,
|
||||
width: 12 * GridResolution,
|
||||
height: 10 * GridResolution,
|
||||
}
|
||||
}
|
||||
let bounds = getNodesBounds(interactiveNodes)
|
||||
|
||||
// Enforce a min size
|
||||
if (bounds.height < MinHeight) {
|
||||
const diff = MinHeight - bounds.height
|
||||
bounds.height = MinHeight
|
||||
bounds.y -= diff / 2
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
// Gets the position of the basic role
|
||||
export const getBasicPosition = bounds => ({
|
||||
x: bounds.x - NodeHSpacing - NodeWidth,
|
||||
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
|
||||
})
|
||||
|
||||
// Gets the position of the admin role
|
||||
export const getAdminPosition = bounds => ({
|
||||
x: bounds.x + bounds.width + NodeHSpacing,
|
||||
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
|
||||
})
|
||||
|
||||
// Filters out invalid nodes and edges
|
||||
const preProcessLayout = ({ nodes, edges }) => {
|
||||
const ignoredIds = [Roles.PUBLIC, Roles.BASIC, Roles.ADMIN, EmptyStateID]
|
||||
const targetlessIds = [Roles.POWER]
|
||||
return {
|
||||
nodes: nodes.filter(node => {
|
||||
// Filter out ignored IDs
|
||||
if (ignoredIds.includes(node.id)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
edges: edges.filter(edge => {
|
||||
// Filter out edges from ignored IDs
|
||||
if (
|
||||
ignoredIds.includes(edge.source) ||
|
||||
ignoredIds.includes(edge.target)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Filter out edges which have the same source and target
|
||||
if (edge.source === edge.target) {
|
||||
return false
|
||||
}
|
||||
// Filter out edges which target targetless roles
|
||||
if (targetlessIds.includes(edge.target)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Updates positions of nodes and edges into a nice graph structure
|
||||
export const dagreLayout = ({ nodes, edges }) => {
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({
|
||||
rankdir: "LR",
|
||||
ranksep: NodeHSpacing,
|
||||
nodesep: NodeVSpacing,
|
||||
})
|
||||
nodes.forEach(node => {
|
||||
dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight })
|
||||
})
|
||||
edges.forEach(edge => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
dagre.layout(dagreGraph)
|
||||
nodes.forEach(node => {
|
||||
const pos = dagreGraph.node(node.id)
|
||||
node.targetPosition = Position.Left
|
||||
node.sourcePosition = Position.Right
|
||||
node.position = {
|
||||
x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution,
|
||||
y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
|
||||
}
|
||||
})
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const postProcessLayout = ({ nodes, edges }) => {
|
||||
// Add basic and admin nodes at each edge
|
||||
const bounds = getBounds(nodes)
|
||||
const $roles = get(roles)
|
||||
nodes.push({
|
||||
...roleToNode($roles.find(role => role._id === Roles.BASIC)),
|
||||
position: getBasicPosition(bounds),
|
||||
})
|
||||
nodes.push({
|
||||
...roleToNode($roles.find(role => role._id === Roles.ADMIN)),
|
||||
position: getAdminPosition(bounds),
|
||||
})
|
||||
|
||||
// Add custom edges for basic and admin brackets
|
||||
edges.push({
|
||||
id: "basic-bracket",
|
||||
source: Roles.BASIC,
|
||||
target: Roles.ADMIN,
|
||||
type: "bracket",
|
||||
})
|
||||
edges.push({
|
||||
id: "admin-bracket",
|
||||
source: Roles.ADMIN,
|
||||
target: Roles.BASIC,
|
||||
type: "bracket",
|
||||
})
|
||||
|
||||
// Add empty state node if required
|
||||
if (!nodes.some(node => node.data.interactive)) {
|
||||
nodes.push({
|
||||
id: EmptyStateID,
|
||||
type: "empty",
|
||||
position: {
|
||||
x: bounds.x + bounds.width / 2 - NodeWidth / 2,
|
||||
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
|
||||
},
|
||||
data: {},
|
||||
measured: {
|
||||
width: NodeWidth,
|
||||
height: NodeHeight,
|
||||
},
|
||||
deletable: false,
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
selectable: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
// Automatically lays out the graph, sanitising and enriching the structure
|
||||
export const autoLayout = ({ nodes, edges }) => {
|
||||
return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges })))
|
||||
}
|
||||
|
||||
// Converts a role doc into a node structure
|
||||
export const roleToNode = role => {
|
||||
const custom = ![
|
||||
Roles.PUBLIC,
|
||||
Roles.BASIC,
|
||||
Roles.POWER,
|
||||
Roles.ADMIN,
|
||||
Roles.BUILDER,
|
||||
].includes(role._id)
|
||||
const interactive = custom || role._id === Roles.POWER
|
||||
return {
|
||||
id: role._id,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
type: "role",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...role.uiMetadata,
|
||||
custom,
|
||||
interactive,
|
||||
},
|
||||
measured: {
|
||||
width: NodeWidth,
|
||||
height: NodeHeight,
|
||||
},
|
||||
deletable: custom,
|
||||
draggable: interactive,
|
||||
connectable: interactive,
|
||||
selectable: interactive,
|
||||
}
|
||||
}
|
||||
|
||||
// Converts a node structure back into a role doc
|
||||
export const nodeToRole = ({ node, edges }) => ({
|
||||
...get(roles).find(role => role._id === node.id),
|
||||
inherits: edges
|
||||
.filter(x => x.target === node.id)
|
||||
.map(x => x.source)
|
||||
.concat(Roles.BASIC),
|
||||
uiMetadata: {
|
||||
displayName: node.data.displayName,
|
||||
color: node.data.color,
|
||||
description: node.data.description,
|
||||
},
|
||||
})
|
||||
|
||||
// Builds a default layout from an array of roles
|
||||
export const rolesToLayout = roles => {
|
||||
let nodes = []
|
||||
let edges = []
|
||||
|
||||
// Add all nodes and edges
|
||||
for (let role of roles) {
|
||||
// Add node for this role
|
||||
nodes.push(roleToNode(role))
|
||||
|
||||
// Add edges for this role
|
||||
let inherits = []
|
||||
if (role.inherits) {
|
||||
inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits]
|
||||
}
|
||||
for (let sourceRole of inherits) {
|
||||
if (!roles.some(x => x._id === sourceRole)) {
|
||||
continue
|
||||
}
|
||||
edges.push({
|
||||
id: `${sourceRole}-${role._id}`,
|
||||
source: sourceRole,
|
||||
target: role._id,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
<script>
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { StatusLight } from "@budibase/bbui"
|
||||
import { roles } from "stores/builder"
|
||||
|
||||
export let id
|
||||
export let size = "M"
|
||||
export let disabled = false
|
||||
|
||||
$: color = RoleUtils.getRoleColour(id)
|
||||
$: color =
|
||||
$roles.find(x => x._id === id)?.color ||
|
||||
"var(--spectrum-global-color-static-magenta-400)"
|
||||
</script>
|
||||
|
||||
<StatusLight square {disabled} {size} {color} />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { roles } from "stores/builder"
|
||||
import { licensing } from "stores/portal"
|
||||
|
||||
import { Constants, RoleUtils } from "@budibase/frontend-core"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
|
@ -49,7 +49,8 @@
|
|||
let options = roles
|
||||
.filter(role => allowedRoles.includes(role._id))
|
||||
.map(role => ({
|
||||
name: enrichLabel(role.name),
|
||||
color: role.uiMetadata.color,
|
||||
name: enrichLabel(role.uiMetadata.displayName),
|
||||
_id: role._id,
|
||||
}))
|
||||
if (allowedRoles.includes(Constants.Roles.CREATOR)) {
|
||||
|
@ -64,7 +65,8 @@
|
|||
|
||||
// Allow all core roles
|
||||
let options = roles.map(role => ({
|
||||
name: enrichLabel(role.name),
|
||||
color: role.uiMetadata.color,
|
||||
name: enrichLabel(role.uiMetadata.displayName),
|
||||
_id: role._id,
|
||||
}))
|
||||
|
||||
|
@ -100,7 +102,7 @@
|
|||
if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) {
|
||||
return null
|
||||
}
|
||||
return RoleUtils.getRoleColour(role._id)
|
||||
return role.color || "var(--spectrum-global-color-static-magenta-400)"
|
||||
}
|
||||
|
||||
const getIcon = role => {
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { roles } from "stores/builder"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
|
||||
export let value
|
||||
export let error
|
||||
export let placeholder = null
|
||||
export let autoWidth = false
|
||||
</script>
|
||||
|
||||
<Select
|
||||
bind:value
|
||||
on:change
|
||||
options={$roles}
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionLabel={role => role.uiMetadata.displayName}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionColour={role => RoleUtils.getRoleColour(role._id)}
|
||||
getOptionColour={role => role.uiMetadata.color}
|
||||
{placeholder}
|
||||
{error}
|
||||
{autoWidth}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<script>
|
||||
import { Label, notifications, Select } from "@budibase/bbui"
|
||||
import { permissions, roles } from "stores/builder"
|
||||
import { Label, notifications } from "@budibase/bbui"
|
||||
import { permissions } from "stores/builder"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
|
||||
|
||||
export let query
|
||||
export let label
|
||||
|
@ -52,12 +53,5 @@
|
|||
{#if label}
|
||||
<Label>{label}</Label>
|
||||
{/if}
|
||||
<Select
|
||||
value={roleId}
|
||||
on:change={e => updateRole(e.detail)}
|
||||
options={$roles}
|
||||
getOptionLabel={x => x.name}
|
||||
getOptionValue={x => x._id}
|
||||
autoWidth
|
||||
/>
|
||||
<RoleSelect value={roleId} on:change={e => updateRole(e.detail)} autoWidth />
|
||||
{/if}
|
||||
|
|
|
@ -503,7 +503,7 @@
|
|||
on:save={saveQuery}
|
||||
/>
|
||||
<div class="access">
|
||||
<Label>Access level</Label>
|
||||
<Label>Access</Label>
|
||||
<AccessLevelSelect {query} {saveId} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -725,10 +725,10 @@ const getRoleBindings = () => {
|
|||
return {
|
||||
type: "context",
|
||||
runtimeBinding: `'${role._id}'`,
|
||||
readableBinding: `Role.${role.name}`,
|
||||
readableBinding: `Role.${role.uiMetadata.displayName}`,
|
||||
category: "Role",
|
||||
icon: "UserGroup",
|
||||
display: { type: "string", name: role.name },
|
||||
display: { type: "string", name: role.uiMetadata.displayName },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -228,7 +228,7 @@
|
|||
.top-nav {
|
||||
flex: 0 0 60px;
|
||||
background: var(--background);
|
||||
padding-left: var(--spacing-xl);
|
||||
padding: 0 var(--spacing-xl);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
flex-direction: row;
|
||||
|
@ -269,6 +269,7 @@
|
|||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-right: calc(-1 * var(--spacing-xl));
|
||||
}
|
||||
|
||||
.toprightnav :global(.avatars) {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import RoleEditor from "components/backend/RoleEditor/RoleEditor.svelte"
|
||||
import { builderStore } from "stores/builder"
|
||||
|
||||
builderStore.selectResource("roles")
|
||||
</script>
|
||||
|
||||
<RoleEditor />
|
|
@ -56,15 +56,13 @@
|
|||
buttonsCollapsed
|
||||
>
|
||||
<svelte:fragment slot="controls">
|
||||
<GridManageAccessButton />
|
||||
<GridFilterButton />
|
||||
<GridSortButton />
|
||||
<GridSizeButton />
|
||||
<GridColumnsSettingButton />
|
||||
<GridManageAccessButton />
|
||||
<GridRowActionsButton />
|
||||
<GridScreensButton on:request-generate={() => generateButton?.show()} />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="controls-right">
|
||||
<GridScreensButton on:generate={() => generateButton?.show()} />
|
||||
<GridGenerateButton bind:this={generateButton} />
|
||||
</svelte:fragment>
|
||||
<GridCreateEditRowModal />
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
integrations,
|
||||
appStore,
|
||||
rowActions,
|
||||
roles,
|
||||
} from "stores/builder"
|
||||
import { themeStore, admin, licensing } from "stores/portal"
|
||||
import { TableNames } from "constants"
|
||||
|
@ -26,16 +27,20 @@
|
|||
import GridRowActionsButton from "components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte"
|
||||
import { DB_TYPE_EXTERNAL } from "constants/backend"
|
||||
|
||||
const userSchemaOverrides = {
|
||||
let generateButton
|
||||
|
||||
$: userSchemaOverrides = {
|
||||
firstName: { displayName: "First name", disabled: true },
|
||||
lastName: { displayName: "Last name", disabled: true },
|
||||
email: { displayName: "Email", disabled: true },
|
||||
roleId: { displayName: "Role", disabled: true },
|
||||
status: { displayName: "Status", disabled: true },
|
||||
roleId: {
|
||||
displayName: "Role",
|
||||
type: "role",
|
||||
disabled: true,
|
||||
roles: $roles,
|
||||
},
|
||||
}
|
||||
|
||||
let generateButton
|
||||
|
||||
$: autoColumnStatus = verifyAutocolumns($tables?.selected)
|
||||
$: duplicates = Object.values(autoColumnStatus).reduce((acc, status) => {
|
||||
if (status.length > 1) {
|
||||
|
@ -141,17 +146,11 @@
|
|||
<GridRelationshipButton />
|
||||
{/if}
|
||||
{#if !isUsersTable}
|
||||
<GridRowActionsButton />
|
||||
<GridScreensButton on:request-generate={() => generateButton?.show()} />
|
||||
<GridAutomationsButton
|
||||
on:request-generate={() => generateButton?.show()}
|
||||
/>
|
||||
<GridImportButton />
|
||||
{/if}
|
||||
<GridExportButton />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="controls-right">
|
||||
{#if !isUsersTable}
|
||||
<GridExportButton />
|
||||
<GridRowActionsButton />
|
||||
<GridScreensButton on:generate={() => generateButton?.show()} />
|
||||
<GridAutomationsButton on:generate={() => generateButton?.show()} />
|
||||
<GridGenerateButton bind:this={generateButton} />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
|
|
@ -4,9 +4,11 @@
|
|||
export let title
|
||||
export let body
|
||||
export let icon = "HelpOutline"
|
||||
export let warning = false
|
||||
export let error = false
|
||||
</script>
|
||||
|
||||
<div class="info" class:noTitle={!title}>
|
||||
<div class="info" class:noTitle={!title} class:warning class:error>
|
||||
{#if title}
|
||||
<div class="title">
|
||||
<Icon name={icon} />
|
||||
|
@ -16,7 +18,7 @@
|
|||
{@html body}
|
||||
{:else}
|
||||
<span class="icon">
|
||||
<Icon name={icon} />
|
||||
<Icon size="S" name={icon} />
|
||||
</span>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html body}
|
||||
|
@ -24,6 +26,23 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.info {
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
border-radius: var(--border-radius-s);
|
||||
font-size: 13px;
|
||||
}
|
||||
.warning {
|
||||
background: rgba(255, 200, 0, 0.2);
|
||||
}
|
||||
.error {
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
.noTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
@ -39,17 +58,7 @@
|
|||
.icon {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
.info {
|
||||
padding: var(--spacing-m) var(--spacing-l) var(--spacing-l) var(--spacing-l);
|
||||
background-color: var(--background-alt);
|
||||
border-radius: var(--border-radius-s);
|
||||
font-size: 13px;
|
||||
}
|
||||
.noTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.info :global(a) {
|
||||
color: inherit;
|
||||
transition: color 130ms ease-out;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script>
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { Tooltip, StatusLight } from "@budibase/bbui"
|
||||
import { roles } from "stores/builder"
|
||||
import { Roles } from "constants/backend"
|
||||
|
@ -8,12 +7,13 @@
|
|||
|
||||
let showTooltip = false
|
||||
|
||||
$: color = RoleUtils.getRoleColour(roleId)
|
||||
$: role = $roles.find(role => role._id === roleId)
|
||||
$: color =
|
||||
role?.uiMetadata.color || "var(--spectrum-global-color-static-magenta-400)"
|
||||
$: tooltip =
|
||||
roleId === Roles.PUBLIC
|
||||
? "Open to the public"
|
||||
: `Requires ${role?.name} access`
|
||||
: `Requires ${role?.uiMetadata.displayName || "Unknown role"} access`
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
|
|
@ -31,10 +31,7 @@
|
|||
|
||||
async function fetchAIConfig() {
|
||||
try {
|
||||
const aiDoc = await API.getConfig(ConfigTypes.AI)
|
||||
if (aiDoc._id) {
|
||||
fullAIConfig = aiDoc
|
||||
}
|
||||
fullAIConfig = await API.getConfig(ConfigTypes.AI)
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching AI config")
|
||||
}
|
||||
|
@ -66,6 +63,7 @@
|
|||
}
|
||||
// Add new or update existing custom AI Config
|
||||
fullAIConfig.config[id] = editingAIConfig
|
||||
fullAIConfig.type = ConfigTypes.AI
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -4,15 +4,16 @@
|
|||
|
||||
export let value
|
||||
export let row
|
||||
$: count = getCount(Object.keys(value || {}).length)
|
||||
|
||||
const getCount = () => {
|
||||
$: count = getCount(row, value)
|
||||
|
||||
const getCount = (row, value) => {
|
||||
return sdk.users.hasAppBuilderPermissions(row)
|
||||
? row.builder.apps.length +
|
||||
Object.keys(row.roles || {}).filter(appId =>
|
||||
row.builder.apps.includes(appId)
|
||||
).length
|
||||
: value?.length || 0
|
||||
: Object.keys(value || {}).length
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,23 +1,28 @@
|
|||
<script>
|
||||
import { StatusLight } from "@budibase/bbui"
|
||||
import { RoleUtils, Constants } from "@budibase/frontend-core"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { roles } from "stores/builder"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
export let value
|
||||
|
||||
$: role = $roles.find(x => x._id === value)
|
||||
|
||||
const getRoleLabel = roleId => {
|
||||
const role = $roles.find(x => x._id === roleId)
|
||||
return roleId === Constants.Roles.CREATOR
|
||||
? capitalise(Constants.Roles.CREATOR.toLowerCase())
|
||||
: role?.name || "Custom role"
|
||||
: role?.uiMetadata.displayName || role?.name || "Custom role"
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if value === Constants.Roles.CREATOR}
|
||||
Can edit
|
||||
{:else}
|
||||
<StatusLight square color={RoleUtils.getRoleColour(value)}>
|
||||
<StatusLight
|
||||
square
|
||||
color={role?.uiMetadata.color ||
|
||||
"var(--spectrum-global-color-static-magenta-400)"}
|
||||
>
|
||||
Can use as {getRoleLabel(value)}
|
||||
</StatusLight>
|
||||
{/if}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<script>
|
||||
import TagsTableRenderer from "./TagsTableRenderer.svelte"
|
||||
|
||||
export let value
|
||||
|
||||
$: roles = value?.filter(role => role != null).map(role => role.name) ?? []
|
||||
</script>
|
||||
|
||||
<TagsTableRenderer value={roles} />
|
|
@ -1,35 +0,0 @@
|
|||
<script>
|
||||
import { Tag, Tags } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
|
||||
const displayLimit = 5
|
||||
|
||||
$: values = value?.filter(value => value != null) ?? []
|
||||
$: tags = values.slice(0, displayLimit)
|
||||
$: leftover = values.length - tags.length
|
||||
</script>
|
||||
|
||||
<div class="tag-renderer">
|
||||
<Tags>
|
||||
{#each tags as tag}
|
||||
<Tag>
|
||||
{tag}
|
||||
</Tag>
|
||||
{/each}
|
||||
{#if leftover}
|
||||
<Tag>+{leftover} more</Tag>
|
||||
{/if}
|
||||
</Tags>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-renderer :global(.spectrum-Tags-item:hover) {
|
||||
color: var(--spectrum-alias-label-text-color);
|
||||
border-color: var(--spectrum-alias-border-color-darker-default);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-renderer :global(.spectrum-Tags-itemLabel) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -1,74 +0,0 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
|
||||
import { users } from "stores/portal"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
export let app
|
||||
export let user
|
||||
|
||||
const NO_ACCESS = "NO_ACCESS"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const roles = app.roles
|
||||
let options = roles
|
||||
.filter(role => role._id !== "PUBLIC")
|
||||
.map(role => ({ value: role._id, label: role.name }))
|
||||
|
||||
if (!sdk.users.isBuilder(user, app?.appId)) {
|
||||
options.push({ value: NO_ACCESS, label: "No Access" })
|
||||
}
|
||||
let selectedRole = user?.roles?.[app?._id]
|
||||
|
||||
async function updateUserRoles() {
|
||||
try {
|
||||
if (selectedRole === NO_ACCESS) {
|
||||
// Remove the user role
|
||||
const filteredRoles = { ...user.roles }
|
||||
delete filteredRoles[app?._id]
|
||||
await users.save({
|
||||
...user,
|
||||
roles: {
|
||||
...filteredRoles,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Add the user role
|
||||
await users.save({
|
||||
...user,
|
||||
roles: {
|
||||
...user.roles,
|
||||
[app._id]: selectedRole,
|
||||
},
|
||||
})
|
||||
}
|
||||
notifications.success("Role updated")
|
||||
dispatch("update")
|
||||
} catch (error) {
|
||||
notifications.error("Failed to update role")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={updateUserRoles}
|
||||
title="Update App Role"
|
||||
confirmText="Update role"
|
||||
cancelText="Cancel"
|
||||
size="M"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body>
|
||||
Update {user.email}'s role for <strong>{app.name}</strong>.
|
||||
</Body>
|
||||
<Select
|
||||
placeholder={null}
|
||||
bind:value={selectedRole}
|
||||
on:change
|
||||
{options}
|
||||
label="Role"
|
||||
getOptionLabel={role => role.label}
|
||||
getOptionValue={role => role.value}
|
||||
/>
|
||||
</ModalContent>
|
|
@ -1,16 +1,34 @@
|
|||
import { writable } from "svelte/store"
|
||||
import { derived, writable, get } from "svelte/store"
|
||||
import { API } from "api"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
|
||||
export function createRolesStore() {
|
||||
const { subscribe, update, set } = writable([])
|
||||
const store = writable([])
|
||||
const enriched = derived(store, $store => {
|
||||
return $store.map(role => ({
|
||||
...role,
|
||||
|
||||
// Ensure we have new metadata for all roles
|
||||
uiMetadata: {
|
||||
displayName: role.uiMetadata?.displayName || role.name,
|
||||
color:
|
||||
role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)",
|
||||
description: role.uiMetadata?.description || "Custom role",
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
function setRoles(roles) {
|
||||
set(
|
||||
store.set(
|
||||
roles.sort((a, b) => {
|
||||
const priorityA = RoleUtils.getRolePriority(a._id)
|
||||
const priorityB = RoleUtils.getRolePriority(b._id)
|
||||
return priorityA > priorityB ? -1 : 1
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA > priorityB ? -1 : 1
|
||||
}
|
||||
const nameA = a.uiMetadata?.displayName || a.name
|
||||
const nameB = b.uiMetadata?.displayName || b.name
|
||||
return nameA < nameB ? -1 : 1
|
||||
})
|
||||
)
|
||||
}
|
||||
|
@ -29,17 +47,43 @@ export function createRolesStore() {
|
|||
roleId: role?._id,
|
||||
roleRev: role?._rev,
|
||||
})
|
||||
update(state => state.filter(existing => existing._id !== role._id))
|
||||
await actions.fetch()
|
||||
},
|
||||
save: async role => {
|
||||
const savedRole = await API.saveRole(role)
|
||||
await actions.fetch()
|
||||
return savedRole
|
||||
},
|
||||
replace: (roleId, role) => {
|
||||
// Handles external updates of roles
|
||||
if (!roleId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle deletion
|
||||
if (!role) {
|
||||
store.update(state => state.filter(x => x._id !== roleId))
|
||||
return
|
||||
}
|
||||
|
||||
// Add new role
|
||||
const index = get(store).findIndex(x => x._id === role._id)
|
||||
if (index === -1) {
|
||||
store.update(state => [...state, role])
|
||||
}
|
||||
|
||||
// Update existing role
|
||||
else if (role) {
|
||||
store.update(state => {
|
||||
state[index] = role
|
||||
return [...state]
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
subscribe: enriched.subscribe,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { API } from "api"
|
|||
import { dataFilters } from "@budibase/shared-core"
|
||||
|
||||
function convertToSearchFilters(view) {
|
||||
// convert from SearchFilterGroup type
|
||||
// convert from UISearchFilter type
|
||||
if (view?.query) {
|
||||
return {
|
||||
...view,
|
||||
|
@ -15,7 +15,7 @@ function convertToSearchFilters(view) {
|
|||
return view
|
||||
}
|
||||
|
||||
function convertToSearchFilterGroup(view) {
|
||||
function convertToUISearchFilter(view) {
|
||||
if (view?.queryUI) {
|
||||
return {
|
||||
...view,
|
||||
|
@ -36,7 +36,7 @@ export function createViewsV2Store() {
|
|||
const views = Object.values(table?.views || {}).filter(view => {
|
||||
return view.version === 2
|
||||
})
|
||||
list = list.concat(views.map(view => convertToSearchFilterGroup(view)))
|
||||
list = list.concat(views.map(view => convertToUISearchFilter(view)))
|
||||
})
|
||||
return {
|
||||
...$store,
|
||||
|
@ -77,7 +77,7 @@ export function createViewsV2Store() {
|
|||
if (!viewId) {
|
||||
return
|
||||
}
|
||||
view = convertToSearchFilterGroup(view)
|
||||
view = convertToUISearchFilter(view)
|
||||
const existingView = get(derivedStore).list.find(view => view.id === viewId)
|
||||
const tableIndex = get(tables).list.findIndex(table => {
|
||||
return table._id === view?.tableId || table._id === existingView?.tableId
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
snippets,
|
||||
datasources,
|
||||
tables,
|
||||
roles,
|
||||
} from "stores/builder"
|
||||
import { get } from "svelte/store"
|
||||
import { auth, appsStore } from "stores/portal"
|
||||
|
@ -56,12 +57,18 @@ export const createBuilderWebsocket = appId => {
|
|||
datasources.replaceDatasource(id, datasource)
|
||||
})
|
||||
|
||||
// Role events
|
||||
socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => {
|
||||
roles.replace(id, role)
|
||||
})
|
||||
|
||||
// Design section events
|
||||
socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
|
||||
screenStore.replace(id, screen)
|
||||
})
|
||||
|
||||
// App events
|
||||
socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
|
||||
//Sync app metadata across the stores
|
||||
appStore.syncMetadata(metadata)
|
||||
themeStore.syncMetadata(metadata)
|
||||
navigationStore.syncMetadata(metadata)
|
||||
|
@ -79,7 +86,7 @@ export const createBuilderWebsocket = appId => {
|
|||
}
|
||||
)
|
||||
|
||||
// Automations
|
||||
// Automation events
|
||||
socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => {
|
||||
automationStore.actions.replace(id, automation)
|
||||
})
|
||||
|
|
|
@ -1,28 +1,29 @@
|
|||
<script>
|
||||
import { Heading, Select, ActionButton } from "@budibase/bbui"
|
||||
import { devToolsStore, appStore, roleStore } from "../../stores"
|
||||
import { devToolsStore, appStore } from "../../stores"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { API } from "api"
|
||||
|
||||
const context = getContext("context")
|
||||
const SELF_ROLE = "self"
|
||||
|
||||
let staticRoleList
|
||||
let roles
|
||||
|
||||
$: previewOptions = buildRoleList(staticRoleList)
|
||||
$: previewOptions = buildRoleList(roles)
|
||||
|
||||
function buildRoleList(roleIds) {
|
||||
function buildRoleList(roles) {
|
||||
const list = []
|
||||
list.push({
|
||||
label: "View as yourself",
|
||||
value: SELF_ROLE,
|
||||
})
|
||||
if (!roleIds) {
|
||||
if (!roles) {
|
||||
return list
|
||||
}
|
||||
for (let roleId of roleIds) {
|
||||
for (let role of roles) {
|
||||
list.push({
|
||||
label: `View as ${roleId.toLowerCase()} user`,
|
||||
value: roleId,
|
||||
label: `View as ${role.uiMetadata?.displayName || role.name}`,
|
||||
value: role._id,
|
||||
})
|
||||
}
|
||||
return list
|
||||
|
@ -31,7 +32,7 @@
|
|||
onMount(async () => {
|
||||
// make sure correct before starting
|
||||
await devToolsStore.actions.changeRole(SELF_ROLE)
|
||||
staticRoleList = await roleStore.actions.fetchAccessibleRoles()
|
||||
roles = await API.getRoles()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
} from "@budibase/bbui"
|
||||
import {
|
||||
FieldType,
|
||||
FilterGroupLogicalOperator,
|
||||
UILogicalOperator,
|
||||
EmptyFilterOption,
|
||||
} from "@budibase/types"
|
||||
import { QueryUtils, Constants } from "@budibase/frontend-core"
|
||||
|
@ -230,7 +230,7 @@
|
|||
} else if (addGroup) {
|
||||
if (!editable?.groups?.length) {
|
||||
editable = {
|
||||
logicalOperator: FilterGroupLogicalOperator.ALL,
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
|
||||
groups: [],
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
focus: () => api?.focus?.(),
|
||||
blur: () => api?.blur?.(),
|
||||
isActive: () => api?.isActive?.() ?? false,
|
||||
onKeyDown: (...params) => api?.onKeyDown(...params),
|
||||
onKeyDown: (...params) => api?.onKeyDown?.(...params),
|
||||
isReadonly: () => readonly,
|
||||
getType: () => column.schema.type,
|
||||
getValue: () => row[column.name],
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
<script>
|
||||
import { StatusLight } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
|
||||
$: role = schema.roles?.find(x => x._id === value)
|
||||
</script>
|
||||
|
||||
<div class="role-cell">
|
||||
<div class="light">
|
||||
<StatusLight
|
||||
square
|
||||
size="L"
|
||||
color={role?.uiMetadata?.color ||
|
||||
"var(--spectrum-global-color-static-magenta-400)"}
|
||||
/>
|
||||
</div>
|
||||
<div class="value">
|
||||
{role?.uiMetadata?.displayName || role?.name || "Unknown role"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.role-cell {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--cell-padding);
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
gap: var(--cell-padding);
|
||||
}
|
||||
.light {
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
|
@ -16,6 +16,7 @@ import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
|
|||
import BBReferenceCell from "../cells/BBReferenceCell.svelte"
|
||||
import SignatureCell from "../cells/SignatureCell.svelte"
|
||||
import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
|
||||
import RoleCell from "../cells/RoleCell.svelte"
|
||||
|
||||
const TypeComponentMap = {
|
||||
[FieldType.STRING]: TextCell,
|
||||
|
@ -35,6 +36,9 @@ const TypeComponentMap = {
|
|||
[FieldType.JSON]: JSONCell,
|
||||
[FieldType.BB_REFERENCE]: BBReferenceCell,
|
||||
[FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell,
|
||||
|
||||
// Custom types for UI only
|
||||
role: RoleCell,
|
||||
}
|
||||
export const getCellRenderer = column => {
|
||||
return (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { get, derived } from "svelte/store"
|
||||
import { FieldType, FilterGroupLogicalOperator } from "@budibase/types"
|
||||
import { FieldType, UILogicalOperator } from "@budibase/types"
|
||||
import { memo } from "../../../utils/memo"
|
||||
|
||||
export const createStores = context => {
|
||||
|
@ -25,10 +25,10 @@ export const deriveStores = context => {
|
|||
return $filter
|
||||
}
|
||||
let allFilters = {
|
||||
logicalOperator: FilterGroupLogicalOperator.ALL,
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
groups: [
|
||||
{
|
||||
logicalOperator: FilterGroupLogicalOperator.ALL,
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
filters: $inlineFilters,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -7,20 +7,7 @@ const RolePriorities = {
|
|||
[Roles.BASIC]: 2,
|
||||
[Roles.PUBLIC]: 1,
|
||||
}
|
||||
const RoleColours = {
|
||||
[Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)",
|
||||
[Roles.CREATOR]: "var(--spectrum-global-color-static-magenta-600)",
|
||||
[Roles.POWER]: "var(--spectrum-global-color-static-orange-400)",
|
||||
[Roles.BASIC]: "var(--spectrum-global-color-static-green-400)",
|
||||
[Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)",
|
||||
}
|
||||
|
||||
export const getRolePriority = role => {
|
||||
return RolePriorities[role] ?? 0
|
||||
}
|
||||
|
||||
export const getRoleColour = roleId => {
|
||||
return (
|
||||
RoleColours[roleId] ?? "var(--spectrum-global-color-static-magenta-400)"
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 297fdc937e9c650b4964fc1a942b60022b195865
|
||||
Subproject commit f6aebba94451ce47bba551926e5ad72bd75f71c6
|
|
@ -18,9 +18,11 @@ import {
|
|||
UserCtx,
|
||||
UserMetadata,
|
||||
DocumentType,
|
||||
PermissionLevel,
|
||||
} from "@budibase/types"
|
||||
import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core"
|
||||
import sdk from "../../sdk"
|
||||
import { builderSocket } from "../../websockets"
|
||||
|
||||
const UpdateRolesOptions = {
|
||||
CREATED: "created",
|
||||
|
@ -34,11 +36,11 @@ async function removeRoleFromOthers(roleId: string) {
|
|||
let changed = false
|
||||
if (Array.isArray(role.inherits)) {
|
||||
const newInherits = role.inherits.filter(
|
||||
id => !roles.compareRoleIds(id, roleId)
|
||||
id => !roles.roleIDsAreEqual(id, roleId)
|
||||
)
|
||||
changed = role.inherits.length !== newInherits.length
|
||||
role.inherits = newInherits
|
||||
} else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) {
|
||||
} else if (role.inherits && roles.roleIDsAreEqual(role.inherits, roleId)) {
|
||||
role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||
changed = true
|
||||
}
|
||||
|
@ -124,6 +126,17 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
|||
ctx.throw(400, "Cannot change custom role name")
|
||||
}
|
||||
|
||||
// custom roles should always inherit basic - if they don't inherit anything else
|
||||
if (!inherits && roles.validInherits(allRoles, dbRole?.inherits)) {
|
||||
inherits = dbRole?.inherits
|
||||
} else if (!roles.validInherits(allRoles, inherits)) {
|
||||
inherits = [roles.BUILTIN_ROLE_IDS.BASIC]
|
||||
}
|
||||
// assume write permission level for newly created roles
|
||||
if (isCreate && !permissionId) {
|
||||
permissionId = PermissionLevel.WRITE
|
||||
}
|
||||
|
||||
const role = new roles.Role(_id, name, permissionId, {
|
||||
displayName: uiMetadata?.displayName || name,
|
||||
description: uiMetadata?.description || "Custom role",
|
||||
|
@ -177,6 +190,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
|
|||
},
|
||||
})
|
||||
}
|
||||
builderSocket?.emitRoleUpdate(ctx, role)
|
||||
}
|
||||
|
||||
export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
|
||||
|
@ -216,6 +230,7 @@ export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
|
|||
|
||||
ctx.message = `Role ${ctx.params.roleId} deleted successfully`
|
||||
ctx.status = 200
|
||||
builderSocket?.emitRoleDeletion(ctx, role)
|
||||
}
|
||||
|
||||
export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
|
||||
|
@ -223,35 +238,23 @@ export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
|
|||
if (!roleId) {
|
||||
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||
}
|
||||
// If a custom role is provided in the header, filter out higher level roles
|
||||
const roleHeader = ctx.header[Header.PREVIEW_ROLE]
|
||||
if (Array.isArray(roleHeader)) {
|
||||
ctx.throw(400, `Too many roles specified in ${Header.PREVIEW_ROLE} header`)
|
||||
}
|
||||
const isBuilder = ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)
|
||||
let roleIds: string[] = []
|
||||
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
|
||||
if (!roleHeader && isBuilder) {
|
||||
const appId = context.getAppId()
|
||||
if (appId) {
|
||||
roleIds = await roles.getAllRoleIds(appId)
|
||||
}
|
||||
} else if (isBuilder && roleHeader) {
|
||||
roleIds = await roles.getUserRoleIdHierarchy(roleHeader)
|
||||
} else {
|
||||
roleIds = await roles.getUserRoleIdHierarchy(roleId!)
|
||||
}
|
||||
|
||||
// If a custom role is provided in the header, filter out higher level roles
|
||||
const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
|
||||
if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
|
||||
const role = await roles.getRole(roleHeader)
|
||||
const inherits = role?.inherits
|
||||
const orderedRoles = roleIds.reverse()
|
||||
let filteredRoles = [roleHeader]
|
||||
for (let role of orderedRoles) {
|
||||
filteredRoles = [role, ...filteredRoles]
|
||||
if (
|
||||
(Array.isArray(inherits) && inherits.includes(role)) ||
|
||||
role === inherits
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
filteredRoles.pop()
|
||||
roleIds = [roleHeader, ...filteredRoles]
|
||||
}
|
||||
|
||||
ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId))
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ import {
|
|||
CreateRowActionRequest,
|
||||
Ctx,
|
||||
RowActionPermissions,
|
||||
RowActionPermissionsResponse,
|
||||
RowActionResponse,
|
||||
RowActionsResponse,
|
||||
UpdateRowActionRequest,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../../sdk"
|
||||
|
||||
|
@ -30,6 +30,7 @@ export async function find(ctx: Ctx<void, RowActionsResponse>) {
|
|||
}
|
||||
|
||||
const { actions } = rowActions
|
||||
const automationNames = await sdk.rowActions.getNames(rowActions)
|
||||
const result: RowActionsResponse = {
|
||||
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
|
||||
(acc, [key, action]) => ({
|
||||
|
@ -37,7 +38,7 @@ export async function find(ctx: Ctx<void, RowActionsResponse>) {
|
|||
[key]: {
|
||||
id: key,
|
||||
tableId,
|
||||
name: action.name,
|
||||
name: automationNames[action.automationId],
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
},
|
||||
|
@ -68,26 +69,6 @@ export async function create(
|
|||
ctx.status = 201
|
||||
}
|
||||
|
||||
export async function update(
|
||||
ctx: Ctx<UpdateRowActionRequest, RowActionResponse>
|
||||
) {
|
||||
const table = await getTable(ctx)
|
||||
const tableId = table._id!
|
||||
const { actionId } = ctx.params
|
||||
|
||||
const action = await sdk.rowActions.update(tableId, actionId, {
|
||||
name: ctx.request.body.name,
|
||||
})
|
||||
|
||||
ctx.body = {
|
||||
tableId,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(ctx: Ctx<void, void>) {
|
||||
const table = await getTable(ctx)
|
||||
const { actionId } = ctx.params
|
||||
|
@ -96,22 +77,22 @@ export async function remove(ctx: Ctx<void, void>) {
|
|||
ctx.status = 204
|
||||
}
|
||||
|
||||
export async function setTablePermission(ctx: Ctx<void, RowActionResponse>) {
|
||||
export async function setTablePermission(
|
||||
ctx: Ctx<void, RowActionPermissionsResponse>
|
||||
) {
|
||||
const table = await getTable(ctx)
|
||||
const tableId = table._id!
|
||||
const { actionId } = ctx.params
|
||||
|
||||
const action = await sdk.rowActions.setTablePermission(tableId, actionId)
|
||||
ctx.body = {
|
||||
tableId,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsetTablePermission(ctx: Ctx<void, RowActionResponse>) {
|
||||
export async function unsetTablePermission(
|
||||
ctx: Ctx<void, RowActionPermissionsResponse>
|
||||
) {
|
||||
const table = await getTable(ctx)
|
||||
const tableId = table._id!
|
||||
const { actionId } = ctx.params
|
||||
|
@ -119,15 +100,13 @@ export async function unsetTablePermission(ctx: Ctx<void, RowActionResponse>) {
|
|||
const action = await sdk.rowActions.unsetTablePermission(tableId, actionId)
|
||||
|
||||
ctx.body = {
|
||||
tableId,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
}
|
||||
}
|
||||
|
||||
export async function setViewPermission(ctx: Ctx<void, RowActionResponse>) {
|
||||
export async function setViewPermission(
|
||||
ctx: Ctx<void, RowActionPermissionsResponse>
|
||||
) {
|
||||
const table = await getTable(ctx)
|
||||
const tableId = table._id!
|
||||
const { actionId, viewId } = ctx.params
|
||||
|
@ -138,15 +117,13 @@ export async function setViewPermission(ctx: Ctx<void, RowActionResponse>) {
|
|||
viewId
|
||||
)
|
||||
ctx.body = {
|
||||
tableId,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsetViewPermission(ctx: Ctx<void, RowActionResponse>) {
|
||||
export async function unsetViewPermission(
|
||||
ctx: Ctx<void, RowActionPermissionsResponse>
|
||||
) {
|
||||
const table = await getTable(ctx)
|
||||
const tableId = table._id!
|
||||
const { actionId, viewId } = ctx.params
|
||||
|
@ -158,10 +135,6 @@ export async function unsetViewPermission(ctx: Ctx<void, RowActionResponse>) {
|
|||
)
|
||||
|
||||
ctx.body = {
|
||||
tableId,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
allowedSources: flattenAllowedSources(tableId, action.permissions),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,12 +40,6 @@ router
|
|||
rowActionValidator(),
|
||||
rowActionController.create
|
||||
)
|
||||
.put(
|
||||
"/api/tables/:tableId/actions/:actionId",
|
||||
authorized(BUILDER),
|
||||
rowActionValidator(),
|
||||
rowActionController.update
|
||||
)
|
||||
.delete(
|
||||
"/api/tables/:tableId/actions/:actionId",
|
||||
authorized(BUILDER),
|
||||
|
|
|
@ -38,6 +38,26 @@ describe("/roles", () => {
|
|||
_id: dbCore.prefixRoleID(res._id!),
|
||||
})
|
||||
})
|
||||
|
||||
it("handle a role with invalid inherits", async () => {
|
||||
const role = basicRole()
|
||||
role.inherits = ["not_real", "some_other_not_real"]
|
||||
|
||||
const res = await config.api.roles.save(role, {
|
||||
status: 200,
|
||||
})
|
||||
expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
|
||||
})
|
||||
|
||||
it("handle a role with no inherits", async () => {
|
||||
const role = basicRole()
|
||||
role.inherits = []
|
||||
|
||||
const res = await config.api.roles.save(role, {
|
||||
status: 200,
|
||||
})
|
||||
expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
|
@ -149,6 +169,17 @@ describe("/roles", () => {
|
|||
{ status: 400, body: { message: LOOP_ERROR } }
|
||||
)
|
||||
})
|
||||
|
||||
it("handle updating a role, without its inherits", async () => {
|
||||
const res = await config.api.roles.save({
|
||||
...basicRole(),
|
||||
inherits: [BUILTIN_ROLE_IDS.ADMIN],
|
||||
})
|
||||
// remove the roles so that it will default back to DB roles, then save again
|
||||
delete res.inherits
|
||||
const updatedRes = await config.api.roles.save(res)
|
||||
expect(updatedRes.inherits).toEqual([BUILTIN_ROLE_IDS.ADMIN])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
|
@ -298,6 +329,23 @@ describe("/roles", () => {
|
|||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("should fetch preview role correctly even without basic specified", async () => {
|
||||
const role = await config.api.roles.save(basicRole())
|
||||
// have to forcefully delete the inherits from DB - technically can't
|
||||
// happen anymore - but good test case
|
||||
await dbCore.getDB(config.appId!).put({
|
||||
...role,
|
||||
_id: dbCore.prefixRoleID(role._id!),
|
||||
inherits: [],
|
||||
})
|
||||
await config.withHeaders({ "x-budibase-role": role.name }, async () => {
|
||||
const res = await config.api.roles.accessible({
|
||||
status: 200,
|
||||
})
|
||||
expect(res).toEqual([role.name])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("accessible - multi-inheritance", () => {
|
||||
|
|
|
@ -328,129 +328,6 @@ describe("/rowsActions", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
unauthorisedTests((expectations, testConfig) =>
|
||||
config.api.rowAction.update(
|
||||
tableId,
|
||||
generator.guid(),
|
||||
createRowActionRequest(),
|
||||
expectations,
|
||||
testConfig
|
||||
)
|
||||
)
|
||||
|
||||
it("can update existing actions", async () => {
|
||||
for (const rowAction of createRowActionRequests(3)) {
|
||||
await createRowAction(tableId, rowAction)
|
||||
}
|
||||
|
||||
const persisted = await config.api.rowAction.find(tableId)
|
||||
|
||||
const [actionId, actionData] = _.sample(
|
||||
Object.entries(persisted.actions)
|
||||
)!
|
||||
|
||||
const updatedName = generator.string()
|
||||
|
||||
const res = await config.api.rowAction.update(tableId, actionId, {
|
||||
name: updatedName,
|
||||
})
|
||||
|
||||
expect(res).toEqual({
|
||||
id: actionId,
|
||||
tableId,
|
||||
name: updatedName,
|
||||
automationId: actionData.automationId,
|
||||
allowedSources: [tableId],
|
||||
})
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
actions: expect.objectContaining({
|
||||
[actionId]: {
|
||||
name: updatedName,
|
||||
id: actionData.id,
|
||||
tableId: actionData.tableId,
|
||||
automationId: actionData.automationId,
|
||||
allowedSources: [tableId],
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("trims row action names", async () => {
|
||||
const rowAction = await createRowAction(tableId, createRowActionRequest())
|
||||
|
||||
const res = await config.api.rowAction.update(tableId, rowAction.id, {
|
||||
name: " action name ",
|
||||
})
|
||||
|
||||
expect(res).toEqual(expect.objectContaining({ name: "action name" }))
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
actions: expect.objectContaining({
|
||||
[rowAction.id]: expect.objectContaining({
|
||||
name: "action name",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("throws Bad Request when trying to update by a non-existing id", async () => {
|
||||
await createRowAction(tableId, createRowActionRequest())
|
||||
|
||||
await config.api.rowAction.update(
|
||||
tableId,
|
||||
generator.guid(),
|
||||
createRowActionRequest(),
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it("throws Bad Request when trying to update by a via another table id", async () => {
|
||||
const otherTable = await config.api.table.save(
|
||||
setup.structures.basicTable()
|
||||
)
|
||||
await createRowAction(otherTable._id!, createRowActionRequest())
|
||||
|
||||
const action = await createRowAction(tableId, createRowActionRequest())
|
||||
await config.api.rowAction.update(
|
||||
otherTable._id!,
|
||||
action.id,
|
||||
createRowActionRequest(),
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it("can not use existing row action names (for the same table)", async () => {
|
||||
const action1 = await createRowAction(tableId, createRowActionRequest())
|
||||
const action2 = await createRowAction(tableId, createRowActionRequest())
|
||||
|
||||
await config.api.rowAction.update(
|
||||
tableId,
|
||||
action1.id,
|
||||
{ name: action2.name },
|
||||
{
|
||||
status: 409,
|
||||
body: {
|
||||
message: "A row action with the same name already exists.",
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("does not throw with name conflicts for the same row action", async () => {
|
||||
const action1 = await createRowAction(tableId, createRowActionRequest())
|
||||
|
||||
await config.api.rowAction.update(tableId, action1.id, {
|
||||
name: action1.name,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
unauthorisedTests((expectations, testConfig) =>
|
||||
config.api.rowAction.delete(
|
||||
|
|
|
@ -86,7 +86,6 @@ describe("/screens", () => {
|
|||
status: 200,
|
||||
}
|
||||
)
|
||||
// basic and role1 screen
|
||||
expect(res.screens.length).toEqual(screenIds.length)
|
||||
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
|
||||
})
|
||||
|
@ -107,6 +106,25 @@ describe("/screens", () => {
|
|||
screen2._id!,
|
||||
])
|
||||
})
|
||||
|
||||
it("should be able to fetch basic and screen 1 with role1 in role header", async () => {
|
||||
await config.withHeaders(
|
||||
{
|
||||
"x-budibase-role": role1._id!,
|
||||
},
|
||||
async () => {
|
||||
const res = await config.api.application.getDefinition(
|
||||
config.prodAppId!,
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
const screenIds = [screen._id!, screen1._id!]
|
||||
expect(res.screens.length).toEqual(screenIds.length)
|
||||
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("save", () => {
|
||||
|
|
|
@ -1688,7 +1688,7 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
|
||||
describe("arrays", () => {
|
||||
beforeAll(async () => {
|
||||
tableOrViewId = await createTableOrView({
|
||||
numbers: {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -18,6 +18,7 @@ import * as loop from "./steps/loop"
|
|||
import * as collect from "./steps/collect"
|
||||
import * as branch from "./steps/branch"
|
||||
import * as triggerAutomationRun from "./steps/triggerAutomationRun"
|
||||
import * as openai from "./steps/openai"
|
||||
import env from "../environment"
|
||||
import {
|
||||
PluginType,
|
||||
|
@ -50,6 +51,7 @@ const ACTION_IMPLS: ActionImplType = {
|
|||
QUERY_ROWS: queryRow.run,
|
||||
COLLECT: collect.run,
|
||||
TRIGGER_AUTOMATION_RUN: triggerAutomationRun.run,
|
||||
OPENAI: openai.run,
|
||||
// these used to be lowercase step IDs, maintain for backwards compat
|
||||
discord: discord.run,
|
||||
slack: slack.run,
|
||||
|
@ -89,21 +91,25 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<
|
|||
// ran at all
|
||||
if (env.SELF_HOSTED) {
|
||||
const bash = require("./steps/bash")
|
||||
const openai = require("./steps/openai")
|
||||
|
||||
// @ts-ignore
|
||||
ACTION_IMPLS["EXECUTE_BASH"] = bash.run
|
||||
// @ts-ignore
|
||||
BUILTIN_ACTION_DEFINITIONS["EXECUTE_BASH"] = bash.definition
|
||||
// @ts-ignore
|
||||
ACTION_IMPLS.OPENAI = openai.run
|
||||
BUILTIN_ACTION_DEFINITIONS.OPENAI = openai.definition
|
||||
}
|
||||
|
||||
export async function getActionDefinitions() {
|
||||
if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
|
||||
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
|
||||
}
|
||||
if (
|
||||
env.SELF_HOSTED ||
|
||||
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
|
||||
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
|
||||
) {
|
||||
BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
|
||||
}
|
||||
|
||||
const actionDefinitions = BUILTIN_ACTION_DEFINITIONS
|
||||
if (env.SELF_HOSTED) {
|
||||
const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION)
|
||||
|
|
|
@ -56,22 +56,9 @@ export default async (ctx: UserCtx, next: any) => {
|
|||
ctx.request &&
|
||||
(ctx.request.headers[constants.Header.PREVIEW_ROLE] as string)
|
||||
if (isBuilder && isDevApp && roleHeader) {
|
||||
// Ensure the role is valid by ensuring a definition exists
|
||||
try {
|
||||
if (roleHeader) {
|
||||
const role = await roles.getRole(roleHeader)
|
||||
if (role) {
|
||||
roleId = roleHeader
|
||||
|
||||
// Delete admin and builder flags so that the specified role is honoured
|
||||
ctx.user = users.removePortalUserPermissions(
|
||||
ctx.user
|
||||
) as ContextUser
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Swallow error and do nothing
|
||||
}
|
||||
roleId = roleHeader
|
||||
// Delete admin and builder flags so that the specified role is honoured
|
||||
ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { sdk } from "@budibase/shared-core"
|
||||
import {
|
||||
Automation,
|
||||
RequiredKeys,
|
||||
|
@ -99,6 +98,12 @@ export async function get(automationId: string) {
|
|||
return trimUnexpectedObjectFields(result)
|
||||
}
|
||||
|
||||
export async function find(ids: string[]) {
|
||||
const db = getDb()
|
||||
const result = await db.getMultiple<PersistedAutomation>(ids)
|
||||
return result.map(trimUnexpectedObjectFields)
|
||||
}
|
||||
|
||||
export async function create(automation: Automation) {
|
||||
automation = trimUnexpectedObjectFields(automation)
|
||||
const db = getDb()
|
||||
|
@ -289,13 +294,6 @@ function guardInvalidUpdatesAndThrow(
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
sdk.automations.isRowAction(automation) &&
|
||||
automation.name !== oldAutomation.name
|
||||
) {
|
||||
throw new Error("Row actions cannot be renamed")
|
||||
}
|
||||
}
|
||||
|
||||
function trimUnexpectedObjectFields<T extends Automation>(automation: T): T {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { sample } from "lodash/fp"
|
||||
import { Automation, AutomationTriggerStepId } from "@budibase/types"
|
||||
import { Automation } from "@budibase/types"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||
import automationSdk from "../"
|
||||
|
@ -26,25 +26,6 @@ describe("automation sdk", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("cannot rename row action automations", async () => {
|
||||
await config.doInContext(config.getAppId(), async () => {
|
||||
const automation = structures.newAutomation({
|
||||
trigger: {
|
||||
...structures.automationTrigger(),
|
||||
stepId: AutomationTriggerStepId.ROW_ACTION,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await automationSdk.create(automation)
|
||||
|
||||
const newName = generator.guid()
|
||||
const update = { ...response, name: newName }
|
||||
await expect(automationSdk.update(update)).rejects.toThrow(
|
||||
"Row actions cannot be renamed"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
["trigger", (a: Automation) => a.definition.trigger],
|
||||
["step", (a: Automation) => a.definition.steps[0]],
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
Automation,
|
||||
AutomationActionStepId,
|
||||
AutomationBuilderData,
|
||||
TableRowActions,
|
||||
} from "@budibase/types"
|
||||
import { sdk as coreSdk } from "@budibase/shared-core"
|
||||
import sdk from "../../../sdk"
|
||||
|
@ -26,15 +25,6 @@ export async function getBuilderData(
|
|||
return tableNameCache[tableId]
|
||||
}
|
||||
|
||||
const rowActionNameCache: Record<string, TableRowActions | undefined> = {}
|
||||
async function getRowActionName(tableId: string, rowActionId: string) {
|
||||
if (!rowActionNameCache[tableId]) {
|
||||
rowActionNameCache[tableId] = await sdk.rowActions.getAll(tableId)
|
||||
}
|
||||
|
||||
return rowActionNameCache[tableId]?.actions[rowActionId]?.name
|
||||
}
|
||||
|
||||
const result: Record<string, AutomationBuilderData> = {}
|
||||
for (const automation of automations) {
|
||||
const isRowAction = coreSdk.automations.isRowAction(automation)
|
||||
|
@ -49,12 +39,7 @@ export async function getBuilderData(
|
|||
}
|
||||
|
||||
const tableName = await getTableName(tableId)
|
||||
const rowActionName = await getRowActionName(tableId, rowActionId)
|
||||
|
||||
if (!rowActionName) {
|
||||
throw new Error(`Row action not found: ${rowActionId}`)
|
||||
}
|
||||
|
||||
const rowActionName = automation.name
|
||||
result[automation._id!] = {
|
||||
displayName: rowActionName,
|
||||
triggerInfo: {
|
||||
|
|
|
@ -13,16 +13,19 @@ import { definitions as TRIGGER_DEFINITIONS } from "../../automations/triggerInf
|
|||
import * as triggers from "../../automations/triggers"
|
||||
import sdk from ".."
|
||||
|
||||
function ensureUniqueAndThrow(
|
||||
async function ensureUniqueAndThrow(
|
||||
doc: TableRowActions,
|
||||
name: string,
|
||||
existingRowActionId?: string
|
||||
) {
|
||||
const names = await getNames(doc)
|
||||
name = name.toLowerCase().trim()
|
||||
|
||||
if (
|
||||
Object.entries(doc.actions).find(
|
||||
([id, a]) =>
|
||||
a.name.toLowerCase() === name.toLowerCase() &&
|
||||
id !== existingRowActionId
|
||||
Object.entries(names).find(
|
||||
([automationId, automationName]) =>
|
||||
automationName.toLowerCase().trim() === name &&
|
||||
automationId !== existingRowActionId
|
||||
)
|
||||
) {
|
||||
throw new HTTPError("A row action with the same name already exists.", 409)
|
||||
|
@ -34,18 +37,12 @@ export async function create(tableId: string, rowAction: { name: string }) {
|
|||
|
||||
const db = context.getAppDB()
|
||||
const rowActionsId = generateRowActionsID(tableId)
|
||||
let doc: TableRowActions
|
||||
try {
|
||||
doc = await db.get<TableRowActions>(rowActionsId)
|
||||
} catch (e: any) {
|
||||
if (e.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
|
||||
let doc = await db.tryGet<TableRowActions>(rowActionsId)
|
||||
if (!doc) {
|
||||
doc = { _id: rowActionsId, actions: {} }
|
||||
}
|
||||
|
||||
ensureUniqueAndThrow(doc, action.name)
|
||||
await ensureUniqueAndThrow(doc, action.name)
|
||||
|
||||
const appId = context.getAppId()
|
||||
if (!appId) {
|
||||
|
@ -74,7 +71,6 @@ export async function create(tableId: string, rowAction: { name: string }) {
|
|||
})
|
||||
|
||||
doc.actions[newRowActionId] = {
|
||||
name: action.name,
|
||||
automationId: automation._id!,
|
||||
permissions: {
|
||||
table: { runAllowed: true },
|
||||
|
@ -85,6 +81,7 @@ export async function create(tableId: string, rowAction: { name: string }) {
|
|||
|
||||
return {
|
||||
id: newRowActionId,
|
||||
name: automation.name,
|
||||
...doc.actions[newRowActionId],
|
||||
}
|
||||
}
|
||||
|
@ -159,20 +156,6 @@ async function updateDoc(
|
|||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
tableId: string,
|
||||
rowActionId: string,
|
||||
rowActionData: { name: string }
|
||||
) {
|
||||
const newName = rowActionData.name.trim()
|
||||
|
||||
return await updateDoc(tableId, rowActionId, actionsDoc => {
|
||||
ensureUniqueAndThrow(actionsDoc, newName, rowActionId)
|
||||
actionsDoc.actions[rowActionId].name = newName
|
||||
return actionsDoc
|
||||
})
|
||||
}
|
||||
|
||||
async function guardView(tableId: string, viewId: string) {
|
||||
let view
|
||||
if (docIds.isViewId(viewId)) {
|
||||
|
@ -248,13 +231,8 @@ export async function run(
|
|||
throw new HTTPError("Table not found", 404)
|
||||
}
|
||||
|
||||
const rowActions = await getAll(tableId)
|
||||
const rowAction = rowActions?.actions[rowActionId]
|
||||
if (!rowAction) {
|
||||
throw new HTTPError("Row action not found", 404)
|
||||
}
|
||||
|
||||
const automation = await sdk.automations.get(rowAction.automationId)
|
||||
const { automationId } = await get(tableId, rowActionId)
|
||||
const automation = await sdk.automations.get(automationId)
|
||||
|
||||
const row = await sdk.rows.find(tableId, rowId)
|
||||
await triggers.externalTrigger(
|
||||
|
@ -272,3 +250,17 @@ export async function run(
|
|||
{ getResponses: true }
|
||||
)
|
||||
}
|
||||
|
||||
export async function getNames({ actions }: TableRowActions) {
|
||||
const automations = await sdk.automations.find(
|
||||
Object.values(actions).map(({ automationId }) => automationId)
|
||||
)
|
||||
const automationNames = automations.reduce<Record<string, string>>(
|
||||
(names, a) => {
|
||||
names[a._id] = a.name
|
||||
return names
|
||||
},
|
||||
{}
|
||||
)
|
||||
return automationNames
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
Row,
|
||||
RowSearchParams,
|
||||
SearchFilterKey,
|
||||
SearchFilters,
|
||||
SearchResponse,
|
||||
SortOrder,
|
||||
Table,
|
||||
|
@ -90,17 +91,18 @@ export async function search(
|
|||
options = searchInputMapping(table, options)
|
||||
|
||||
if (options.viewId) {
|
||||
// Delete extraneous search params that cannot be overridden
|
||||
delete options.query.onEmptyFilter
|
||||
|
||||
const view = source as ViewV2
|
||||
|
||||
// Enrich saved query with ephemeral query params.
|
||||
// We prevent searching on any fields that are saved as part of the query, as
|
||||
// that could let users find rows they should not be allowed to access.
|
||||
let viewQuery = await enrichSearchContext(view.query || {}, context)
|
||||
viewQuery = dataFilters.buildQueryLegacy(viewQuery) || {}
|
||||
let viewQuery = (await enrichSearchContext(view.query || {}, context)) as
|
||||
| SearchFilters
|
||||
| LegacyFilter[]
|
||||
if (Array.isArray(viewQuery)) {
|
||||
viewQuery = dataFilters.buildQuery(viewQuery)
|
||||
}
|
||||
viewQuery = checkFilters(table, viewQuery)
|
||||
delete viewQuery?.onEmptyFilter
|
||||
|
||||
const sqsEnabled = await features.flags.isEnabled(FeatureFlag.SQS)
|
||||
const supportsLogicalOperators =
|
||||
|
@ -113,13 +115,12 @@ export async function search(
|
|||
? view.query
|
||||
: []
|
||||
|
||||
delete options.query.onEmptyFilter
|
||||
const { filters } = dataFilters.splitFiltersArray(queryFilters)
|
||||
|
||||
// Extract existing fields
|
||||
const existingFields =
|
||||
queryFilters
|
||||
?.filter(filter => filter.field)
|
||||
.map(filter => db.removeKeyNumbering(filter.field)) || []
|
||||
const existingFields = filters.map(filter =>
|
||||
db.removeKeyNumbering(filter.field)
|
||||
)
|
||||
|
||||
// Carry over filters for unused fields
|
||||
Object.keys(options.query).forEach(key => {
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
} from "@budibase/types"
|
||||
import datasources from "../datasources"
|
||||
import sdk from "../../../sdk"
|
||||
import { ensureQueryUISet } from "../views/utils"
|
||||
import { isV2 } from "../views"
|
||||
|
||||
export async function processTable(table: Table): Promise<Table> {
|
||||
if (!table) {
|
||||
|
@ -23,6 +25,14 @@ export async function processTable(table: Table): Promise<Table> {
|
|||
}
|
||||
|
||||
table = { ...table }
|
||||
if (table.views) {
|
||||
for (const [key, view] of Object.entries(table.views)) {
|
||||
if (!isV2(view)) {
|
||||
continue
|
||||
}
|
||||
table.views[key] = ensureQueryUISet(view)
|
||||
}
|
||||
}
|
||||
if (table._id && isExternalTableID(table._id)) {
|
||||
// Old created external tables via Budibase might have a missing field name breaking some UI such as filters
|
||||
if (table.schema["id"] && !table.schema["id"].name) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import sdk from "../../../sdk"
|
|||
import * as utils from "../../../db/utils"
|
||||
import { enrichSchema, isV2 } from "."
|
||||
import { breakExternalTableId } from "../../../integrations/utils"
|
||||
import { ensureQuerySet, ensureQueryUISet } from "./utils"
|
||||
|
||||
export async function get(viewId: string): Promise<ViewV2> {
|
||||
const { tableId } = utils.extractViewInfoFromID(viewId)
|
||||
|
@ -18,7 +19,7 @@ export async function get(viewId: string): Promise<ViewV2> {
|
|||
if (!found) {
|
||||
throw new Error("No view found")
|
||||
}
|
||||
return found
|
||||
return ensureQueryUISet(found)
|
||||
}
|
||||
|
||||
export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
||||
|
@ -33,19 +34,22 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
|||
if (!found) {
|
||||
throw new Error("No view found")
|
||||
}
|
||||
return await enrichSchema(found, table.schema)
|
||||
return await enrichSchema(ensureQueryUISet(found), table.schema)
|
||||
}
|
||||
|
||||
export async function create(
|
||||
tableId: string,
|
||||
viewRequest: Omit<ViewV2, "id" | "version">
|
||||
): Promise<ViewV2> {
|
||||
const view: ViewV2 = {
|
||||
let view: ViewV2 = {
|
||||
...viewRequest,
|
||||
id: utils.generateViewID(tableId),
|
||||
version: 2,
|
||||
}
|
||||
|
||||
view = ensureQuerySet(view)
|
||||
view = ensureQueryUISet(view)
|
||||
|
||||
const db = context.getAppDB()
|
||||
|
||||
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||
|
@ -56,7 +60,10 @@ export async function create(
|
|||
return view
|
||||
}
|
||||
|
||||
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
||||
export async function update(
|
||||
tableId: string,
|
||||
view: Readonly<ViewV2>
|
||||
): Promise<ViewV2> {
|
||||
const db = context.getAppDB()
|
||||
|
||||
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||
|
@ -74,6 +81,9 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
|||
throw new HTTPError(`Cannot update view type after creation`, 400)
|
||||
}
|
||||
|
||||
view = ensureQuerySet(view)
|
||||
view = ensureQueryUISet(view)
|
||||
|
||||
delete views[existingView.name]
|
||||
views[view.name] = view
|
||||
await db.put(ds)
|
||||
|
|
|
@ -4,6 +4,7 @@ import { context, HTTPError } from "@budibase/backend-core"
|
|||
import sdk from "../../../sdk"
|
||||
import * as utils from "../../../db/utils"
|
||||
import { enrichSchema, isV2 } from "."
|
||||
import { ensureQuerySet, ensureQueryUISet } from "./utils"
|
||||
|
||||
export async function get(viewId: string): Promise<ViewV2> {
|
||||
const { tableId } = utils.extractViewInfoFromID(viewId)
|
||||
|
@ -13,7 +14,7 @@ export async function get(viewId: string): Promise<ViewV2> {
|
|||
if (!found) {
|
||||
throw new Error("No view found")
|
||||
}
|
||||
return found
|
||||
return ensureQueryUISet(found)
|
||||
}
|
||||
|
||||
export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
||||
|
@ -24,19 +25,22 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
|||
if (!found) {
|
||||
throw new Error("No view found")
|
||||
}
|
||||
return await enrichSchema(found, table.schema)
|
||||
return await enrichSchema(ensureQueryUISet(found), table.schema)
|
||||
}
|
||||
|
||||
export async function create(
|
||||
tableId: string,
|
||||
viewRequest: Omit<ViewV2, "id" | "version">
|
||||
): Promise<ViewV2> {
|
||||
const view: ViewV2 = {
|
||||
let view: ViewV2 = {
|
||||
...viewRequest,
|
||||
id: utils.generateViewID(tableId),
|
||||
version: 2,
|
||||
}
|
||||
|
||||
view = ensureQuerySet(view)
|
||||
view = ensureQueryUISet(view)
|
||||
|
||||
const db = context.getAppDB()
|
||||
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
|
@ -47,7 +51,10 @@ export async function create(
|
|||
return view
|
||||
}
|
||||
|
||||
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
||||
export async function update(
|
||||
tableId: string,
|
||||
view: Readonly<ViewV2>
|
||||
): Promise<ViewV2> {
|
||||
const db = context.getAppDB()
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
table.views ??= {}
|
||||
|
@ -63,6 +70,9 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
|||
throw new HTTPError(`Cannot update view type after creation`, 400)
|
||||
}
|
||||
|
||||
view = ensureQuerySet(view)
|
||||
view = ensureQueryUISet(view)
|
||||
|
||||
delete table.views[existingView.name]
|
||||
table.views[view.name] = view
|
||||
await db.put(table)
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { ViewV2 } from "@budibase/types"
|
||||
import { utils, dataFilters } from "@budibase/shared-core"
|
||||
import { cloneDeep, isPlainObject } from "lodash"
|
||||
import { HTTPError } from "@budibase/backend-core"
|
||||
|
||||
function isEmptyObject(obj: any) {
|
||||
return obj && isPlainObject(obj) && Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
export function ensureQueryUISet(viewArg: Readonly<ViewV2>): ViewV2 {
|
||||
const view = cloneDeep<ViewV2>(viewArg)
|
||||
if (!view.queryUI && view.query && !isEmptyObject(view.query)) {
|
||||
if (!Array.isArray(view.query)) {
|
||||
// In practice this should not happen. `view.query`, at the time this code
|
||||
// goes into the codebase, only contains LegacyFilter[] in production.
|
||||
// We're changing it in the change that this comment is part of to also
|
||||
// include SearchFilters objects. These are created when we receive an
|
||||
// update to a ViewV2 that contains a queryUI and not a query field. We
|
||||
// can convert UISearchFilter (the type of queryUI) to SearchFilters,
|
||||
// but not LegacyFilter[], they are incompatible due to UISearchFilter
|
||||
// and SearchFilters being recursive types.
|
||||
//
|
||||
// So despite the type saying that `view.query` is a LegacyFilter[] |
|
||||
// SearchFilters, it will never be a SearchFilters when a `view.queryUI`
|
||||
// is specified, making it "safe" to throw an error here.
|
||||
throw new HTTPError("view is missing queryUI field", 400)
|
||||
}
|
||||
|
||||
view.queryUI = utils.processSearchFilters(view.query)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
export function ensureQuerySet(viewArg: Readonly<ViewV2>): ViewV2 {
|
||||
const view = cloneDeep<ViewV2>(viewArg)
|
||||
// We consider queryUI to be the source of truth, so we don't check for the
|
||||
// presence of query here. We will overwrite it regardless of whether it is
|
||||
// present or not.
|
||||
if (view.queryUI && !isEmptyObject(view.queryUI)) {
|
||||
view.query = dataFilters.buildQuery(view.queryUI)
|
||||
}
|
||||
return view
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
CreateRowActionRequest,
|
||||
RowActionPermissionsResponse,
|
||||
RowActionResponse,
|
||||
RowActionsResponse,
|
||||
RowActionTriggerRequest,
|
||||
|
@ -40,23 +41,6 @@ export class RowActionAPI extends TestAPI {
|
|||
)
|
||||
}
|
||||
|
||||
update = async (
|
||||
tableId: string,
|
||||
rowActionId: string,
|
||||
rowAction: CreateRowActionRequest,
|
||||
expectations?: Expectations,
|
||||
config?: { publicUser?: boolean }
|
||||
) => {
|
||||
return await this._put<RowActionResponse>(
|
||||
`/api/tables/${tableId}/actions/${rowActionId}`,
|
||||
{
|
||||
body: rowAction,
|
||||
expectations,
|
||||
...config,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
delete = async (
|
||||
tableId: string,
|
||||
rowActionId: string,
|
||||
|
@ -78,7 +62,7 @@ export class RowActionAPI extends TestAPI {
|
|||
expectations?: Expectations,
|
||||
config?: { publicUser?: boolean }
|
||||
) => {
|
||||
return await this._post<RowActionResponse>(
|
||||
return await this._post<RowActionPermissionsResponse>(
|
||||
`/api/tables/${tableId}/actions/${rowActionId}/permissions`,
|
||||
{
|
||||
expectations: {
|
||||
|
@ -96,7 +80,7 @@ export class RowActionAPI extends TestAPI {
|
|||
expectations?: Expectations,
|
||||
config?: { publicUser?: boolean }
|
||||
) => {
|
||||
return await this._delete<RowActionResponse>(
|
||||
return await this._delete<RowActionPermissionsResponse>(
|
||||
`/api/tables/${tableId}/actions/${rowActionId}/permissions`,
|
||||
{
|
||||
expectations: {
|
||||
|
@ -115,7 +99,7 @@ export class RowActionAPI extends TestAPI {
|
|||
expectations?: Expectations,
|
||||
config?: { publicUser?: boolean }
|
||||
) => {
|
||||
return await this._post<RowActionResponse>(
|
||||
return await this._post<RowActionPermissionsResponse>(
|
||||
`/api/tables/${tableId}/actions/${rowActionId}/permissions/${viewId}`,
|
||||
{
|
||||
expectations: {
|
||||
|
@ -134,7 +118,7 @@ export class RowActionAPI extends TestAPI {
|
|||
expectations?: Expectations,
|
||||
config?: { publicUser?: boolean }
|
||||
) => {
|
||||
return await this._delete<RowActionResponse>(
|
||||
return await this._delete<RowActionPermissionsResponse>(
|
||||
`/api/tables/${tableId}/actions/${rowActionId}/permissions/${viewId}`,
|
||||
{
|
||||
expectations: {
|
||||
|
|
|
@ -8,31 +8,31 @@ import {
|
|||
} from "../../automations"
|
||||
import {
|
||||
AIOperationEnum,
|
||||
AutoFieldSubType,
|
||||
Automation,
|
||||
AutomationActionStepId,
|
||||
AutomationEventType,
|
||||
AutomationResults,
|
||||
AutomationStatus,
|
||||
AutomationStep,
|
||||
AutomationStepType,
|
||||
AutomationTrigger,
|
||||
AutomationTriggerStepId,
|
||||
BBReferenceFieldSubType,
|
||||
CreateViewRequest,
|
||||
Datasource,
|
||||
FieldSchema,
|
||||
FieldType,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
JsonFieldSubType,
|
||||
LoopStepType,
|
||||
Query,
|
||||
Role,
|
||||
SourceName,
|
||||
Table,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
TableSourceType,
|
||||
Query,
|
||||
Webhook,
|
||||
WebhookActionType,
|
||||
AutomationEventType,
|
||||
LoopStepType,
|
||||
FieldSchema,
|
||||
BBReferenceFieldSubType,
|
||||
JsonFieldSubType,
|
||||
AutoFieldSubType,
|
||||
Role,
|
||||
CreateViewRequest,
|
||||
} from "@budibase/types"
|
||||
import { LoopInput } from "../../definitions/automations"
|
||||
import { merge } from "lodash"
|
||||
|
@ -439,7 +439,7 @@ export function updateRowAutomationWithFilters(
|
|||
appId: string,
|
||||
tableId: string
|
||||
): Automation {
|
||||
const automation: Automation = {
|
||||
return {
|
||||
name: "updateRowWithFilters",
|
||||
type: "automation",
|
||||
appId,
|
||||
|
@ -472,7 +472,6 @@ export function updateRowAutomationWithFilters(
|
|||
},
|
||||
},
|
||||
}
|
||||
return automation
|
||||
}
|
||||
|
||||
export function basicAutomationResults(
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Screen,
|
||||
App,
|
||||
Automation,
|
||||
Role,
|
||||
} from "@budibase/types"
|
||||
import { gridSocket } from "./index"
|
||||
import { clearLock, updateLock } from "../utilities/redis"
|
||||
|
@ -100,6 +101,20 @@ export default class BuilderSocket extends BaseSocket {
|
|||
})
|
||||
}
|
||||
|
||||
emitRoleUpdate(ctx: any, role: Role) {
|
||||
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, {
|
||||
id: role._id,
|
||||
role,
|
||||
})
|
||||
}
|
||||
|
||||
emitRoleDeletion(ctx: any, role: Role) {
|
||||
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, {
|
||||
id: role._id,
|
||||
role: null,
|
||||
})
|
||||
}
|
||||
|
||||
emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) {
|
||||
if (table.sourceId == null || table.sourceId === "") {
|
||||
throw new Error("Table sourceId is not set")
|
||||
|
|
|
@ -97,6 +97,7 @@ export enum BuilderSocketEvent {
|
|||
SelectResource = "SelectResource",
|
||||
AppPublishChange = "AppPublishChange",
|
||||
AutomationChange = "AutomationChange",
|
||||
RoleChange = "RoleChange",
|
||||
}
|
||||
|
||||
export const SocketSessionTTL = 60
|
||||
|
|
|
@ -19,8 +19,12 @@ import {
|
|||
RangeOperator,
|
||||
LogicalOperator,
|
||||
isLogicalSearchOperator,
|
||||
SearchFilterGroup,
|
||||
FilterGroupLogicalOperator,
|
||||
UISearchFilter,
|
||||
UILogicalOperator,
|
||||
isBasicSearchOperator,
|
||||
isArraySearchOperator,
|
||||
isRangeSearchOperator,
|
||||
SearchFilter,
|
||||
} from "@budibase/types"
|
||||
import dayjs from "dayjs"
|
||||
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
|
||||
|
@ -310,308 +314,195 @@ export class ColumnSplitter {
|
|||
* @param filter the builder filter structure
|
||||
*/
|
||||
|
||||
const buildCondition = (expression: LegacyFilter) => {
|
||||
// Filter body
|
||||
let query: SearchFilters = {
|
||||
string: {},
|
||||
fuzzy: {},
|
||||
range: {},
|
||||
equal: {},
|
||||
notEqual: {},
|
||||
empty: {},
|
||||
notEmpty: {},
|
||||
contains: {},
|
||||
notContains: {},
|
||||
oneOf: {},
|
||||
containsAny: {},
|
||||
}
|
||||
let { operator, field, type, value, externalType, onEmptyFilter } = expression
|
||||
|
||||
if (!operator || !field) {
|
||||
function buildCondition(filter: undefined): undefined
|
||||
function buildCondition(filter: SearchFilter): SearchFilters
|
||||
function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
|
||||
if (!filter) {
|
||||
return
|
||||
}
|
||||
|
||||
const queryOperator = operator as SearchFilterOperator
|
||||
const isHbs =
|
||||
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
|
||||
// Parse all values into correct types
|
||||
if (operator === "allOr") {
|
||||
query.allOr = true
|
||||
return
|
||||
}
|
||||
if (onEmptyFilter) {
|
||||
query.onEmptyFilter = onEmptyFilter
|
||||
return
|
||||
}
|
||||
const query: SearchFilters = {}
|
||||
const { operator, field, type, externalType } = filter
|
||||
let { value } = filter
|
||||
|
||||
// Default the value for noValue fields to ensure they are correctly added
|
||||
// to the final query
|
||||
if (queryOperator === "empty" || queryOperator === "notEmpty") {
|
||||
if (operator === "empty" || operator === "notEmpty") {
|
||||
value = null
|
||||
}
|
||||
|
||||
if (
|
||||
type === "datetime" &&
|
||||
!isHbs &&
|
||||
queryOperator !== "empty" &&
|
||||
queryOperator !== "notEmpty"
|
||||
const isHbs =
|
||||
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
|
||||
|
||||
// Parsing value depending on what the type is.
|
||||
switch (type) {
|
||||
case FieldType.DATETIME:
|
||||
if (!isHbs && operator !== "empty" && operator !== "notEmpty") {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
value = new Date(value).toISOString()
|
||||
}
|
||||
break
|
||||
case FieldType.NUMBER:
|
||||
if (typeof value === "string" && !isHbs) {
|
||||
if (operator === "oneOf") {
|
||||
value = value.split(",").map(parseFloat)
|
||||
} else {
|
||||
value = parseFloat(value)
|
||||
}
|
||||
}
|
||||
break
|
||||
case FieldType.BOOLEAN:
|
||||
value = `${value}`.toLowerCase() === "true"
|
||||
break
|
||||
case FieldType.ARRAY:
|
||||
if (
|
||||
["contains", "notContains", "containsAny"].includes(
|
||||
operator.toLocaleString()
|
||||
) &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
value = value.split(",")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (isRangeSearchOperator(operator)) {
|
||||
const key = externalType as keyof typeof SqlNumberTypeRangeMap
|
||||
const limits = SqlNumberTypeRangeMap[key] || {
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
|
||||
query[operator] ??= {}
|
||||
query[operator][field] = {
|
||||
low: type === "number" ? limits.min : "0000-00-00T00:00:00.000Z",
|
||||
high: type === "number" ? limits.max : "9999-00-00T00:00:00.000Z",
|
||||
}
|
||||
} else if (operator === "rangeHigh" && value != null && value !== "") {
|
||||
query.range ??= {}
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
high: value,
|
||||
}
|
||||
} else if (operator === "rangeLow" && value != null && value !== "") {
|
||||
query.range ??= {}
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
low: value,
|
||||
}
|
||||
} else if (
|
||||
isBasicSearchOperator(operator) ||
|
||||
isArraySearchOperator(operator) ||
|
||||
isRangeSearchOperator(operator)
|
||||
) {
|
||||
// Ensure date value is a valid date and parse into correct format
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
value = new Date(value).toISOString()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (type === "number" && typeof value === "string" && !isHbs) {
|
||||
if (queryOperator === "oneOf") {
|
||||
value = value.split(",").map(item => parseFloat(item))
|
||||
} else {
|
||||
value = parseFloat(value)
|
||||
}
|
||||
}
|
||||
if (type === "boolean") {
|
||||
value = `${value}`?.toLowerCase() === "true"
|
||||
}
|
||||
if (
|
||||
["contains", "notContains", "containsAny"].includes(
|
||||
operator.toLocaleString()
|
||||
) &&
|
||||
type === "array" &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
value = value.split(",")
|
||||
}
|
||||
if (operator.toLocaleString().startsWith("range") && query.range) {
|
||||
const minint =
|
||||
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap]
|
||||
?.min || Number.MIN_SAFE_INTEGER
|
||||
const maxint =
|
||||
SqlNumberTypeRangeMap[externalType as keyof typeof SqlNumberTypeRangeMap]
|
||||
?.max || Number.MAX_SAFE_INTEGER
|
||||
if (!query.range[field]) {
|
||||
query.range[field] = {
|
||||
low: type === "number" ? minint : "0000-00-00T00:00:00.000Z",
|
||||
high: type === "number" ? maxint : "9999-00-00T00:00:00.000Z",
|
||||
}
|
||||
}
|
||||
if (operator === "rangeLow" && value != null && value !== "") {
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
low: value,
|
||||
}
|
||||
} else if (operator === "rangeHigh" && value != null && value !== "") {
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
high: value,
|
||||
}
|
||||
}
|
||||
} else if (isLogicalSearchOperator(queryOperator)) {
|
||||
// TODO
|
||||
} else if (query[queryOperator] && operator !== "onEmptyFilter") {
|
||||
if (type === "boolean") {
|
||||
// Transform boolean filters to cope with null.
|
||||
// "equals false" needs to be "not equals true"
|
||||
// "not equals false" needs to be "equals true"
|
||||
if (queryOperator === "equal" && value === false) {
|
||||
// TODO(samwho): I suspect this boolean transformation isn't needed anymore,
|
||||
// write some tests to confirm.
|
||||
|
||||
// Transform boolean filters to cope with null. "equals false" needs to
|
||||
// be "not equals true" "not equals false" needs to be "equals true"
|
||||
if (operator === "equal" && value === false) {
|
||||
query.notEqual = query.notEqual || {}
|
||||
query.notEqual[field] = true
|
||||
} else if (queryOperator === "notEqual" && value === false) {
|
||||
} else if (operator === "notEqual" && value === false) {
|
||||
query.equal = query.equal || {}
|
||||
query.equal[field] = true
|
||||
} else {
|
||||
query[queryOperator] ??= {}
|
||||
query[queryOperator]![field] = value
|
||||
query[operator] ??= {}
|
||||
query[operator][field] = value
|
||||
}
|
||||
} else {
|
||||
query[queryOperator] ??= {}
|
||||
query[queryOperator]![field] = value
|
||||
query[operator] ??= {}
|
||||
query[operator][field] = value
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported operator: ${operator}`)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export const buildQueryLegacy = (
|
||||
filter?: LegacyFilter[] | SearchFilters
|
||||
): SearchFilters | undefined => {
|
||||
// this is of type SearchFilters or is undefined
|
||||
if (!Array.isArray(filter)) {
|
||||
return filter
|
||||
export interface LegacyFilterSplit {
|
||||
allOr?: boolean
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
filters: SearchFilter[]
|
||||
}
|
||||
|
||||
export function splitFiltersArray(filters: LegacyFilter[]) {
|
||||
const split: LegacyFilterSplit = {
|
||||
filters: [],
|
||||
}
|
||||
|
||||
let query: SearchFilters = {
|
||||
string: {},
|
||||
fuzzy: {},
|
||||
range: {},
|
||||
equal: {},
|
||||
notEqual: {},
|
||||
empty: {},
|
||||
notEmpty: {},
|
||||
contains: {},
|
||||
notContains: {},
|
||||
oneOf: {},
|
||||
containsAny: {},
|
||||
for (const filter of filters) {
|
||||
if ("operator" in filter && filter.operator === "allOr") {
|
||||
split.allOr = true
|
||||
} else if ("onEmptyFilter" in filter) {
|
||||
split.onEmptyFilter = filter.onEmptyFilter
|
||||
} else {
|
||||
split.filters.push(filter)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(filter)) {
|
||||
return query
|
||||
}
|
||||
|
||||
filter.forEach(expression => {
|
||||
let { operator, field, type, value, externalType, onEmptyFilter } =
|
||||
expression
|
||||
const queryOperator = operator as SearchFilterOperator
|
||||
const isHbs =
|
||||
typeof value === "string" && (value.match(HBS_REGEX) || []).length > 0
|
||||
// Parse all values into correct types
|
||||
if (operator === "allOr") {
|
||||
query.allOr = true
|
||||
return
|
||||
}
|
||||
if (onEmptyFilter) {
|
||||
query.onEmptyFilter = onEmptyFilter
|
||||
return
|
||||
}
|
||||
if (
|
||||
type === "datetime" &&
|
||||
!isHbs &&
|
||||
queryOperator !== "empty" &&
|
||||
queryOperator !== "notEmpty"
|
||||
) {
|
||||
// Ensure date value is a valid date and parse into correct format
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
value = new Date(value).toISOString()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (type === "number" && typeof value === "string" && !isHbs) {
|
||||
if (queryOperator === "oneOf") {
|
||||
value = value.split(",").map(item => parseFloat(item))
|
||||
} else {
|
||||
value = parseFloat(value)
|
||||
}
|
||||
}
|
||||
if (type === "boolean") {
|
||||
value = `${value}`?.toLowerCase() === "true"
|
||||
}
|
||||
if (
|
||||
["contains", "notContains", "containsAny"].includes(
|
||||
operator.toLocaleString()
|
||||
) &&
|
||||
type === "array" &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
value = value.split(",")
|
||||
}
|
||||
if (operator.toLocaleString().startsWith("range") && query.range) {
|
||||
const minint =
|
||||
SqlNumberTypeRangeMap[
|
||||
externalType as keyof typeof SqlNumberTypeRangeMap
|
||||
]?.min || Number.MIN_SAFE_INTEGER
|
||||
const maxint =
|
||||
SqlNumberTypeRangeMap[
|
||||
externalType as keyof typeof SqlNumberTypeRangeMap
|
||||
]?.max || Number.MAX_SAFE_INTEGER
|
||||
if (!query.range[field]) {
|
||||
query.range[field] = {
|
||||
low: type === "number" ? minint : "0000-00-00T00:00:00.000Z",
|
||||
high: type === "number" ? maxint : "9999-00-00T00:00:00.000Z",
|
||||
}
|
||||
}
|
||||
if (operator === "rangeLow" && value != null && value !== "") {
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
low: value,
|
||||
}
|
||||
} else if (operator === "rangeHigh" && value != null && value !== "") {
|
||||
query.range[field] = {
|
||||
...query.range[field],
|
||||
high: value,
|
||||
}
|
||||
}
|
||||
} else if (isLogicalSearchOperator(queryOperator)) {
|
||||
// ignore
|
||||
} else if (query[queryOperator] && operator !== "onEmptyFilter") {
|
||||
if (type === "boolean") {
|
||||
// Transform boolean filters to cope with null.
|
||||
// "equals false" needs to be "not equals true"
|
||||
// "not equals false" needs to be "equals true"
|
||||
if (queryOperator === "equal" && value === false) {
|
||||
query.notEqual = query.notEqual || {}
|
||||
query.notEqual[field] = true
|
||||
} else if (queryOperator === "notEqual" && value === false) {
|
||||
query.equal = query.equal || {}
|
||||
query.equal[field] = true
|
||||
} else {
|
||||
query[queryOperator] ??= {}
|
||||
query[queryOperator]![field] = value
|
||||
}
|
||||
} else {
|
||||
query[queryOperator] ??= {}
|
||||
query[queryOperator]![field] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
return query
|
||||
return split
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a **SearchFilterGroup** filter definition into a grouped
|
||||
* Converts a **UISearchFilter** filter definition into a grouped
|
||||
* search query of type **SearchFilters**
|
||||
*
|
||||
* Legacy support remains for the old **SearchFilter[]** format.
|
||||
* These will be migrated to an appropriate **SearchFilters** object, if encountered
|
||||
*
|
||||
* @param filter
|
||||
*
|
||||
* @returns {SearchFilters}
|
||||
*/
|
||||
|
||||
export const buildQuery = (
|
||||
filter?: SearchFilterGroup | LegacyFilter[]
|
||||
): SearchFilters | undefined => {
|
||||
const parsedFilter: SearchFilterGroup | undefined =
|
||||
processSearchFilters(filter)
|
||||
|
||||
if (!parsedFilter) {
|
||||
export function buildQuery(filter: undefined): undefined
|
||||
export function buildQuery(
|
||||
filter: UISearchFilter | LegacyFilter[]
|
||||
): SearchFilters
|
||||
export function buildQuery(
|
||||
filter?: UISearchFilter | LegacyFilter[]
|
||||
): SearchFilters | undefined {
|
||||
if (!filter) {
|
||||
return
|
||||
}
|
||||
|
||||
const operatorMap: { [key in FilterGroupLogicalOperator]: LogicalOperator } =
|
||||
{
|
||||
[FilterGroupLogicalOperator.ALL]: LogicalOperator.AND,
|
||||
[FilterGroupLogicalOperator.ANY]: LogicalOperator.OR,
|
||||
}
|
||||
|
||||
const globalOnEmpty = parsedFilter.onEmptyFilter
|
||||
? parsedFilter.onEmptyFilter
|
||||
: null
|
||||
|
||||
const globalOperator: LogicalOperator =
|
||||
operatorMap[parsedFilter.logicalOperator as FilterGroupLogicalOperator]
|
||||
|
||||
return {
|
||||
...(globalOnEmpty ? { onEmptyFilter: globalOnEmpty } : {}),
|
||||
[globalOperator]: {
|
||||
conditions: parsedFilter.groups?.map((group: SearchFilterGroup) => {
|
||||
return {
|
||||
[operatorMap[group.logicalOperator]]: {
|
||||
conditions: group.filters
|
||||
?.map(x => buildCondition(x))
|
||||
.filter(filter => filter),
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
if (Array.isArray(filter)) {
|
||||
filter = processSearchFilters(filter)
|
||||
}
|
||||
|
||||
const operator = logicalOperatorFromUI(
|
||||
filter.logicalOperator || UILogicalOperator.ALL
|
||||
)
|
||||
|
||||
const query: SearchFilters = {}
|
||||
if (filter.onEmptyFilter) {
|
||||
query.onEmptyFilter = filter.onEmptyFilter
|
||||
} else {
|
||||
query.onEmptyFilter = EmptyFilterOption.RETURN_ALL
|
||||
}
|
||||
|
||||
query[operator] = {
|
||||
conditions: (filter.groups || []).map(group => {
|
||||
const { allOr, onEmptyFilter, filters } = splitFiltersArray(
|
||||
group.filters || []
|
||||
)
|
||||
if (onEmptyFilter) {
|
||||
query.onEmptyFilter = onEmptyFilter
|
||||
}
|
||||
const operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
|
||||
return {
|
||||
[operator]: { conditions: filters.map(buildCondition).filter(f => f) },
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
function logicalOperatorFromUI(operator: UILogicalOperator): LogicalOperator {
|
||||
return operator === UILogicalOperator.ALL
|
||||
? LogicalOperator.AND
|
||||
: LogicalOperator.OR
|
||||
}
|
||||
|
||||
// The frontend can send single values for array fields sometimes, so to handle
|
||||
|
|
|
@ -1,18 +1,28 @@
|
|||
import {
|
||||
LegacyFilter,
|
||||
SearchFilterGroup,
|
||||
FilterGroupLogicalOperator,
|
||||
UISearchFilter,
|
||||
UILogicalOperator,
|
||||
SearchFilters,
|
||||
BasicOperator,
|
||||
ArrayOperator,
|
||||
isLogicalSearchOperator,
|
||||
SearchFilter,
|
||||
EmptyFilterOption,
|
||||
} from "@budibase/types"
|
||||
import * as Constants from "./constants"
|
||||
import { removeKeyNumbering } from "./filters"
|
||||
import { removeKeyNumbering, splitFiltersArray } from "./filters"
|
||||
import _ from "lodash"
|
||||
|
||||
// an array of keys from filter type to properties that are in the type
|
||||
// this can then be converted using .fromEntries to an object
|
||||
type AllowedFilters = [keyof LegacyFilter, LegacyFilter[keyof LegacyFilter]][]
|
||||
const FILTER_ALLOWED_KEYS: (keyof SearchFilter)[] = [
|
||||
"field",
|
||||
"operator",
|
||||
"value",
|
||||
"type",
|
||||
"externalType",
|
||||
"valueType",
|
||||
"noValue",
|
||||
"formulaType",
|
||||
]
|
||||
|
||||
export function unreachable(
|
||||
value: never,
|
||||
|
@ -128,97 +138,25 @@ export function isSupportedUserSearch(query: SearchFilters) {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the filter config. Filters are migrated from
|
||||
* SearchFilter[] to SearchFilterGroup
|
||||
*
|
||||
* If filters is not an array, the migration is skipped
|
||||
*
|
||||
* @param {LegacyFilter[] | SearchFilterGroup} filters
|
||||
*/
|
||||
export const processSearchFilters = (
|
||||
filters: LegacyFilter[] | SearchFilterGroup | undefined
|
||||
): SearchFilterGroup | undefined => {
|
||||
if (!filters) {
|
||||
return
|
||||
filterArray: LegacyFilter[]
|
||||
): Required<UISearchFilter> => {
|
||||
const { allOr, onEmptyFilter, filters } = splitFiltersArray(filterArray)
|
||||
return {
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
onEmptyFilter: onEmptyFilter || EmptyFilterOption.RETURN_ALL,
|
||||
groups: [
|
||||
{
|
||||
logicalOperator: allOr ? UILogicalOperator.ANY : UILogicalOperator.ALL,
|
||||
filters: filters.map(filter => {
|
||||
const trimmedFilter = _.pick(
|
||||
filter,
|
||||
FILTER_ALLOWED_KEYS
|
||||
) as SearchFilter
|
||||
trimmedFilter.field = removeKeyNumbering(trimmedFilter.field)
|
||||
return trimmedFilter
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Base search config.
|
||||
const defaultCfg: SearchFilterGroup = {
|
||||
logicalOperator: FilterGroupLogicalOperator.ALL,
|
||||
groups: [],
|
||||
}
|
||||
|
||||
const filterAllowedKeys = [
|
||||
"field",
|
||||
"operator",
|
||||
"value",
|
||||
"type",
|
||||
"externalType",
|
||||
"valueType",
|
||||
"noValue",
|
||||
"formulaType",
|
||||
]
|
||||
|
||||
if (Array.isArray(filters)) {
|
||||
let baseGroup: SearchFilterGroup = {
|
||||
filters: [],
|
||||
logicalOperator: FilterGroupLogicalOperator.ALL,
|
||||
}
|
||||
|
||||
return filters.reduce((acc: SearchFilterGroup, filter: LegacyFilter) => {
|
||||
// Sort the properties for easier debugging
|
||||
const filterPropertyKeys = (Object.keys(filter) as (keyof LegacyFilter)[])
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
.filter(key => filter[key])
|
||||
|
||||
if (filterPropertyKeys.length == 1) {
|
||||
const key = filterPropertyKeys[0],
|
||||
value = filter[key]
|
||||
// Global
|
||||
if (key === "onEmptyFilter") {
|
||||
// unset otherwise
|
||||
acc.onEmptyFilter = value
|
||||
} else if (key === "operator" && value === "allOr") {
|
||||
// Group 1 logical operator
|
||||
baseGroup.logicalOperator = FilterGroupLogicalOperator.ANY
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
const allowedFilterSettings: AllowedFilters = filterPropertyKeys.reduce(
|
||||
(acc: AllowedFilters, key) => {
|
||||
const value = filter[key]
|
||||
if (filterAllowedKeys.includes(key)) {
|
||||
if (key === "field") {
|
||||
acc.push([key, removeKeyNumbering(value)])
|
||||
} else {
|
||||
acc.push([key, value])
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const migratedFilter: LegacyFilter = Object.fromEntries(
|
||||
allowedFilterSettings
|
||||
) as LegacyFilter
|
||||
|
||||
baseGroup.filters!.push(migratedFilter)
|
||||
|
||||
if (!acc.groups || !acc.groups.length) {
|
||||
// init the base group
|
||||
acc.groups = [baseGroup]
|
||||
}
|
||||
|
||||
return acc
|
||||
}, defaultCfg)
|
||||
} else if (!filters?.groups) {
|
||||
return
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
interface RowActionData {
|
||||
name: string
|
||||
}
|
||||
interface RowActionPermissionsData {
|
||||
allowedSources: string[] | undefined
|
||||
}
|
||||
export interface CreateRowActionRequest extends RowActionData {}
|
||||
export interface UpdateRowActionRequest extends RowActionData {}
|
||||
|
||||
export interface RowActionResponse extends RowActionData {
|
||||
export interface RowActionResponse
|
||||
extends RowActionData,
|
||||
RowActionPermissionsData {
|
||||
id: string
|
||||
tableId: string
|
||||
automationId: string
|
||||
allowedSources: string[] | undefined
|
||||
}
|
||||
|
||||
export interface RowActionsResponse {
|
||||
|
@ -18,3 +21,6 @@ export interface RowActionsResponse {
|
|||
export interface RowActionTriggerRequest {
|
||||
rowId: string
|
||||
}
|
||||
|
||||
export interface RowActionPermissionsResponse
|
||||
extends RowActionPermissionsData {}
|
||||
|
|
|
@ -1,23 +1,59 @@
|
|||
import { FieldType } from "../../documents"
|
||||
import {
|
||||
EmptyFilterOption,
|
||||
FilterGroupLogicalOperator,
|
||||
SearchFilters,
|
||||
UILogicalOperator,
|
||||
BasicOperator,
|
||||
RangeOperator,
|
||||
ArrayOperator,
|
||||
} from "../../sdk"
|
||||
|
||||
export type LegacyFilter = {
|
||||
operator: keyof SearchFilters | "rangeLow" | "rangeHigh"
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
field: string
|
||||
type?: FieldType
|
||||
value: any
|
||||
externalType?: string
|
||||
type AllOr = {
|
||||
operator: "allOr"
|
||||
}
|
||||
|
||||
// this is a type purely used by the UI
|
||||
type OnEmptyFilter = {
|
||||
onEmptyFilter: EmptyFilterOption
|
||||
}
|
||||
|
||||
// TODO(samwho): this could be broken down further
|
||||
export type SearchFilter = {
|
||||
operator:
|
||||
| BasicOperator
|
||||
| RangeOperator
|
||||
| ArrayOperator
|
||||
| "rangeLow"
|
||||
| "rangeHigh"
|
||||
// Field name will often have a numerical prefix when coming from the frontend,
|
||||
// use the ColumnSplitter class to remove it.
|
||||
field: string
|
||||
value: any
|
||||
type?: FieldType
|
||||
externalType?: string
|
||||
noValue?: boolean
|
||||
valueType?: string
|
||||
formulaType?: string
|
||||
}
|
||||
|
||||
// Prior to v2, this is the type the frontend sent us when filters were
|
||||
// involved. We convert this to a SearchFilters before use with the search SDK.
|
||||
export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter
|
||||
|
||||
export type SearchFilterGroup = {
|
||||
logicalOperator: FilterGroupLogicalOperator
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
logicalOperator?: UILogicalOperator
|
||||
groups?: SearchFilterGroup[]
|
||||
filters?: LegacyFilter[]
|
||||
}
|
||||
|
||||
// As of v3, this is the format that the frontend always sends when search
|
||||
// filters are involved. We convert this to SearchFilters before use with the
|
||||
// search SDK.
|
||||
//
|
||||
// The reason we migrated was that we started to support "logical operators" in
|
||||
// tests and SearchFilters because a recursive data structure. LegacyFilter[]
|
||||
// wasn't able to support these sorts of recursive structures, so we changed the
|
||||
// format.
|
||||
export type UISearchFilter = {
|
||||
logicalOperator?: UILogicalOperator
|
||||
onEmptyFilter?: EmptyFilterOption
|
||||
groups?: SearchFilterGroup[]
|
||||
}
|
||||
|
|
|
@ -126,16 +126,16 @@ export type ActionImplementations<T extends Hosting> = {
|
|||
n8nStepInputs,
|
||||
ExternalAppStepOutputs
|
||||
>
|
||||
[AutomationActionStepId.OPENAI]: ActionImplementation<
|
||||
OpenAIStepInputs,
|
||||
OpenAIStepOutputs
|
||||
>
|
||||
} & (T extends "self"
|
||||
? {
|
||||
[AutomationActionStepId.EXECUTE_BASH]: ActionImplementation<
|
||||
BashStepInputs,
|
||||
BashStepOutputs
|
||||
>
|
||||
[AutomationActionStepId.OPENAI]: ActionImplementation<
|
||||
OpenAIStepInputs,
|
||||
OpenAIStepOutputs
|
||||
>
|
||||
}
|
||||
: {})
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ export interface TableRowActions extends Document {
|
|||
}
|
||||
|
||||
export interface RowActionData {
|
||||
name: string
|
||||
automationId: string
|
||||
permissions: RowActionPermissions
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { LegacyFilter, SearchFilterGroup, SortOrder, SortType } from "../../api"
|
||||
import { LegacyFilter, UISearchFilter, SortOrder, SortType } from "../../api"
|
||||
import { UIFieldMetadata } from "./table"
|
||||
import { Document } from "../document"
|
||||
import { DBView, SearchFilters } from "../../sdk"
|
||||
|
@ -92,7 +92,7 @@ export interface ViewV2 {
|
|||
tableId: string
|
||||
query?: LegacyFilter[] | SearchFilters
|
||||
// duplicate to store UI information about filters
|
||||
queryUI?: SearchFilterGroup
|
||||
queryUI?: UISearchFilter
|
||||
sort?: {
|
||||
field: string
|
||||
order?: SortOrder
|
||||
|
|
|
@ -32,7 +32,19 @@ export enum LogicalOperator {
|
|||
export function isLogicalSearchOperator(
|
||||
value: string
|
||||
): value is LogicalOperator {
|
||||
return value === LogicalOperator.AND || value === LogicalOperator.OR
|
||||
return Object.values(LogicalOperator).includes(value as LogicalOperator)
|
||||
}
|
||||
|
||||
export function isBasicSearchOperator(value: string): value is BasicOperator {
|
||||
return Object.values(BasicOperator).includes(value as BasicOperator)
|
||||
}
|
||||
|
||||
export function isArraySearchOperator(value: string): value is ArrayOperator {
|
||||
return Object.values(ArrayOperator).includes(value as ArrayOperator)
|
||||
}
|
||||
|
||||
export function isRangeSearchOperator(value: string): value is RangeOperator {
|
||||
return Object.values(RangeOperator).includes(value as RangeOperator)
|
||||
}
|
||||
|
||||
export type SearchFilterOperator =
|
||||
|
@ -191,7 +203,7 @@ export enum EmptyFilterOption {
|
|||
RETURN_NONE = "none",
|
||||
}
|
||||
|
||||
export enum FilterGroupLogicalOperator {
|
||||
export enum UILogicalOperator {
|
||||
ALL = "all",
|
||||
ANY = "any",
|
||||
}
|
||||
|
|
|
@ -44,9 +44,7 @@ const getEventFns = async (config: Config, existing?: Config) => {
|
|||
fns.push(events.email.SMTPCreated)
|
||||
} else if (isAIConfig(config)) {
|
||||
fns.push(() => events.ai.AIConfigCreated)
|
||||
fns.push(() =>
|
||||
pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
|
||||
)
|
||||
fns.push(() => pro.quotas.addCustomAIConfig())
|
||||
} else if (isGoogleConfig(config)) {
|
||||
fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE))
|
||||
if (config.config.activated) {
|
||||
|
@ -85,9 +83,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
|
|||
fns.push(events.email.SMTPUpdated)
|
||||
} else if (isAIConfig(config)) {
|
||||
fns.push(() => events.ai.AIConfigUpdated)
|
||||
fns.push(() =>
|
||||
pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
|
||||
)
|
||||
} else if (isGoogleConfig(config)) {
|
||||
fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
|
||||
if (!existing.config.activated && config.config.activated) {
|
||||
|
@ -253,7 +248,7 @@ export async function save(ctx: UserCtx<Config>) {
|
|||
if (existingConfig) {
|
||||
await verifyAIConfig(config, existingConfig)
|
||||
}
|
||||
await pro.quotas.updateCustomAIConfigCount(Object.keys(config).length)
|
||||
await pro.quotas.addCustomAIConfig()
|
||||
break
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
@ -342,29 +337,43 @@ export async function find(ctx: UserCtx) {
|
|||
let scopedConfig = await configs.getConfig(type)
|
||||
|
||||
if (scopedConfig) {
|
||||
if (type === ConfigType.OIDC_LOGOS) {
|
||||
enrichOIDCLogos(scopedConfig)
|
||||
}
|
||||
|
||||
if (type === ConfigType.AI) {
|
||||
await pro.sdk.ai.enrichAIConfig(scopedConfig)
|
||||
// Strip out the API Keys from the response so they don't show in the UI
|
||||
for (const key in scopedConfig.config) {
|
||||
if (scopedConfig.config[key].apiKey) {
|
||||
scopedConfig.config[key].apiKey = PASSWORD_REPLACEMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.body = scopedConfig
|
||||
await handleConfigType(type, scopedConfig)
|
||||
} else if (type === ConfigType.AI) {
|
||||
scopedConfig = { config: {} } as AIConfig
|
||||
await handleAIConfig(scopedConfig)
|
||||
} else {
|
||||
// don't throw an error, there simply is nothing to return
|
||||
// If no config found and not AI type, just return an empty body
|
||||
ctx.body = {}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = scopedConfig
|
||||
} catch (err: any) {
|
||||
ctx.throw(err?.status || 400, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfigType(type: ConfigType, config: Config) {
|
||||
if (type === ConfigType.OIDC_LOGOS) {
|
||||
enrichOIDCLogos(config)
|
||||
} else if (type === ConfigType.AI) {
|
||||
await handleAIConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAIConfig(config: AIConfig) {
|
||||
await pro.sdk.ai.enrichAIConfig(config)
|
||||
stripApiKeys(config)
|
||||
}
|
||||
|
||||
function stripApiKeys(config: AIConfig) {
|
||||
for (const key in config?.config) {
|
||||
if (config.config[key].apiKey) {
|
||||
config.config[key].apiKey = PASSWORD_REPLACEMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function publicOidc(ctx: Ctx<void, GetPublicOIDCConfigResponse>) {
|
||||
try {
|
||||
// Find the config with the most granular scope based on context
|
||||
|
@ -508,6 +517,9 @@ export async function destroy(ctx: UserCtx) {
|
|||
try {
|
||||
await db.remove(id, rev)
|
||||
await cache.destroy(cache.CacheKey.CHECKLIST)
|
||||
if (id === configs.generateConfigID(ConfigType.AI)) {
|
||||
await pro.quotas.removeCustomAIConfig()
|
||||
}
|
||||
ctx.body = { message: "Config deleted successfully" }
|
||||
} catch (err: any) {
|
||||
ctx.throw(err.status, err)
|
||||
|
|
|
@ -13,10 +13,6 @@ describe("Global configs controller", () => {
|
|||
await config.afterAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it("Should strip secrets when pulling AI config", async () => {
|
||||
const data = structures.configs.ai()
|
||||
await config.api.configs.saveConfig(data)
|
||||
|
|
145
yarn.lock
145
yarn.lock
|
@ -2343,6 +2343,18 @@
|
|||
enabled "2.0.x"
|
||||
kuler "^2.0.0"
|
||||
|
||||
"@dagrejs/dagre@1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-1.1.4.tgz#66f9c0e2b558308f2c268f60e2c28f22ee17e339"
|
||||
integrity sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==
|
||||
dependencies:
|
||||
"@dagrejs/graphlib" "2.2.4"
|
||||
|
||||
"@dagrejs/graphlib@2.2.4":
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.2.4.tgz#d77bfa9ff49e2307c0c6e6b8b26b5dd3c05816c4"
|
||||
integrity sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==
|
||||
|
||||
"@datadog/native-appsec@7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-7.0.0.tgz#a380174dd49aef2d9bb613a0ec8ead6dc7822095"
|
||||
|
@ -5093,6 +5105,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.1.tgz#d333fa41909f691c8750b5c15ad9ba029df2248e"
|
||||
integrity sha512-rX6Iasu9BsFMVgEN0vGRPm9dmSxva+IK/uqQAa9HM0lliwqUiFrJxrFXHHpiAgNuux/U4srEJwbSpGzfF+CegQ==
|
||||
|
||||
"@svelte-put/shortcut@^3.1.0":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@svelte-put/shortcut/-/shortcut-3.1.1.tgz#aba4d7407024d5cff38727e12925c8f81e877079"
|
||||
integrity sha512-2L5EYTZXiaKvbEelVkg5znxqvfZGZai3m97+cAiUBhLZwXnGtviTDpHxOoZBsqz41szlfRMcamW/8o0+fbW3ZQ==
|
||||
|
||||
"@sveltejs/vite-plugin-svelte@1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-1.4.0.tgz#412a735de489ca731d0c780c2b410f45dd95b392"
|
||||
|
@ -5451,6 +5468,45 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/d3-color@*":
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
|
||||
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
|
||||
|
||||
"@types/d3-drag@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
|
||||
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-interpolate@*":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
|
||||
version "3.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe"
|
||||
integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==
|
||||
|
||||
"@types/d3-transition@^3.0.8":
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.8.tgz#677707f5eed5b24c66a1918cde05963021351a8f"
|
||||
integrity sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-zoom@^3.0.8":
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
|
||||
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
|
||||
dependencies:
|
||||
"@types/d3-interpolate" "*"
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/debug@*":
|
||||
version "4.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
|
||||
|
@ -6578,6 +6634,28 @@
|
|||
loupe "^3.1.1"
|
||||
tinyrainbow "^1.2.0"
|
||||
|
||||
"@xyflow/svelte@^0.1.18":
|
||||
version "0.1.18"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/svelte/-/svelte-0.1.18.tgz#ba2f9f72adc64ff6f71a5ad03cf759af8d7c9748"
|
||||
integrity sha512-P2td3XcvMk36pnhyRUAXtmwfd7sv1KAHVF29YZUNndYlgxG98vwj1UoyyuXwCHIiyu82GgowaTppHCNPXsvNSg==
|
||||
dependencies:
|
||||
"@svelte-put/shortcut" "^3.1.0"
|
||||
"@xyflow/system" "0.0.41"
|
||||
classcat "^5.0.4"
|
||||
|
||||
"@xyflow/system@0.0.41":
|
||||
version "0.0.41"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.41.tgz#6c314b2bbca594aec4d7cdb56efb003be6727d21"
|
||||
integrity sha512-XAjs8AUA0YMfYD91cT6pLGALwbsPS64s2WBHyULqL1m0gTqXqaUSLK1P7qA/Q8HecN0RFbqlM2tPO8bmZXP0YQ==
|
||||
dependencies:
|
||||
"@types/d3-drag" "^3.0.7"
|
||||
"@types/d3-selection" "^3.0.10"
|
||||
"@types/d3-transition" "^3.0.8"
|
||||
"@types/d3-zoom" "^3.0.8"
|
||||
d3-drag "^3.0.0"
|
||||
d3-selection "^3.0.0"
|
||||
d3-zoom "^3.0.0"
|
||||
|
||||
"@yarnpkg/lockfile@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
|
||||
|
@ -8244,6 +8322,11 @@ cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2:
|
|||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107"
|
||||
integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==
|
||||
|
||||
classcat@^5.0.4:
|
||||
version "5.0.5"
|
||||
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
|
||||
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
|
||||
|
||||
clean-stack@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
|
||||
|
@ -9159,6 +9242,68 @@ curlconverter@3.21.0:
|
|||
string.prototype.startswith "^1.0.0"
|
||||
yamljs "^0.3.0"
|
||||
|
||||
"d3-color@1 - 3":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
|
||||
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
|
||||
|
||||
"d3-dispatch@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
|
||||
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
|
||||
|
||||
"d3-drag@2 - 3", d3-drag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
|
||||
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
|
||||
dependencies:
|
||||
d3-dispatch "1 - 3"
|
||||
d3-selection "3"
|
||||
|
||||
"d3-ease@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
|
||||
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||
|
||||
"d3-interpolate@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
|
||||
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
|
||||
|
||||
"d3-timer@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||
|
||||
"d3-transition@2 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
|
||||
integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
d3-dispatch "1 - 3"
|
||||
d3-ease "1 - 3"
|
||||
d3-interpolate "1 - 3"
|
||||
d3-timer "1 - 3"
|
||||
|
||||
d3-zoom@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
|
||||
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
|
||||
dependencies:
|
||||
d3-dispatch "1 - 3"
|
||||
d3-drag "2 - 3"
|
||||
d3-interpolate "1 - 3"
|
||||
d3-selection "2 - 3"
|
||||
d3-transition "2 - 3"
|
||||
|
||||
dargs@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc"
|
||||
|
|
Loading…
Reference in New Issue