Merge branch 'v3-ui' of github.com:Budibase/budibase into new-rbac-ui

This commit is contained in:
Andrew Kingston 2024-09-30 08:21:36 +01:00
commit adfe467329
No known key found for this signature in database
49 changed files with 1284 additions and 330 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.32.7", "version": "2.32.8",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -1 +1 @@
Subproject commit c24374879d2b61516fabc24d7404e7da235be05e Subproject commit 558a32dfd1f55bd894804a503e7e1090937df88c

View File

@ -3,6 +3,7 @@ import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node" import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { Duration } from "../utils"
let posthog: PostHog | undefined let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) { export function init(opts?: PostHogOptions) {
@ -16,6 +17,7 @@ export function init(opts?: PostHogOptions) {
posthog = new PostHog(env.POSTHOG_TOKEN, { posthog = new PostHog(env.POSTHOG_TOKEN, {
host: env.POSTHOG_API_HOST, host: env.POSTHOG_API_HOST,
personalApiKey: env.POSTHOG_PERSONAL_TOKEN, personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(),
...opts, ...opts,
}) })
} else { } else {
@ -275,5 +277,5 @@ export const flags = new FlagSet({
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()), AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()), SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false), [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
}) })

View File

@ -396,6 +396,11 @@
padding: 6px 10px; padding: 6px 10px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.compact .placeholder {
height: fit-content;
}
.title { .title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -6,7 +6,7 @@
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
import RoleCell from "./cells/RoleCell.svelte" import RoleCell from "./cells/RoleCell.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { canBeSortColumn } from "@budibase/shared-core" import { canBeSortColumn } from "@budibase/frontend-core"
export let schema = {} export let schema = {}
export let data = [] export let data = []
@ -31,7 +31,7 @@
acc[key] = acc[key] =
typeof schema[key] === "string" ? { type: schema[key] } : schema[key] typeof schema[key] === "string" ? { type: schema[key] } : schema[key]
if (!canBeSortColumn(acc[key].type)) { if (!canBeSortColumn(acc[key])) {
acc[key].sortable = false acc[key].sortable = false
} }
return acc return acc

View File

@ -121,8 +121,10 @@
label: name, label: name,
schema: { schema: {
type: column.type, type: column.type,
subtype: column.subtype,
visible: column.visible, visible: column.visible,
readonly: column.readonly, readonly: column.readonly,
constraints: column.constraints, // This is needed to properly display "users" column
}, },
} }
}) })

View File

@ -13,12 +13,14 @@
import { isEnabled } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
import { FeatureFlag } from "@budibase/types" import { FeatureFlag } from "@budibase/types"
const { columns, datasource } = getContext("grid") const { tableColumns, datasource } = getContext("grid")
let open = false let open = false
let anchor let anchor
$: anyRestricted = $columns.filter(col => !col.visible || col.readonly).length $: anyRestricted = $tableColumns.filter(
col => !col.visible || col.readonly
).length
$: text = anyRestricted ? `Columns: ${anyRestricted} restricted` : "Columns" $: text = anyRestricted ? `Columns: ${anyRestricted} restricted` : "Columns"
$: permissions = $: permissions =
$datasource.type === "viewV2" $datasource.type === "viewV2"
@ -37,7 +39,7 @@
size="M" size="M"
on:click={() => (open = !open)} on:click={() => (open = !open)}
selected={open || anyRestricted} selected={open || anyRestricted}
disabled={!$columns.length} disabled={!$tableColumns.length}
accentColor="#674D00" accentColor="#674D00"
> >
{text} {text}
@ -46,7 +48,7 @@
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<ColumnsSettingContent <ColumnsSettingContent
columns={$columns} columns={$tableColumns}
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)} canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
{permissions} {permissions}
/> />

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui" import { ActionButton, Popover, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/shared-core" import { canBeSortColumn } from "@budibase/frontend-core"
const { sort, columns } = getContext("grid") const { sort, columns } = getContext("grid")
@ -13,8 +13,9 @@
label: col.label || col.name, label: col.label || col.name,
value: col.name, value: col.name,
type: col.schema?.type, type: col.schema?.type,
related: col.related,
})) }))
.filter(col => canBeSortColumn(col.type)) .filter(col => canBeSortColumn(col))
$: orderOptions = getOrderOptions($sort.column, columnOptions) $: orderOptions = getOrderOptions($sort.column, columnOptions)
const getOrderOptions = (column, columnOptions) => { const getOrderOptions = (column, columnOptions) => {

View File

@ -19,7 +19,6 @@
helpers, helpers,
PROTECTED_INTERNAL_COLUMNS, PROTECTED_INTERNAL_COLUMNS,
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
canBeDisplayColumn,
canHaveDefaultColumn, canHaveDefaultColumn,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
@ -43,7 +42,7 @@
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core" import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte" import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
@ -166,7 +165,7 @@
: availableAutoColumns : availableAutoColumns
// used to select what different options can be displayed for column type // used to select what different options can be displayed for column type
$: canBeDisplay = $: canBeDisplay =
canBeDisplayColumn(editableColumn.type) && !editableColumn.autocolumn canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: canHaveDefault = $: canHaveDefault =
isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type) isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired = $: canBeRequired =

View File

@ -1,7 +1,8 @@
<script> <script>
import { Select, Icon } from "@budibase/bbui" import { Select, Icon } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import { canBeDisplayColumn, utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { canBeDisplayColumn } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
@ -100,10 +101,10 @@
let rawRows = [] let rawRows = []
$: displayColumnOptions = Object.keys(schema || {}).filter(column => { $: displayColumnOptions = Object.keys(schema || {}).filter(column => {
return validation[column] && canBeDisplayColumn(schema[column].type) return validation[column] && canBeDisplayColumn(schema[column])
}) })
$: if (displayColumn && !canBeDisplayColumn(schema[displayColumn].type)) { $: if (displayColumn && !canBeDisplayColumn(schema[displayColumn])) {
displayColumn = null displayColumn = null
} }

View File

@ -1,4 +1,5 @@
<script> <script>
import { enrichSchemaWithRelColumns } from "@budibase/frontend-core"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import { selectedScreen, componentStore } from "stores/builder" import { selectedScreen, componentStore } from "stores/builder"
import DraggableList from "../DraggableList/DraggableList.svelte" import DraggableList from "../DraggableList/DraggableList.svelte"
@ -28,7 +29,8 @@
delete schema._rev delete schema._rev
} }
return schema const result = enrichSchemaWithRelColumns(schema)
return result
} }
$: datasource = getDatasourceForProvider($selectedScreen, componentInstance) $: datasource = getDatasourceForProvider($selectedScreen, componentInstance)

View File

@ -82,7 +82,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
active: column.active, active: column.active,
field: column.field, field: column.field,
label: column.label, label: column.label,
columnType: schema[column.field].type, columnType: column.columnType || schema[column.field].type,
width: column.width, width: column.width,
conditions: column.conditions, conditions: column.conditions,
}, },

View File

@ -3,7 +3,7 @@
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import { selectedScreen } from "stores/builder" import { selectedScreen } from "stores/builder"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { canBeSortColumn } from "@budibase/shared-core" import { canBeSortColumn } from "@budibase/frontend-core"
export let componentInstance = {} export let componentInstance = {}
export let value = "" export let value = ""
@ -17,7 +17,7 @@
const getSortableFields = schema => { const getSortableFields = schema => {
return Object.entries(schema || {}) return Object.entries(schema || {})
.filter(entry => canBeSortColumn(entry[1].type)) .filter(entry => canBeSortColumn(entry[1]))
.map(entry => entry[0]) .map(entry => entry[0])
} }

View File

@ -1,6 +1,6 @@
<script> <script>
import { viewsV2, rowActions } from "stores/builder" import { viewsV2, rowActions } from "stores/builder"
import { admin } from "stores/portal" import { admin, themeStore } from "stores/portal"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte" import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
@ -23,6 +23,11 @@
} }
$: buttons = makeRowActionButtons($rowActions[id]) $: buttons = makeRowActionButtons($rowActions[id])
$: rowActions.refreshRowActions(id) $: rowActions.refreshRowActions(id)
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
const makeRowActionButtons = actions => { const makeRowActionButtons = actions => {
return (actions || []).map(action => ({ return (actions || []).map(action => ({
@ -40,13 +45,15 @@
<Grid <Grid
{API} {API}
{darkMode}
{datasource} {datasource}
{buttons}
{darkMode}
allowAddRows allowAddRows
allowDeleteRows allowDeleteRows
showAvatars={false} showAvatars={false}
on:updatedatasource={handleGridViewUpdate} on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud} isCloud={$admin.cloud}
{buttons}
buttonsCollapsed buttonsCollapsed
> >
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">

View File

@ -4,6 +4,7 @@
import OpenAILogo from "./logos/OpenAI.svelte" import OpenAILogo from "./logos/OpenAI.svelte"
import AnthropicLogo from "./logos/Anthropic.svelte" import AnthropicLogo from "./logos/Anthropic.svelte"
import TogetherAILogo from "./logos/TogetherAI.svelte" import TogetherAILogo from "./logos/TogetherAI.svelte"
import AzureOpenAILogo from "./logos/AzureOpenAI.svelte"
import { Providers } from "./constants" import { Providers } from "./constants"
const logos = { const logos = {
@ -11,6 +12,7 @@
[Providers.OpenAI.name]: OpenAILogo, [Providers.OpenAI.name]: OpenAILogo,
[Providers.Anthropic.name]: AnthropicLogo, [Providers.Anthropic.name]: AnthropicLogo,
[Providers.TogetherAI.name]: TogetherAILogo, [Providers.TogetherAI.name]: TogetherAILogo,
[Providers.AzureOpenAI.name]: AzureOpenAILogo,
} }
export let config export let config
@ -26,8 +28,8 @@
<div class="icon"> <div class="icon">
<svelte:component <svelte:component
this={logos[config.name || config.provider]} this={logos[config.name || config.provider]}
height="30" height="18"
width="30" width="18"
/> />
</div> </div>
<div class="header"> <div class="header">
@ -110,7 +112,7 @@
.tag { .tag {
display: flex; display: flex;
color: var(--spectrum-body-m-text-color); color: #ffffff;
padding: 4px 8px; padding: 4px 8px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@ -1,6 +1,6 @@
import { it, expect, describe, vi } from "vitest" import { it, expect, describe, vi } from "vitest"
import AISettings from "./index.svelte" import AISettings from "./index.svelte"
import { render } from "@testing-library/svelte" import { render, fireEvent } from "@testing-library/svelte"
import { admin, licensing } from "stores/portal" import { admin, licensing } from "stores/portal"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -55,39 +55,43 @@ describe("AISettings", () => {
expect(enterpriseTag).toBeInTheDocument() expect(enterpriseTag).toBeInTheDocument()
}) })
it("should show the premium label on cloud when Budibase AI isn't enabled", async () => { it("the add configuration button should not do anything the user doesn't have the correct license on cloud", async () => {
setupEnv(Hosting.Cloud)
instance = render(AISettings, {})
const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument()
})
it("should not show the add configuration button if the user doesn't have the correct license on cloud", async () => {
let addConfigurationButton let addConfigurationButton
let configModal
setupEnv(Hosting.Cloud) setupEnv(Hosting.Cloud)
instance = render(AISettings) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).not.toBeInTheDocument()
})
it("the add configuration button should open the config modal if the user has the correct license on cloud", async () => {
let addConfigurationButton
let configModal
setupEnv(Hosting.Cloud, { customAIConfigsEnabled: true }) setupEnv(Hosting.Cloud, { customAIConfigsEnabled: true })
instance = render(AISettings) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
}) })
it("should not show the add configuration button if the user doesn't have the correct license on self host", async () => { it("the add configuration button should open the config modal if the user has the correct license on self host", async () => {
let addConfigurationButton let addConfigurationButton
let configModal
setupEnv(Hosting.Self)
instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument()
setupEnv(Hosting.Self, { customAIConfigsEnabled: true }) setupEnv(Hosting.Self, { customAIConfigsEnabled: true })
instance = render(AISettings, {}) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
}) })
}) })
}) })

View File

@ -84,8 +84,10 @@
<Label size="M">API Key</Label> <Label size="M">API Key</Label>
<Input type="password" bind:value={config.apiKey} /> <Input type="password" bind:value={config.apiKey} />
</div> </div>
<Toggle text="Active" bind:value={config.active} /> <div class="form-row">
<Toggle text="Set as default" bind:value={config.isDefault} /> <Toggle text="Active" bind:value={config.active} />
<Toggle text="Set as default" bind:value={config.isDefault} />
</div>
</ModalContent> </ModalContent>
<style> <style>

View File

@ -23,7 +23,7 @@ export const Providers = {
models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }], models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }],
}, },
AzureOpenAI: { AzureOpenAI: {
name: "Azure Open AI", name: "Azure OpenAI",
models: [ models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" }, { label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" }, { label: "GPT 4o", value: "gpt-4o" },

View File

@ -27,7 +27,6 @@
let editingUuid let editingUuid
$: isCloud = $admin.cloud $: isCloud = $admin.cloud
$: budibaseAIEnabled = $licensing.budibaseAIEnabled
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled $: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() { async function fetchAIConfig() {
@ -127,18 +126,8 @@
</Modal> </Modal>
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">AI</Heading> <div class="header">
{#if isCloud && !budibaseAIEnabled} <Heading size="M">AI</Heading>
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{/if}
<Body>Configure your AI settings within this section:</Body>
</Layout>
<Divider />
<Layout noPadding>
<div class="config-heading">
<Heading size="S">AI Configurations</Heading>
{#if !isCloud && !customAIConfigsEnabled} {#if !isCloud && !customAIConfigsEnabled}
<Tags> <Tags>
<Tag icon="LockClosed">Premium</Tag> <Tag icon="LockClosed">Premium</Tag>
@ -147,24 +136,43 @@
<Tags> <Tags>
<Tag icon="LockClosed">Enterprise</Tag> <Tag icon="LockClosed">Enterprise</Tag>
</Tags> </Tags>
{:else}
<Button size="S" cta on:click={newConfig}>Add configuration</Button>
{/if} {/if}
</div> </div>
<Body size="S" <Body>Configure your AI settings within this section:</Body>
>Use the following interface to select your preferred AI configuration.</Body
>
<Body size="S">Select your AI Model:</Body>
{#if fullAIConfig?.config}
{#each Object.keys(fullAIConfig.config) as key}
<AIConfigTile
config={fullAIConfig.config[key]}
editHandler={() => editConfig(key)}
deleteHandler={() => deleteConfig(key)}
/>
{/each}
{/if}
</Layout> </Layout>
<Divider />
<div style={`opacity: ${customAIConfigsEnabled ? 1 : 0.5}`}>
<Layout noPadding>
<div class="config-heading">
<Heading size="S">AI Configurations</Heading>
<Button
size="S"
cta={customAIConfigsEnabled}
secondary={!customAIConfigsEnabled}
on:click={customAIConfigsEnabled ? newConfig : null}
>
Add configuration
</Button>
</div>
<Body size="S"
>Use the following interface to select your preferred AI configuration.</Body
>
{#if customAIConfigsEnabled}
<Body size="S">Select your AI Model:</Body>
{/if}
{#if fullAIConfig?.config}
{#each Object.keys(fullAIConfig.config) as key}
<AIConfigTile
config={fullAIConfig.config[key]}
editHandler={customAIConfigsEnabled ? () => editConfig(key) : null}
deleteHandler={customAIConfigsEnabled
? () => deleteConfig(key)
: null}
/>
{/each}
{/if}
</Layout>
</div>
</Layout> </Layout>
<style> <style>
@ -172,5 +180,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: -18px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
} }
</style> </style>

View File

@ -0,0 +1,64 @@
<script>
export let width
export let height
</script>
<svg xmlns="http://www.w3.org/2000/svg" {width} {height} viewBox="0 0 96 96">
<defs>
<linearGradient
id="e399c19f-b68f-429d-b176-18c2117ff73c"
x1="-1032.172"
x2="-1059.213"
y1="145.312"
y2="65.426"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#114a8b" />
<stop offset="1" stop-color="#0669bc" />
</linearGradient>
<linearGradient
id="ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15"
x1="-1023.725"
x2="-1029.98"
y1="108.083"
y2="105.968"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-opacity=".3" />
<stop offset=".071" stop-opacity=".2" />
<stop offset=".321" stop-opacity=".1" />
<stop offset=".623" stop-opacity=".05" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
<linearGradient
id="a7fee970-a784-4bb1-af8d-63d18e5f7db9"
x1="-1027.165"
x2="-997.482"
y1="147.642"
y2="68.561"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#3ccbf4" />
<stop offset="1" stop-color="#2892df" />
</linearGradient>
</defs>
<path
fill="url(#e399c19f-b68f-429d-b176-18c2117ff73c)"
d="M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z"
/>
<path
fill="#0078d4"
d="M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z"
/>
<path
fill="url(#ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15)"
d="M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z"
/>
<path
fill="url(#a7fee970-a784-4bb1-af8d-63d18e5f7db9)"
d="M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z"
/>
</svg>

View File

@ -1,5 +1,10 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { licensing } from "stores/portal"
$redirect("./auth") if ($licensing.customAIConfigsEnabled) {
$redirect("./ai")
} else {
$redirect("./auth")
}
</script> </script>

View File

@ -2,7 +2,7 @@
import { getContext, onDestroy } from "svelte" import { getContext, onDestroy } from "svelte"
import { Table } from "@budibase/bbui" import { Table } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte" import SlotRenderer from "./SlotRenderer.svelte"
import { canBeSortColumn } from "@budibase/shared-core" import { canBeSortColumn } from "@budibase/frontend-core"
import Provider from "components/context/Provider.svelte" import Provider from "components/context/Provider.svelte"
export let dataProvider export let dataProvider
@ -146,7 +146,7 @@
return return
} }
newSchema[columnName] = schema[columnName] newSchema[columnName] = schema[columnName]
if (!canBeSortColumn(schema[columnName].type)) { if (!canBeSortColumn(schema[columnName])) {
newSchema[columnName].sortable = false newSchema[columnName].sortable = false
} }

View File

@ -2,6 +2,7 @@
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { Dropzone } from "@budibase/bbui" import { Dropzone } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte" import GridPopover from "../overlays/GridPopover.svelte"
import { FieldType } from "@budibase/types"
export let value export let value
export let focused = false export let focused = false
@ -81,7 +82,12 @@
> >
{#each value || [] as attachment} {#each value || [] as attachment}
{#if isImage(attachment.extension)} {#if isImage(attachment.extension)}
<img src={attachment.url} alt={attachment.extension} /> <img
class:light={!$props?.darkMode &&
schema.type === FieldType.SIGNATURE_SINGLE}
src={attachment.url}
alt={attachment.extension}
/>
{:else} {:else}
<div class="file" title={attachment.name}> <div class="file" title={attachment.name}>
{attachment.extension} {attachment.extension}
@ -140,4 +146,9 @@
width: 320px; width: 320px;
padding: var(--cell-padding); padding: var(--cell-padding);
} }
.attachment-cell img.light {
-webkit-filter: invert(100%);
filter: invert(100%);
}
</style> </style>

View File

@ -1,6 +1,6 @@
<script> <script>
import { getContext, onMount, tick } from "svelte" import { getContext, onMount, tick } from "svelte"
import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core" import { canBeSortColumn, canBeDisplayColumn } from "@budibase/frontend-core"
import { Icon, Menu, MenuItem, Modal } from "@budibase/bbui" import { Icon, Menu, MenuItem, Modal } from "@budibase/bbui"
import GridCell from "./GridCell.svelte" import GridCell from "./GridCell.svelte"
import { getColumnIcon } from "../../../utils/schema" import { getColumnIcon } from "../../../utils/schema"
@ -165,7 +165,17 @@
} }
const hideColumn = () => { const hideColumn = () => {
datasource.actions.addSchemaMutation(column.name, { visible: false }) const { related } = column
const mutation = { visible: false }
if (!related) {
datasource.actions.addSchemaMutation(column.name, mutation)
} else {
datasource.actions.addSubSchemaMutation(
related.subField,
related.field,
mutation
)
}
datasource.actions.saveSchemaMutations() datasource.actions.saveSchemaMutations()
open = false open = false
} }
@ -347,15 +357,14 @@
<MenuItem <MenuItem
icon="Label" icon="Label"
on:click={makeDisplayColumn} on:click={makeDisplayColumn}
disabled={column.primaryDisplay || disabled={column.primaryDisplay || !canBeDisplayColumn(column.schema)}
!canBeDisplayColumn(column.schema.type)}
> >
Use as display column Use as display column
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="SortOrderUp" icon="SortOrderUp"
on:click={sortAscending} on:click={sortAscending}
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema) ||
(column.name === $sort.column && $sort.order === "ascending")} (column.name === $sort.column && $sort.order === "ascending")}
> >
Sort {sortingLabels.ascending} Sort {sortingLabels.ascending}
@ -363,7 +372,7 @@
<MenuItem <MenuItem
icon="SortOrderDown" icon="SortOrderDown"
on:click={sortDescending} on:click={sortDescending}
disabled={!canBeSortColumn(column.schema.type) || disabled={!canBeSortColumn(column.schema) ||
(column.name === $sort.column && $sort.order === "descending")} (column.name === $sort.column && $sort.order === "descending")}
> >
Sort {sortingLabels.descending} Sort {sortingLabels.descending}

View File

@ -39,5 +39,9 @@ const TypeComponentMap = {
role: RoleCell, role: RoleCell,
} }
export const getCellRenderer = column => { export const getCellRenderer = column => {
return TypeComponentMap[column?.schema?.type] || TextCell return (
TypeComponentMap[column?.schema?.cellRenderType] ||
TypeComponentMap[column?.schema?.type] ||
TextCell
)
} }

View File

@ -42,6 +42,11 @@ export const deriveStores = context => {
return map return map
}) })
// Derived list of columns which are direct part of the table
const tableColumns = derived(columns, $columns => {
return $columns.filter(col => !col.related)
})
// Derived list of columns which have not been explicitly hidden // Derived list of columns which have not been explicitly hidden
const visibleColumns = derived(columns, $columns => { const visibleColumns = derived(columns, $columns => {
return $columns.filter(col => col.visible) return $columns.filter(col => col.visible)
@ -64,6 +69,7 @@ export const deriveStores = context => {
}) })
return { return {
tableColumns,
displayColumn, displayColumn,
columnLookupMap, columnLookupMap,
visibleColumns, visibleColumns,
@ -73,16 +79,24 @@ export const deriveStores = context => {
} }
export const createActions = context => { export const createActions = context => {
const { columns, datasource, schema } = context const { columns, datasource } = context
// Updates the width of all columns // Updates the width of all columns
const changeAllColumnWidths = async width => { const changeAllColumnWidths = async width => {
const $schema = get(schema) const $columns = get(columns)
let mutations = {} $columns.forEach(column => {
Object.keys($schema).forEach(field => { const { related } = column
mutations[field] = { width } const mutation = { width }
if (!related) {
datasource.actions.addSchemaMutation(column.name, mutation)
} else {
datasource.actions.addSubSchemaMutation(
related.subField,
related.field,
mutation
)
}
}) })
datasource.actions.addSchemaMutations(mutations)
await datasource.actions.saveSchemaMutations() await datasource.actions.saveSchemaMutations()
} }
@ -136,7 +150,7 @@ export const initialise = context => {
.map(field => { .map(field => {
const fieldSchema = $enrichedSchema[field] const fieldSchema = $enrichedSchema[field]
const oldColumn = $columns?.find(col => col.name === field) const oldColumn = $columns?.find(col => col.name === field)
let column = { const column = {
name: field, name: field,
label: fieldSchema.displayName || field, label: fieldSchema.displayName || field,
schema: fieldSchema, schema: fieldSchema,
@ -146,6 +160,7 @@ export const initialise = context => {
order: fieldSchema.order ?? oldColumn?.order, order: fieldSchema.order ?? oldColumn?.order,
conditions: fieldSchema.conditions, conditions: fieldSchema.conditions,
enrichValue: fieldSchema.enrichValue, enrichValue: fieldSchema.enrichValue,
related: fieldSchema.related,
} }
// Override a few properties for primary display // Override a few properties for primary display
if (field === primaryDisplay) { if (field === primaryDisplay) {

View File

@ -1,6 +1,6 @@
import { derived, get } from "svelte/store" import { derived, get } from "svelte/store"
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch" import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { memo } from "../../../utils" import { enrichSchemaWithRelColumns, memo } from "../../../utils"
export const createStores = () => { export const createStores = () => {
const definition = memo(null) const definition = memo(null)
@ -53,10 +53,13 @@ export const deriveStores = context => {
if (!$schema) { if (!$schema) {
return null return null
} }
let enrichedSchema = {}
Object.keys($schema).forEach(field => { const schemaWithRelatedColumns = enrichSchemaWithRelColumns($schema)
const enrichedSchema = {}
Object.keys(schemaWithRelatedColumns).forEach(field => {
enrichedSchema[field] = { enrichedSchema[field] = {
...$schema[field], ...schemaWithRelatedColumns[field],
...$schemaOverrides?.[field], ...$schemaOverrides?.[field],
...$schemaMutations[field], ...$schemaMutations[field],
} }
@ -202,24 +205,6 @@ export const createActions = context => {
}) })
} }
// Adds schema mutations for multiple fields at once
const addSchemaMutations = mutations => {
const fields = Object.keys(mutations || {})
if (!fields.length) {
return
}
schemaMutations.update($schemaMutations => {
let newSchemaMutations = { ...$schemaMutations }
fields.forEach(field => {
newSchemaMutations[field] = {
...newSchemaMutations[field],
...mutations[field],
}
})
return newSchemaMutations
})
}
// Saves schema changes to the server, if possible // Saves schema changes to the server, if possible
const saveSchemaMutations = async () => { const saveSchemaMutations = async () => {
// If we can't save schema changes then we just want to keep this in memory // If we can't save schema changes then we just want to keep this in memory
@ -309,7 +294,6 @@ export const createActions = context => {
changePrimaryDisplay, changePrimaryDisplay,
addSchemaMutation, addSchemaMutation,
addSubSchemaMutation, addSubSchemaMutation,
addSchemaMutations,
saveSchemaMutations, saveSchemaMutations,
resetSchemaMutations, resetSchemaMutations,
}, },

View File

@ -133,24 +133,29 @@ export const initialise = context => {
// When sorting changes, ensure view definition is kept up to date // When sorting changes, ensure view definition is kept up to date
unsubscribers.push( unsubscribers.push(
sort.subscribe(async $sort => { sort.subscribe(async $sort => {
// Ensure we're updating the correct view
const $view = get(definition)
if ($view?.id !== $datasource.id) {
return
}
// Skip if nothing actually changed
if (
$sort?.column === $view.sort?.field &&
$sort?.order === $view.sort?.order
) {
return
}
// If we can mutate schema then update the view definition // If we can mutate schema then update the view definition
if (get(config).canSaveSchema) { if (get(config).canSaveSchema) {
const $view = get(definition) await datasource.actions.saveDefinition({
if ($view?.id !== $datasource.id) { ...$view,
return sort: {
} field: $sort.column,
if ( order: $sort.order || "ascending",
$sort?.column !== $view.sort?.field || },
$sort?.order !== $view.sort?.order })
) {
await datasource.actions.saveDefinition({
...$view,
sort: {
field: $sort.column,
order: $sort.order || "ascending",
},
})
}
} }
// Also update the fetch to ensure the new sort is respected. // Also update the fetch to ensure the new sort is respected.

View File

@ -214,11 +214,20 @@ export const createActions = context => {
}) })
// Extract new orders as schema mutations // Extract new orders as schema mutations
let mutations = {}
get(columns).forEach((column, idx) => { get(columns).forEach((column, idx) => {
mutations[column.name] = { order: idx } const { related } = column
const mutation = { order: idx }
if (!related) {
datasource.actions.addSchemaMutation(column.name, mutation)
} else {
datasource.actions.addSubSchemaMutation(
related.subField,
related.field,
mutation
)
}
}) })
datasource.actions.addSchemaMutations(mutations)
await datasource.actions.saveSchemaMutations() await datasource.actions.saveSchemaMutations()
} }

View File

@ -38,6 +38,7 @@ export const createActions = context => {
initialWidth: column.width, initialWidth: column.width,
initialMouseX: x, initialMouseX: x,
column: column.name, column: column.name,
related: column.related,
}) })
// Add mouse event listeners to handle resizing // Add mouse event listeners to handle resizing
@ -50,7 +51,7 @@ export const createActions = context => {
// Handler for moving the mouse to resize columns // Handler for moving the mouse to resize columns
const onResizeMouseMove = e => { const onResizeMouseMove = e => {
const { initialMouseX, initialWidth, width, column } = get(resize) const { initialMouseX, initialWidth, width, column, related } = get(resize)
const { x } = parseEventLocation(e) const { x } = parseEventLocation(e)
const dx = x - initialMouseX const dx = x - initialMouseX
const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx)) const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx))
@ -61,7 +62,13 @@ export const createActions = context => {
} }
// Update column state // Update column state
datasource.actions.addSchemaMutation(column, { width }) if (!related) {
datasource.actions.addSchemaMutation(column, { width })
} else {
datasource.actions.addSubSchemaMutation(related.subField, related.field, {
width,
})
}
// Update state // Update state
resize.update(state => ({ resize.update(state => ({

View File

@ -6,6 +6,7 @@ import { tick } from "svelte"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { sleep } from "../../../utils/utils" import { sleep } from "../../../utils/utils"
import { FieldType } from "@budibase/types" import { FieldType } from "@budibase/types"
import { getRelatedTableValues } from "../../../utils"
export const createStores = () => { export const createStores = () => {
const rows = writable([]) const rows = writable([])
@ -42,15 +43,26 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { rows } = context const { rows, enrichedSchema } = context
// Enrich rows with an index property and any pending changes // Enrich rows with an index property and any pending changes
const enrichedRows = derived(rows, $rows => { const enrichedRows = derived(
return $rows.map((row, idx) => ({ [rows, enrichedSchema],
...row, ([$rows, $enrichedSchema]) => {
__idx: idx, const customColumns = Object.values($enrichedSchema || {}).filter(
})) f => f.related
}) )
return $rows.map((row, idx) => ({
...row,
__idx: idx,
...customColumns.reduce((map, column) => {
const fromField = $enrichedSchema[column.related.field]
map[column.name] = getRelatedTableValues(row, column, fromField)
return map
}, {}),
}))
}
)
// Generate a lookup map to quick find a row by ID // Generate a lookup map to quick find a row by ID
const rowLookupMap = derived(enrichedRows, $enrichedRows => { const rowLookupMap = derived(enrichedRows, $enrichedRows => {

View File

@ -11,3 +11,5 @@ export { createWebsocket } from "./websocket"
export * from "./download" export * from "./download"
export * from "./theme" export * from "./theme"
export * from "./settings" export * from "./settings"
export * from "./relatedColumns"
export * from "./table"

View File

@ -0,0 +1,107 @@
import { FieldType, RelationshipType } from "@budibase/types"
import { Helpers } from "@budibase/bbui"
const columnTypeManyTypeOverrides = {
[FieldType.DATETIME]: FieldType.STRING,
[FieldType.BOOLEAN]: FieldType.STRING,
[FieldType.SIGNATURE_SINGLE]: FieldType.ATTACHMENTS,
}
const columnTypeManyParser = {
[FieldType.DATETIME]: (value, field) => {
function parseDate(value) {
const { timeOnly, dateOnly, ignoreTimezones } = field || {}
const enableTime = !dateOnly
const parsedValue = Helpers.parseDate(value, {
timeOnly,
enableTime,
ignoreTimezones,
})
const parsed = Helpers.getDateDisplayValue(parsedValue, {
enableTime,
timeOnly,
})
return parsed
}
return value?.map(v => parseDate(v))
},
[FieldType.BOOLEAN]: value => value?.map(v => !!v),
[FieldType.BB_REFERENCE_SINGLE]: value => [
...new Map(value.map(i => [i._id, i])).values(),
],
[FieldType.BB_REFERENCE]: value => [
...new Map(value.map(i => [i._id, i])).values(),
],
[FieldType.ARRAY]: value => Array.from(new Set(value)),
}
export function enrichSchemaWithRelColumns(schema) {
if (!schema) {
return
}
const result = Object.keys(schema).reduce((result, fieldName) => {
const field = schema[fieldName]
result[fieldName] = field
if (field.visible !== false && field.columns) {
const fromSingle =
field?.relationshipType === RelationshipType.ONE_TO_MANY
for (const relColumn of Object.keys(field.columns)) {
const relField = field.columns[relColumn]
if (!relField.visible) {
continue
}
const name = `${field.name}.${relColumn}`
result[name] = {
...relField,
name,
related: { field: fieldName, subField: relColumn },
cellRenderType:
(!fromSingle && columnTypeManyTypeOverrides[relField.type]) ||
relField.type,
}
}
}
return result
}, {})
return result
}
export function getRelatedTableValues(row, field, fromField) {
const fromSingle =
fromField?.relationshipType === RelationshipType.ONE_TO_MANY
let result = ""
if (fromSingle) {
result = row[field.related.field]?.[0]?.[field.related.subField]
} else {
const parser = columnTypeManyParser[field.type] || (value => value)
result = parser(
row[field.related.field]
?.flatMap(r => r[field.related.subField])
?.filter(i => i !== undefined && i !== null),
field
)
if (
[
FieldType.STRING,
FieldType.NUMBER,
FieldType.BIGINT,
FieldType.BOOLEAN,
FieldType.DATETIME,
FieldType.LONGFORM,
FieldType.BARCODEQR,
].includes(field.type)
) {
result = result?.join(", ")
}
}
return result
}

View File

@ -0,0 +1,27 @@
import * as sharedCore from "@budibase/shared-core"
export function canBeDisplayColumn(column) {
if (!sharedCore.canBeDisplayColumn(column.type)) {
return false
}
if (column.related) {
// If it's a related column (only available in the frontend), don't allow using it as display column
return false
}
return true
}
export function canBeSortColumn(column) {
if (!sharedCore.canBeSortColumn(column.type)) {
return false
}
if (column.related) {
// If it's a related column (only available in the frontend), don't allow using it as display column
return false
}
return true
}

View File

@ -1,9 +1,7 @@
import { permissions, roles, context } from "@budibase/backend-core" import { permissions, roles, context } from "@budibase/backend-core"
import { import {
UserCtx, UserCtx,
Database,
Role, Role,
PermissionLevel,
GetResourcePermsResponse, GetResourcePermsResponse,
ResourcePermissionInfo, ResourcePermissionInfo,
GetDependantResourcesResponse, GetDependantResourcesResponse,
@ -12,107 +10,15 @@ import {
RemovePermissionRequest, RemovePermissionRequest,
RemovePermissionResponse, RemovePermissionResponse,
} from "@budibase/types" } from "@budibase/types"
import { getRoleParams } from "../../db/utils"
import { import {
CURRENTLY_SUPPORTED_LEVELS, CURRENTLY_SUPPORTED_LEVELS,
getBasePermissions, getBasePermissions,
} from "../../utilities/security" } from "../../utilities/security"
import { removeFromArray } from "../../utilities"
import sdk from "../../sdk" import sdk from "../../sdk"
import { PermissionUpdateType } from "../../sdk/app/permissions"
const enum PermissionUpdateType {
REMOVE = "remove",
ADD = "add",
}
const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS
// utility function to stop this repetition - permissions always stored under roles
async function getAllDBRoles(db: Database) {
const body = await db.allDocs<Role>(
getRoleParams(null, {
include_docs: true,
})
)
return body.rows.map(row => row.doc!)
}
async function updatePermissionOnRole(
{
roleId,
resourceId,
level,
}: { roleId: string; resourceId: string; level: PermissionLevel },
updateType: PermissionUpdateType
) {
const db = context.getAppDB()
const remove = updateType === PermissionUpdateType.REMOVE
const isABuiltin = roles.isBuiltin(roleId)
const dbRoleId = roles.getDBRoleID(roleId)
const dbRoles = await getAllDBRoles(db)
const docUpdates: Role[] = []
// the permission is for a built in, make sure it exists
if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) {
const builtin = roles.getBuiltinRoles()[roleId]
builtin._id = roles.getDBRoleID(builtin._id!)
dbRoles.push(builtin)
}
// now try to find any roles which need updated, e.g. removing the
// resource from another role and then adding to the new role
for (let role of dbRoles) {
let updated = false
const rolePermissions: Record<string, PermissionLevel[]> = role.permissions
? role.permissions
: {}
// make sure its an array, also handle migrating
if (
!rolePermissions[resourceId] ||
!Array.isArray(rolePermissions[resourceId])
) {
rolePermissions[resourceId] =
typeof rolePermissions[resourceId] === "string"
? [rolePermissions[resourceId] as unknown as PermissionLevel]
: []
}
// handle the removal/updating the role which has this permission first
// the updating (role._id !== dbRoleId) is required because a resource/level can
// only be permitted in a single role (this reduces hierarchy confusion and simplifies
// the general UI for this, rather than needing to show everywhere it is used)
if (
(role._id !== dbRoleId || remove) &&
rolePermissions[resourceId].indexOf(level) !== -1
) {
removeFromArray(rolePermissions[resourceId], level)
updated = true
}
// handle the adding, we're on the correct role, at it to this
if (!remove && role._id === dbRoleId) {
const set = new Set(rolePermissions[resourceId])
rolePermissions[resourceId] = [...set.add(level)]
updated = true
}
// handle the update, add it to bulk docs to perform at end
if (updated) {
role.permissions = rolePermissions
docUpdates.push(role)
}
}
const response = await db.bulkDocs(docUpdates)
return response.map(resp => {
const version = docUpdates.find(role => role._id === resp.id)?.version
const _id = roles.getExternalRoleID(resp.id, version)
return {
_id,
rev: resp.rev,
error: resp.error,
reason: resp.reason,
}
})
}
export function fetchBuiltin(ctx: UserCtx) { export function fetchBuiltin(ctx: UserCtx) {
ctx.body = Object.values(permissions.getBuiltinPermissions()) ctx.body = Object.values(permissions.getBuiltinPermissions())
} }
@ -124,7 +30,7 @@ export function fetchLevels(ctx: UserCtx) {
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const dbRoles: Role[] = await getAllDBRoles(db) const dbRoles: Role[] = await sdk.permissions.getAllDBRoles(db)
let permissions: any = {} let permissions: any = {}
// create an object with structure role ID -> resource ID -> level // create an object with structure role ID -> resource ID -> level
for (let role of dbRoles) { for (let role of dbRoles) {
@ -186,12 +92,18 @@ export async function getDependantResources(
export async function addPermission(ctx: UserCtx<void, AddPermissionResponse>) { export async function addPermission(ctx: UserCtx<void, AddPermissionResponse>) {
const params: AddPermissionRequest = ctx.params const params: AddPermissionRequest = ctx.params
ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.ADD) ctx.body = await sdk.permissions.updatePermissionOnRole(
params,
PermissionUpdateType.ADD
)
} }
export async function removePermission( export async function removePermission(
ctx: UserCtx<void, RemovePermissionResponse> ctx: UserCtx<void, RemovePermissionResponse>
) { ) {
const params: RemovePermissionRequest = ctx.params const params: RemovePermissionRequest = ctx.params
ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.REMOVE) ctx.body = await sdk.permissions.updatePermissionOnRole(
params,
PermissionUpdateType.REMOVE
)
} }

View File

@ -125,6 +125,12 @@ describe("/permission", () => {
}) })
it("should be able to access the view data when the table is set to public and with no view permissions overrides", async () => { it("should be able to access the view data when the table is set to public and with no view permissions overrides", async () => {
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions // replicate changes before checking permissions
await config.publish() await config.publish()
@ -138,6 +144,12 @@ describe("/permission", () => {
resourceId: table._id, resourceId: table._id,
level: PermissionLevel.READ, level: PermissionLevel.READ,
}) })
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions // replicate changes before checking permissions
await config.publish() await config.publish()

View File

@ -2684,7 +2684,7 @@ describe.each([
async (__, retrieveDelegate) => { async (__, retrieveDelegate) => {
await withCoreEnv( await withCoreEnv(
{ {
TENANT_FEATURE_FLAGS: ``, TENANT_FEATURE_FLAGS: `*:!${FeatureFlag.ENRICHED_RELATIONSHIPS}`,
}, },
async () => { async () => {
const otherRows = _.sampleSize(auxData, 5) const otherRows = _.sampleSize(auxData, 5)

View File

@ -826,11 +826,20 @@ describe("/rowsActions", () => {
) )
).id ).id
// Allow row action on view
await config.api.rowAction.setViewPermission( await config.api.rowAction.setViewPermission(
tableId, tableId,
viewId, viewId,
rowAction.id rowAction.id
) )
// Delete explicit view permissions so they inherit table permissions
await config.api.permission.revoke({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, // Don't think this matters since we are revoking the permission
level: PermissionLevel.READ,
resourceId: viewId,
})
return { permissionResource: tableId, triggerResouce: viewId } return { permissionResource: tableId, triggerResouce: viewId }
}, },
], ],

View File

@ -22,6 +22,8 @@ import {
TableSchema, TableSchema,
ViewFieldMetadata, ViewFieldMetadata,
RenameColumn, RenameColumn,
FeatureFlag,
BBReferenceFieldSubType,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
@ -32,6 +34,7 @@ import {
roles, roles,
withEnv as withCoreEnv, withEnv as withCoreEnv,
setEnv as setCoreEnv, setEnv as setCoreEnv,
env,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
@ -694,22 +697,23 @@ describe.each([
) )
}) })
it("cannot update views v1", async () => { isInternal &&
const viewV1 = await config.api.legacyView.save({ it("cannot update views v1", async () => {
tableId: table._id!, const viewV1 = await config.api.legacyView.save({
name: generator.guid(), tableId: table._id!,
filters: [], name: generator.guid(),
schema: {}, filters: [],
}) schema: {},
})
await config.api.viewV2.update(viewV1 as unknown as ViewV2, { await config.api.viewV2.update(viewV1 as unknown as ViewV2, {
status: 400,
body: {
message: "Only views V2 can be updated",
status: 400, status: 400,
}, body: {
message: "Only views V2 can be updated",
status: 400,
},
})
}) })
})
it("cannot update the a view with unmatching ids between url and body", async () => { it("cannot update the a view with unmatching ids between url and body", async () => {
const anotherView = await config.api.viewV2.create({ const anotherView = await config.api.viewV2.create({
@ -2213,6 +2217,171 @@ describe.each([
}) })
) )
}) })
describe("foreign relationship columns", () => {
let envCleanup: () => void
beforeAll(() => {
const flags = [`*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`]
if (env.TENANT_FEATURE_FLAGS) {
flags.push(...env.TENANT_FEATURE_FLAGS.split(","))
}
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: flags.join(","),
})
})
afterAll(() => {
envCleanup?.()
})
const createMainTable = async (
links: {
name: string
tableId: string
fk: string
}[]
) => {
const table = await config.api.table.save(
saveTableRequest({
schema: { title: { name: "title", type: FieldType.STRING } },
})
)
await config.api.table.save({
...table,
schema: {
...table.schema,
...links.reduce<TableSchema>((acc, c) => {
acc[c.name] = {
name: c.name,
relationshipType: RelationshipType.ONE_TO_MANY,
type: FieldType.LINK,
tableId: c.tableId,
fieldName: c.fk,
constraints: { type: "array" },
}
return acc
}, {}),
},
})
return table
}
const createAuxTable = (schema: TableSchema) =>
config.api.table.save(
saveTableRequest({
primaryDisplay: "name",
schema: {
...schema,
name: { name: "name", type: FieldType.STRING },
},
})
)
it("returns squashed fields respecting the view config", async () => {
const auxTable = await createAuxTable({
age: { name: "age", type: FieldType.NUMBER },
})
const auxRow = await config.api.row.save(auxTable._id!, {
name: generator.name(),
age: generator.age(),
})
const table = await createMainTable([
{ name: "aux", tableId: auxTable._id!, fk: "fk_aux" },
])
await config.api.row.save(table._id!, {
title: generator.word(),
aux: [auxRow],
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
title: { visible: true },
aux: {
visible: true,
columns: {
name: { visible: false, readonly: false },
age: { visible: true, readonly: true },
},
},
},
})
const response = await config.api.viewV2.search(view.id)
expect(response.rows).toEqual([
expect.objectContaining({
aux: [
{
_id: auxRow._id,
primaryDisplay: auxRow.name,
age: auxRow.age,
},
],
}),
])
})
it("enriches squashed fields", async () => {
const auxTable = await createAuxTable({
user: {
name: "user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
constraints: { presence: true },
},
})
const table = await createMainTable([
{ name: "aux", tableId: auxTable._id!, fk: "fk_aux" },
])
const user = config.getUser()
const auxRow = await config.api.row.save(auxTable._id!, {
name: generator.name(),
user: user._id,
})
await config.api.row.save(table._id!, {
title: generator.word(),
aux: [auxRow],
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
title: { visible: true },
aux: {
visible: true,
columns: {
name: { visible: true, readonly: true },
user: { visible: true, readonly: true },
},
},
},
})
const response = await config.api.viewV2.search(view.id)
expect(response.rows).toEqual([
expect.objectContaining({
aux: [
{
_id: auxRow._id,
primaryDisplay: auxRow.name,
name: auxRow.name,
user: {
_id: user._id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
primaryDisplay: user.email,
},
},
],
}),
])
})
})
}) })
describe("permissions", () => { describe("permissions", () => {
@ -2248,6 +2417,11 @@ describe.each([
level: PermissionLevel.READ, level: PermissionLevel.READ,
resourceId: table._id!, resourceId: table._id!,
}) })
await config.api.permission.revoke({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, // Don't think this matters since we are revoking the permission
level: PermissionLevel.READ,
resourceId: view.id,
})
await config.publish() await config.publish()
const response = await config.api.viewV2.publicSearch(view.id) const response = await config.api.viewV2.publicSearch(view.id)

View File

@ -32,25 +32,25 @@ describe("Branching automations", () => {
steps: stepBuilder => steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 1.1" }), stepBuilder.serverLog({ text: "Branch 1.1" }),
condition: { condition: {
equal: { "steps.1.success": true }, equal: { "{{steps.1.success}}": true },
}, },
}, },
branch2: { branch2: {
steps: stepBuilder => steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 1.2" }), stepBuilder.serverLog({ text: "Branch 1.2" }),
condition: { condition: {
equal: { "steps.1.success": false }, equal: { "{{steps.1.success}}": false },
}, },
}, },
}), }),
condition: { condition: {
equal: { "steps.1.success": true }, equal: { "{{steps.1.success}}": true },
}, },
}, },
topLevelBranch2: { topLevelBranch2: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }),
condition: { condition: {
equal: { "steps.1.success": false }, equal: { "{{steps.1.success}}": false },
}, },
}, },
}) })
@ -70,14 +70,14 @@ describe("Branching automations", () => {
activeBranch: { activeBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Active user" }), steps: stepBuilder => stepBuilder.serverLog({ text: "Active user" }),
condition: { condition: {
equal: { "trigger.fields.status": "active" }, equal: { "{{trigger.fields.status}}": "active" },
}, },
}, },
inactiveBranch: { inactiveBranch: {
steps: stepBuilder => steps: stepBuilder =>
stepBuilder.serverLog({ text: "Inactive user" }), stepBuilder.serverLog({ text: "Inactive user" }),
condition: { condition: {
equal: { "trigger.fields.status": "inactive" }, equal: { "{{trigger.fields.status}}": "inactive" },
}, },
}, },
}) })
@ -102,8 +102,8 @@ describe("Branching automations", () => {
condition: { condition: {
$and: { $and: {
conditions: [ conditions: [
{ equal: { "trigger.fields.status": "active" } }, { equal: { "{{trigger.fields.status}}": "active" } },
{ equal: { "trigger.fields.role": "admin" } }, { equal: { "{{trigger.fields.role}}": "admin" } },
], ],
}, },
}, },
@ -111,7 +111,7 @@ describe("Branching automations", () => {
otherBranch: { otherBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Other user" }), steps: stepBuilder => stepBuilder.serverLog({ text: "Other user" }),
condition: { condition: {
notEqual: { "trigger.fields.status": "active" }, notEqual: { "{{trigger.fields.status}}": "active" },
}, },
}, },
}) })
@ -133,8 +133,8 @@ describe("Branching automations", () => {
condition: { condition: {
$or: { $or: {
conditions: [ conditions: [
{ equal: { "trigger.fields.status": "test" } }, { equal: { "{{trigger.fields.status}}": "test" } },
{ equal: { "trigger.fields.role": "admin" } }, { equal: { "{{trigger.fields.role}}": "admin" } },
], ],
}, },
}, },
@ -144,8 +144,8 @@ describe("Branching automations", () => {
condition: { condition: {
$and: { $and: {
conditions: [ conditions: [
{ notEqual: { "trigger.fields.status": "active" } }, { notEqual: { "{{trigger.fields.status}}": "active" } },
{ notEqual: { "trigger.fields.role": "admin" } }, { notEqual: { "{{trigger.fields.role}}": "admin" } },
], ],
}, },
}, },
@ -170,8 +170,8 @@ describe("Branching automations", () => {
condition: { condition: {
$or: { $or: {
conditions: [ conditions: [
{ equal: { "trigger.fields.status": "new" } }, { equal: { "{{trigger.fields.status}}": "new" } },
{ equal: { "trigger.fields.role": "admin" } }, { equal: { "{{trigger.fields.role}}": "admin" } },
], ],
}, },
}, },
@ -181,8 +181,8 @@ describe("Branching automations", () => {
condition: { condition: {
$and: { $and: {
conditions: [ conditions: [
{ equal: { "trigger.fields.status": "active" } }, { equal: { "{{trigger.fields.status}}": "active" } },
{ equal: { "trigger.fields.role": "admin" } }, { equal: { "{{trigger.fields.role}}": "admin" } },
], ],
}, },
}, },

View File

@ -5,6 +5,7 @@ import {
LoopStepType, LoopStepType,
CreateRowStepOutputs, CreateRowStepOutputs,
ServerLogStepOutputs, ServerLogStepOutputs,
FieldType,
} from "@budibase/types" } from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
@ -269,4 +270,145 @@ describe("Loop automations", () => {
expect(results.steps[1].outputs.message).toContain("- 3") expect(results.steps[1].outputs.message).toContain("- 3")
}) })
it("should run an automation with a loop and update row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const builder = createAutomationBuilder({
name: "Test Loop and Update Row",
})
const results = await builder
.appAction({ fields: {} })
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.updateRow({
rowId: "{{ loop.currentItem._id }}",
row: {
name: "Updated {{ loop.currentItem.name }}",
value: "{{ loop.currentItem.value }}",
tableId: table._id,
},
meta: {},
})
.queryRows({
tableId: table._id!,
})
.run()
const expectedRows = [
{ name: "Updated Row 1", value: 1 },
{ name: "Updated Row 2", value: 2 },
{ name: "Updated Row 3", value: 3 },
]
expect(results.steps[1].outputs.items).toEqual(
expect.arrayContaining(
expectedRows.map(row =>
expect.objectContaining({
success: true,
row: expect.objectContaining(row),
})
)
)
)
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
expect(results.steps[1].outputs.items).toHaveLength(expectedRows.length)
expect(results.steps[2].outputs.rows).toHaveLength(expectedRows.length)
})
it("should run an automation with a loop and delete row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const builder = createAutomationBuilder({
name: "Test Loop and Delete Row",
})
const results = await builder
.appAction({ fields: {} })
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.deleteRow({
tableId: table._id!,
id: "{{ loop.currentItem._id }}",
})
.queryRows({
tableId: table._id!,
})
.run()
expect(results.steps).toHaveLength(3)
expect(results.steps[2].outputs.rows).toHaveLength(0)
})
}) })

View File

@ -1,8 +1,9 @@
import * as automation from "../../index" import * as automation from "../../index"
import * as setup from "../utilities" import * as setup from "../utilities"
import { LoopStepType, FieldType } from "@budibase/types" import { LoopStepType, FieldType, Table } from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import { DatabaseName } from "../../../integrations/tests/utils" import { DatabaseName } from "../../../integrations/tests/utils"
import { FilterConditions } from "../../../automations/steps/filter"
describe("Automation Scenarios", () => { describe("Automation Scenarios", () => {
let config = setup.getConfig() let config = setup.getConfig()
@ -195,6 +196,91 @@ describe("Automation Scenarios", () => {
) )
}) })
}) })
it("should trigger an automation which creates and then updates a row", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const builder = createAutomationBuilder({
name: "Test Create and Update Row",
})
const results = await builder
.appAction({ fields: {} })
.createRow(
{
row: {
name: "Initial Row",
value: 1,
tableId: table._id,
},
},
{ stepName: "CreateRowStep" }
)
.updateRow(
{
rowId: "{{ steps.CreateRowStep.row._id }}",
row: {
name: "Updated Row",
value: 2,
tableId: table._id,
},
meta: {},
},
{ stepName: "UpdateRowStep" }
)
.queryRows(
{
tableId: table._id!,
},
{ stepName: "QueryRowsStep" }
)
.run()
expect(results.steps).toHaveLength(3)
expect(results.steps[0].outputs).toMatchObject({
success: true,
row: {
name: "Initial Row",
value: 1,
},
})
expect(results.steps[1].outputs).toMatchObject({
success: true,
row: {
name: "Updated Row",
value: 2,
},
})
const expectedRows = [{ name: "Updated Row", value: 2 }]
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
})
}) })
describe("Name Based Automations", () => { describe("Name Based Automations", () => {
@ -233,4 +319,167 @@ describe("Automation Scenarios", () => {
expect(results.steps[2].outputs.rows).toHaveLength(1) expect(results.steps[2].outputs.rows).toHaveLength(1)
}) })
}) })
describe("Automations with filter", () => {
let table: Table
beforeEach(async () => {
table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
})
it("should stop an automation if the condition is not met", async () => {
const builder = createAutomationBuilder({
name: "Test Equal",
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: "Equal Test",
value: 10,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition: FilterConditions.EQUAL,
value: 20,
})
.serverLog({ text: "Equal condition met" })
.run()
expect(results.steps[2].outputs.success).toBeTrue()
expect(results.steps[2].outputs.result).toBeFalse()
expect(results.steps[3]).toBeUndefined()
})
it("should continue the automation if the condition is met", async () => {
const builder = createAutomationBuilder({
name: "Test Not Equal",
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: "Not Equal Test",
value: 10,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition: FilterConditions.NOT_EQUAL,
value: 20,
})
.serverLog({ text: "Not Equal condition met" })
.run()
expect(results.steps[2].outputs.success).toBeTrue()
expect(results.steps[2].outputs.result).toBeTrue()
expect(results.steps[3].outputs.success).toBeTrue()
})
const testCases = [
{
condition: FilterConditions.EQUAL,
value: 10,
rowValue: 10,
expectPass: true,
},
{
condition: FilterConditions.NOT_EQUAL,
value: 10,
rowValue: 20,
expectPass: true,
},
{
condition: FilterConditions.GREATER_THAN,
value: 10,
rowValue: 15,
expectPass: true,
},
{
condition: FilterConditions.LESS_THAN,
value: 10,
rowValue: 5,
expectPass: true,
},
{
condition: FilterConditions.GREATER_THAN,
value: 10,
rowValue: 5,
expectPass: false,
},
{
condition: FilterConditions.LESS_THAN,
value: 10,
rowValue: 15,
expectPass: false,
},
]
it.each(testCases)(
"should pass the filter when condition is $condition",
async ({ condition, value, rowValue, expectPass }) => {
const builder = createAutomationBuilder({
name: `Test ${condition}`,
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: `${condition} Test`,
value: rowValue,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition,
value,
})
.serverLog({
text: `${condition} condition ${expectPass ? "passed" : "failed"}`,
})
.run()
expect(results.steps[2].outputs.result).toBe(expectPass)
if (expectPass) {
expect(results.steps[3].outputs.success).toBeTrue()
} else {
expect(results.steps[3]).toBeUndefined()
}
}
)
})
}) })

View File

@ -33,6 +33,7 @@ import {
BranchStepInputs, BranchStepInputs,
SearchFilters, SearchFilters,
Branch, Branch,
FilterStepInputs,
} from "@budibase/types" } from "@budibase/types"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import * as setup from "../utilities" import * as setup from "../utilities"
@ -181,6 +182,14 @@ class BaseStepBuilder {
opts?.stepName opts?.stepName
) )
} }
filter(input: FilterStepInputs): this {
return this.step(
AutomationActionStepId.FILTER,
BUILTIN_ACTION_DEFINITIONS.FILTER,
input
)
}
} }
class StepBuilder extends BaseStepBuilder { class StepBuilder extends BaseStepBuilder {
build(): AutomationStep[] { build(): AutomationStep[] {

View File

@ -10,7 +10,10 @@ import flatten from "lodash/flatten"
import { USER_METDATA_PREFIX } from "../utils" import { USER_METDATA_PREFIX } from "../utils"
import partition from "lodash/partition" import partition from "lodash/partition"
import { getGlobalUsersFromMetadata } from "../../utilities/global" import { getGlobalUsersFromMetadata } from "../../utilities/global"
import { processFormulas } from "../../utilities/rowProcessor" import {
coreOutputProcessing,
processFormulas,
} from "../../utilities/rowProcessor"
import { context, features } from "@budibase/backend-core" import { context, features } from "@budibase/backend-core"
import { import {
ContextUser, ContextUser,
@ -156,9 +159,6 @@ export async function updateLinks(args: {
/** /**
* Given a table and a list of rows this will retrieve all of the attached docs and enrich them into the row. * Given a table and a list of rows this will retrieve all of the attached docs and enrich them into the row.
* This is required for formula fields, this may only be utilised internally (for now). * This is required for formula fields, this may only be utilised internally (for now).
* @param table The table from which the rows originated.
* @param rows The rows which are to be enriched.
* @param opts optional - options like passing in a base row to use for enrichment.
* @return returns the rows with all of the enriched relationships on it. * @return returns the rows with all of the enriched relationships on it.
*/ */
export async function attachFullLinkedDocs( export async function attachFullLinkedDocs(
@ -248,9 +248,6 @@ export type SquashTableFields = Record<string, { visibleFieldNames: string[] }>
/** /**
* This function will take the given enriched rows and squash the links to only contain the primary display field. * This function will take the given enriched rows and squash the links to only contain the primary display field.
* @param table The table from which the rows originated.
* @param enriched The pre-enriched rows (full docs) which are to be squashed.
* @param squashFields Per link column (key) define which columns are allowed while squashing.
* @returns The rows after having their links squashed to only contain the ID and primary display. * @returns The rows after having their links squashed to only contain the ID and primary display.
*/ */
export async function squashLinks<T = Row[] | Row>( export async function squashLinks<T = Row[] | Row>(
@ -275,7 +272,7 @@ export async function squashLinks<T = Row[] | Row>(
// will populate this as we find them // will populate this as we find them
const linkedTables = [table] const linkedTables = [table]
const isArray = Array.isArray(enriched) const isArray = Array.isArray(enriched)
const enrichedArray = !isArray ? [enriched] : enriched const enrichedArray = !isArray ? [enriched as Row] : (enriched as Row[])
for (const row of enrichedArray) { for (const row of enrichedArray) {
// this only fetches the table if its not already in array // this only fetches the table if its not already in array
const rowTable = await getLinkedTable(row.tableId!, linkedTables) const rowTable = await getLinkedTable(row.tableId!, linkedTables)
@ -283,18 +280,18 @@ export async function squashLinks<T = Row[] | Row>(
if (schema.type !== FieldType.LINK || !Array.isArray(row[column])) { if (schema.type !== FieldType.LINK || !Array.isArray(row[column])) {
continue continue
} }
const newLinks = [] const relatedTable = await getLinkedTable(schema.tableId, linkedTables)
for (const link of row[column]) { if (viewSchema[column]?.columns) {
const linkTblId = row[column] = await coreOutputProcessing(relatedTable, row[column])
link.tableId || getRelatedTableForField(table.schema, column) }
const linkedTable = await getLinkedTable(linkTblId!, linkedTables) row[column] = row[column].map((link: Row) => {
const obj: any = { _id: link._id } const obj: any = { _id: link._id }
obj.primaryDisplay = getPrimaryDisplayValue(link, linkedTable) obj.primaryDisplay = getPrimaryDisplayValue(link, relatedTable)
if (viewSchema[column]?.columns) { if (viewSchema[column]?.columns) {
const squashFields = Object.entries(viewSchema[column].columns) const squashFields = Object.entries(viewSchema[column].columns || {})
.filter(([columnName, viewColumnConfig]) => { .filter(([columnName, viewColumnConfig]) => {
const tableColumn = linkedTable.schema[columnName] const tableColumn = relatedTable.schema[columnName]
if (!tableColumn) { if (!tableColumn) {
return false return false
} }
@ -312,14 +309,15 @@ export async function squashLinks<T = Row[] | Row>(
.map(([columnName]) => columnName) .map(([columnName]) => columnName)
for (const relField of squashFields) { for (const relField of squashFields) {
obj[relField] = link[relField] if (link[relField] != null) {
obj[relField] = link[relField]
}
} }
} }
newLinks.push(obj) return obj
} })
row[column] = newLinks
} }
} }
return isArray ? enrichedArray : enrichedArray[0] return (isArray ? enrichedArray : enrichedArray[0]) as T
} }

View File

@ -1,22 +1,34 @@
import { db, roles } from "@budibase/backend-core" import { db, roles, context } from "@budibase/backend-core"
import { import {
PermissionLevel, PermissionLevel,
PermissionSource, PermissionSource,
VirtualDocumentType, VirtualDocumentType,
Role,
Database,
} from "@budibase/types" } from "@budibase/types"
import { extractViewInfoFromID, isViewID } from "../../../db/utils" import {
extractViewInfoFromID,
isViewID,
getRoleParams,
} from "../../../db/utils"
import { import {
CURRENTLY_SUPPORTED_LEVELS, CURRENTLY_SUPPORTED_LEVELS,
getBasePermissions, getBasePermissions,
} from "../../../utilities/security" } from "../../../utilities/security"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { isV2 } from "../views" import { isV2 } from "../views"
import { removeFromArray } from "../../../utilities"
type ResourcePermissions = Record< type ResourcePermissions = Record<
string, string,
{ role: string; type: PermissionSource } { role: string; type: PermissionSource }
> >
export const enum PermissionUpdateType {
REMOVE = "remove",
ADD = "add",
}
export async function getInheritablePermissions( export async function getInheritablePermissions(
resourceId: string resourceId: string
): Promise<ResourcePermissions | undefined> { ): Promise<ResourcePermissions | undefined> {
@ -100,3 +112,89 @@ export async function getDependantResources(
return return
} }
export async function updatePermissionOnRole(
{
roleId,
resourceId,
level,
}: { roleId: string; resourceId: string; level: PermissionLevel },
updateType: PermissionUpdateType
) {
const db = context.getAppDB()
const remove = updateType === PermissionUpdateType.REMOVE
const isABuiltin = roles.isBuiltin(roleId)
const dbRoleId = roles.getDBRoleID(roleId)
const dbRoles = await getAllDBRoles(db)
const docUpdates: Role[] = []
// the permission is for a built in, make sure it exists
if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) {
const builtin = roles.getBuiltinRoles()[roleId]
builtin._id = roles.getDBRoleID(builtin._id!)
dbRoles.push(builtin)
}
// now try to find any roles which need updated, e.g. removing the
// resource from another role and then adding to the new role
for (let role of dbRoles) {
let updated = false
const rolePermissions: Record<string, PermissionLevel[]> = role.permissions
? role.permissions
: {}
// make sure its an array, also handle migrating
if (
!rolePermissions[resourceId] ||
!Array.isArray(rolePermissions[resourceId])
) {
rolePermissions[resourceId] =
typeof rolePermissions[resourceId] === "string"
? [rolePermissions[resourceId] as unknown as PermissionLevel]
: []
}
// handle the removal/updating the role which has this permission first
// the updating (role._id !== dbRoleId) is required because a resource/level can
// only be permitted in a single role (this reduces hierarchy confusion and simplifies
// the general UI for this, rather than needing to show everywhere it is used)
if (
(role._id !== dbRoleId || remove) &&
rolePermissions[resourceId].indexOf(level) !== -1
) {
removeFromArray(rolePermissions[resourceId], level)
updated = true
}
// handle the adding, we're on the correct role, at it to this
if (!remove && role._id === dbRoleId) {
const set = new Set(rolePermissions[resourceId])
rolePermissions[resourceId] = [...set.add(level)]
updated = true
}
// handle the update, add it to bulk docs to perform at end
if (updated) {
role.permissions = rolePermissions
docUpdates.push(role)
}
}
const response = await db.bulkDocs(docUpdates)
return response.map(resp => {
const version = docUpdates.find(role => role._id === resp.id)?.version
const _id = roles.getExternalRoleID(resp.id, version)
return {
_id,
rev: resp.rev,
error: resp.error,
reason: resp.reason,
}
})
}
// utility function to stop this repetition - permissions always stored under roles
export async function getAllDBRoles(db: Database) {
const body = await db.allDocs<Role>(
getRoleParams(null, {
include_docs: true,
})
)
return body.rows.map(row => row.doc!)
}

View File

@ -1,5 +1,6 @@
import { import {
FieldType, FieldType,
PermissionLevel,
RelationSchemaField, RelationSchemaField,
RenameColumn, RenameColumn,
Table, Table,
@ -9,19 +10,18 @@ import {
ViewV2ColumnEnriched, ViewV2ColumnEnriched,
ViewV2Enriched, ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { HTTPError } from "@budibase/backend-core" import { HTTPError, roles } from "@budibase/backend-core"
import { import {
helpers, helpers,
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
PROTECTED_INTERNAL_COLUMNS, PROTECTED_INTERNAL_COLUMNS,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { updatePermissionOnRole, PermissionUpdateType } from "../permissions"
function pickApi(tableId: any) { function pickApi(tableId: any) {
if (isExternalTableID(tableId)) { if (isExternalTableID(tableId)) {
@ -114,8 +114,30 @@ export async function create(
viewRequest: Omit<ViewV2, "id" | "version"> viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> { ): Promise<ViewV2> {
await guardViewSchema(tableId, viewRequest) await guardViewSchema(tableId, viewRequest)
const view = await pickApi(tableId).create(tableId, viewRequest)
return pickApi(tableId).create(tableId, viewRequest) // Set permissions to be the same as the table
const tablePerms = await sdk.permissions.getResourcePerms(tableId)
const readRole = tablePerms[PermissionLevel.READ]?.role
const writeRole = tablePerms[PermissionLevel.WRITE]?.role
await updatePermissionOnRole(
{
roleId: readRole || roles.BUILTIN_ROLE_IDS.BASIC,
resourceId: view.id,
level: PermissionLevel.READ,
},
PermissionUpdateType.ADD
)
await updatePermissionOnRole(
{
roleId: writeRole || roles.BUILTIN_ROLE_IDS.BASIC,
resourceId: view.id,
level: PermissionLevel.WRITE,
},
PermissionUpdateType.ADD
)
return view
} }
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> { export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {

View File

@ -516,10 +516,8 @@ class Orchestrator {
filter => { filter => {
Object.entries(filter).forEach(([_, value]) => { Object.entries(filter).forEach(([_, value]) => {
Object.entries(value).forEach(([field, _]) => { Object.entries(value).forEach(([field, _]) => {
const fromContext = processStringSync( const updatedField = field.replace("{{", "{{ literal ")
`{{ literal ${field} }}`, const fromContext = processStringSync(updatedField, this.context)
this.context
)
toFilter[field] = fromContext toFilter[field] = fromContext
}) })
}) })

View File

@ -264,6 +264,7 @@ export async function outputProcessing<T extends Row[] | Row>(
} else { } else {
safeRows = rows safeRows = rows
} }
// SQS returns the rows with full relationship contents
// attach any linked row information // attach any linked row information
let enriched = !opts.preserveLinks let enriched = !opts.preserveLinks
? await linkRows.attachFullLinkedDocs(table.schema, safeRows, { ? await linkRows.attachFullLinkedDocs(table.schema, safeRows, {
@ -271,11 +272,39 @@ export async function outputProcessing<T extends Row[] | Row>(
}) })
: safeRows : safeRows
// make sure squash is enabled if needed
if (!opts.squash && utils.hasCircularStructure(rows)) { if (!opts.squash && utils.hasCircularStructure(rows)) {
opts.squash = true opts.squash = true
} }
enriched = await coreOutputProcessing(table, enriched, opts)
if (opts.squash) {
enriched = await linkRows.squashLinks(table, enriched, {
fromViewId: opts?.fromViewId,
})
}
return (wasArray ? enriched : enriched[0]) as T
}
/**
* This function is similar to the outputProcessing function above, it makes sure that all the provided
* rows are ready for output, but does not have enrichment for squash capabilities which can cause performance issues.
* outputProcessing should be used when responding from the API, while this should be used when internally processing
* rows for any reason (like part of view operations).
*/
export async function coreOutputProcessing(
table: Table,
rows: Row[],
opts: {
preserveLinks?: boolean
skipBBReferences?: boolean
fromViewId?: string
} = {
preserveLinks: false,
skipBBReferences: false,
}
): Promise<Row[]> {
// process complex types: attachments, bb references... // process complex types: attachments, bb references...
for (const [property, column] of Object.entries(table.schema)) { for (const [property, column] of Object.entries(table.schema)) {
if ( if (
@ -283,7 +312,7 @@ export async function outputProcessing<T extends Row[] | Row>(
column.type === FieldType.ATTACHMENT_SINGLE || column.type === FieldType.ATTACHMENT_SINGLE ||
column.type === FieldType.SIGNATURE_SINGLE column.type === FieldType.SIGNATURE_SINGLE
) { ) {
for (const row of enriched) { for (const row of rows) {
if (row[property] == null) { if (row[property] == null) {
continue continue
} }
@ -308,7 +337,7 @@ export async function outputProcessing<T extends Row[] | Row>(
!opts.skipBBReferences && !opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE column.type == FieldType.BB_REFERENCE
) { ) {
for (const row of enriched) { for (const row of rows) {
row[property] = await processOutputBBReferences( row[property] = await processOutputBBReferences(
row[property], row[property],
column.subtype column.subtype
@ -318,14 +347,14 @@ export async function outputProcessing<T extends Row[] | Row>(
!opts.skipBBReferences && !opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE_SINGLE column.type == FieldType.BB_REFERENCE_SINGLE
) { ) {
for (const row of enriched) { for (const row of rows) {
row[property] = await processOutputBBReference( row[property] = await processOutputBBReference(
row[property], row[property],
column.subtype column.subtype
) )
} }
} else if (column.type === FieldType.DATETIME && column.timeOnly) { } else if (column.type === FieldType.DATETIME && column.timeOnly) {
for (const row of enriched) { for (const row of rows) {
if (row[property] instanceof Date) { if (row[property] instanceof Date) {
const hours = row[property].getUTCHours().toString().padStart(2, "0") const hours = row[property].getUTCHours().toString().padStart(2, "0")
const minutes = row[property] const minutes = row[property]
@ -340,7 +369,7 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
} }
} else if (column.type === FieldType.LINK) { } else if (column.type === FieldType.LINK) {
for (let row of enriched) { for (let row of rows) {
// if relationship is empty - remove the array, this has been part of the API for some time // if relationship is empty - remove the array, this has been part of the API for some time
if (Array.isArray(row[property]) && row[property].length === 0) { if (Array.isArray(row[property]) && row[property].length === 0) {
delete row[property] delete row[property]
@ -350,17 +379,12 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
// process formulas after the complex types had been processed // process formulas after the complex types had been processed
enriched = await processFormulas(table, enriched, { dynamic: true }) rows = await processFormulas(table, rows, { dynamic: true })
if (opts.squash) {
enriched = await linkRows.squashLinks(table, enriched, {
fromViewId: opts?.fromViewId,
})
}
// remove null properties to match internal API // remove null properties to match internal API
const isExternal = isExternalTableID(table._id!) const isExternal = isExternalTableID(table._id!)
if (isExternal || (await features.flags.isEnabled("SQS"))) { if (isExternal || (await features.flags.isEnabled("SQS"))) {
for (const row of enriched) { for (const row of rows) {
for (const key of Object.keys(row)) { for (const key of Object.keys(row)) {
if (row[key] === null) { if (row[key] === null) {
delete row[key] delete row[key]
@ -388,7 +412,7 @@ export async function outputProcessing<T extends Row[] | Row>(
const fields = [...tableFields, ...protectedColumns].map(f => const fields = [...tableFields, ...protectedColumns].map(f =>
f.toLowerCase() f.toLowerCase()
) )
for (const row of enriched) { for (const row of rows) {
for (const key of Object.keys(row)) { for (const key of Object.keys(row)) {
if (!fields.includes(key.toLowerCase())) { if (!fields.includes(key.toLowerCase())) {
delete row[key] delete row[key]
@ -397,5 +421,5 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
} }
return (wasArray ? enriched : enriched[0]) as T return rows
} }

View File

@ -2066,7 +2066,7 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.32.5": "@budibase/backend-core@2.32.8":
version "0.0.0" version "0.0.0"
dependencies: dependencies:
"@budibase/nano" "10.1.5" "@budibase/nano" "10.1.5"
@ -2147,14 +2147,15 @@
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest": "@budibase/pro@npm:@budibase/pro@latest":
version "2.32.5" version "2.32.8"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.32.5.tgz#2beecf566da972a92200faddc97bc152ea2bbdea" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.32.8.tgz#1b42da2ca7a496ba1ea8687cde6e7f3a8da47e48"
integrity sha512-afrklI2A8P7pfl/3KxysqO2Sjr0l2yQ1+jyuouEZliEklLxV8AFlzrODr4V2SK3J8E1xk8wG5ztYQS2uT7TnuA== integrity sha512-NlY8DkD54FVcy6sL4T+wBJr/KQjXv6CGlqcHyjWVRBd8k/xLTzivrC5gVryDrkja/5ZxIm0qBKVlE81H6urLNA==
dependencies: dependencies:
"@budibase/backend-core" "2.32.5" "@anthropic-ai/sdk" "^0.27.3"
"@budibase/shared-core" "2.32.5" "@budibase/backend-core" "2.32.8"
"@budibase/string-templates" "2.32.5" "@budibase/shared-core" "2.32.8"
"@budibase/types" "2.32.5" "@budibase/string-templates" "2.32.8"
"@budibase/types" "2.32.8"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
dd-trace "5.2.0" dd-trace "5.2.0"
@ -2163,16 +2164,17 @@
lru-cache "^7.14.1" lru-cache "^7.14.1"
memorystream "^0.3.1" memorystream "^0.3.1"
node-fetch "2.6.7" node-fetch "2.6.7"
openai "4.59.0"
scim-patch "^0.8.1" scim-patch "^0.8.1"
scim2-parse-filter "^0.2.8" scim2-parse-filter "^0.2.8"
"@budibase/shared-core@2.32.5": "@budibase/shared-core@2.32.8":
version "0.0.0" version "0.0.0"
dependencies: dependencies:
"@budibase/types" "0.0.0" "@budibase/types" "0.0.0"
cron-validate "1.4.5" cron-validate "1.4.5"
"@budibase/string-templates@2.32.5": "@budibase/string-templates@2.32.8":
version "0.0.0" version "0.0.0"
dependencies: dependencies:
"@budibase/handlebars-helpers" "^0.13.2" "@budibase/handlebars-helpers" "^0.13.2"
@ -2180,7 +2182,7 @@
handlebars "^4.7.8" handlebars "^4.7.8"
lodash.clonedeep "^4.5.0" lodash.clonedeep "^4.5.0"
"@budibase/types@2.32.5": "@budibase/types@2.32.8":
version "0.0.0" version "0.0.0"
dependencies: dependencies:
scim-patch "^0.8.1" scim-patch "^0.8.1"