Merge branch 'v3-ui' of github.com:Budibase/budibase into new-rbac-ui
This commit is contained in:
commit
adfe467329
|
@ -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
|
|
@ -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()),
|
||||||
})
|
})
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" },
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 => ({
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 }
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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" } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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[] {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!)
|
||||||
|
}
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue