Merge branch 'master' of github.com:Budibase/budibase into remove-tour

This commit is contained in:
Andrew Kingston 2025-05-07 15:15:15 +01:00
commit ce2c06684f
No known key found for this signature in database
78 changed files with 2563 additions and 581 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.9.5", "version": "3.10.1",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -13,7 +13,7 @@
export let readonly = false export let readonly = false
export let error = null export let error = null
export let enableTime = true export let enableTime = true
export let value = null export let value = undefined
export let placeholder = null export let placeholder = null
export let timeOnly = false export let timeOnly = false
export let ignoreTimezones = false export let ignoreTimezones = false

View File

@ -7,6 +7,8 @@
export let type = "number" export let type = "number"
$: style = width ? `width:${width}px;` : "" $: style = width ? `width:${width}px;` : ""
const selectAll = event => event.target.select()
</script> </script>
<input <input
@ -16,7 +18,7 @@
{value} {value}
{min} {min}
{max} {max}
onclick="this.select()" on:click={selectAll}
on:change on:change
on:input on:input
/> />

View File

@ -1,24 +1,87 @@
<script> <script lang="ts">
import dayjs, { type Dayjs } from "dayjs"
import { createEventDispatcher } from "svelte"
import CoreDatePicker from "./DatePicker/DatePicker.svelte" import CoreDatePicker from "./DatePicker/DatePicker.svelte"
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
import { parseDate } from "../../helpers"
import { writable } from "svelte/store"
let fromDate export let enableTime: boolean | undefined = false
let toDate export let timeOnly: boolean | undefined = false
export let ignoreTimezones: boolean | undefined = false
export let value: string[] | undefined = []
const dispatch = createEventDispatcher()
const valueStore = writable<string[]>()
let fromDate: Dayjs | null
let toDate: Dayjs | null
$: valueStore.set(value || [])
$: parseValue($valueStore)
$: parsedFrom = fromDate ? parseDate(fromDate, { enableTime }) : undefined
$: parsedTo = toDate ? parseDate(toDate, { enableTime }) : undefined
const parseValue = (value: string[]) => {
if (!Array.isArray(value) || !value[0] || !value[1]) {
fromDate = null
toDate = null
} else {
fromDate = dayjs(value[0])
toDate = dayjs(value[1])
}
}
const onChangeFrom = (utc: string) => {
// Preserve the time if its editable
const fromDate = utc
? enableTime
? dayjs(utc)
: dayjs(utc).startOf("day")
: null
if (fromDate && (!toDate || fromDate.isAfter(toDate))) {
toDate = !enableTime ? fromDate.endOf("day") : fromDate
} else if (!fromDate) {
toDate = null
}
dispatch("change", [fromDate, toDate])
}
const onChangeTo = (utc: string) => {
// Preserve the time if its editable
const toDate = utc
? enableTime
? dayjs(utc)
: dayjs(utc).startOf("day")
: null
if (toDate && (!fromDate || toDate.isBefore(fromDate))) {
fromDate = !enableTime ? toDate.startOf("day") : toDate
} else if (!toDate) {
fromDate = null
}
dispatch("change", [fromDate, toDate])
}
</script> </script>
<div class="date-range"> <div class="date-range">
<CoreDatePicker <CoreDatePicker
value={fromDate} value={parsedFrom}
on:change={e => (fromDate = e.detail)} on:change={e => onChangeFrom(e.detail)}
enableTime={false} {enableTime}
{timeOnly}
{ignoreTimezones}
/> />
<div class="arrow"> <div class="arrow">
<Icon name="ChevronRight" /> <Icon name="ChevronRight" />
</div> </div>
<CoreDatePicker <CoreDatePicker
value={toDate} value={parsedTo}
on:change={e => (toDate = e.detail)} on:change={e => onChangeTo(e.detail)}
enableTime={false} {enableTime}
{timeOnly}
{ignoreTimezones}
/> />
</div> </div>

View File

@ -3,7 +3,7 @@
import DateRangePicker from "./Core/DateRangePicker.svelte" import DateRangePicker from "./Core/DateRangePicker.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = null export let value = undefined
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let disabled = false export let disabled = false
@ -12,6 +12,8 @@
export let helpText = null export let helpText = null
export let appendTo = undefined export let appendTo = undefined
export let ignoreTimezones = false export let ignoreTimezones = false
export let enableTime = false
export let timeOnly = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -29,6 +31,8 @@
{value} {value}
{appendTo} {appendTo}
{ignoreTimezones} {ignoreTimezones}
{enableTime}
{timeOnly}
on:change={onChange} on:change={onChange}
/> />
</Field> </Field>

View File

@ -11,7 +11,7 @@
export let hoverColor: string | undefined = undefined export let hoverColor: string | undefined = undefined
export let tooltip: string | undefined = undefined export let tooltip: string | undefined = undefined
export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom
export let tooltipType = TooltipType.Default export let tooltipType: TooltipType = TooltipType.Default
export let tooltipColor: string | undefined = undefined export let tooltipColor: string | undefined = undefined
export let tooltipWrap: boolean = true export let tooltipWrap: boolean = true
export let newStyles: boolean = false export let newStyles: boolean = false

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
export let horizontal: boolean = false export let horizontal: boolean = false
export let paddingX: "S" | "M" | "L" | "XL" | "XXL" = "M" export let paddingX: "S" | "M" | "L" | "XL" | "XXL" = "M"
export let paddingY: "S" | "M" | "L" | "XL" | "XXL" = "M" export let paddingY: "none" | "S" | "M" | "L" | "XL" | "XXL" = "M"
export let noPadding: boolean = false export let noPadding: boolean = false
export let gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M" export let gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M"
export let noGap: boolean = false export let noGap: boolean = false

View File

@ -1,9 +1,9 @@
import { ActionMenu } from "./types" import { ActionMenu, ModalContext, ScrollContext } from "./types"
import { ModalContext } from "./types"
declare module "svelte" { declare module "svelte" {
export function getContext(key: "actionMenu"): ActionMenu | undefined export function getContext(key: "actionMenu"): ActionMenu | undefined
export function getContext(key: "bbui-modal"): ModalContext export function getContext(key: "bbui-modal"): ModalContext
export function getContext(key: "scroll"): ScrollContext
} }
export const Modal = "bbui-modal" export const Modal = "bbui-modal"

View File

@ -1,3 +1,4 @@
export * from "./actionMenu" export * from "./actionMenu"
export * from "./envDropdown" export * from "./envDropdown"
export * from "./modalContext" export * from "./modalContext"
export * from "./scrollContext"

View File

@ -0,0 +1,3 @@
export interface ScrollContext {
scrollTo: (bounds: DOMRect) => void
}

View File

@ -10,8 +10,8 @@
export let bindings: EnrichedBinding[] = [] export let bindings: EnrichedBinding[] = []
export let value: string | null = "" export let value: string | null = ""
export let expandedOnly: boolean = false export let expandedOnly: boolean = false
export let parentWidth: number | null = null export let parentWidth: number | null = null
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
update: { code: string } update: { code: string }
accept: void accept: void
@ -26,11 +26,11 @@
const thresholdExpansionWidth = 350 const thresholdExpansionWidth = 350
$: expanded = $: shouldAlwaysBeExpanded =
expandedOnly || expandedOnly ||
(parentWidth !== null && parentWidth > thresholdExpansionWidth) (parentWidth !== null && parentWidth > thresholdExpansionWidth)
? true
: expanded $: expanded = shouldAlwaysBeExpanded || expanded
async function generateJs(prompt: string) { async function generateJs(prompt: string) {
promptText = "" promptText = ""
@ -78,15 +78,17 @@
prompt: promptText, prompt: promptText,
}) })
dispatch("reject", { code: previousContents }) dispatch("reject", { code: previousContents })
reset() reset(false)
} }
function reset() { function reset(clearPrompt: boolean = true) {
if (clearPrompt) {
promptText = ""
inputValue = ""
}
suggestedCode = null suggestedCode = null
previousContents = null previousContents = null
promptText = ""
expanded = false expanded = false
inputValue = ""
} }
</script> </script>
@ -108,7 +110,7 @@
bind:expanded bind:expanded
bind:value={inputValue} bind:value={inputValue}
readonly={!!suggestedCode} readonly={!!suggestedCode}
{expandedOnly} expandedOnly={shouldAlwaysBeExpanded}
/> />
</div> </div>

View File

@ -1,15 +1,13 @@
<script> <script lang="ts">
import { Icon, Modal } from "@budibase/bbui"
import ChooseIconModal from "@/components/start/ChooseIconModal.svelte" import ChooseIconModal from "@/components/start/ChooseIconModal.svelte"
import { Icon, Modal } from "@budibase/bbui"
export let name export let name: string
export let size = "M" export let size: "M" = "M"
export let app export let color: string
export let color export let disabled: boolean = false
export let autoSave = false
export let disabled = false
let modal let modal: Modal
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@ -28,7 +26,7 @@
</div> </div>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ChooseIconModal {name} {color} {app} {autoSave} on:change /> <ChooseIconModal {name} {color} on:change />
</Modal> </Modal>
<style> <style>

View File

@ -1,15 +1,15 @@
<script> <script lang="ts">
import { tick } from "svelte" import { tick } from "svelte"
import { Icon, Body } from "@budibase/bbui" import { Icon, Body } from "@budibase/bbui"
import { keyUtils } from "@/helpers/keyUtils" import { keyUtils } from "@/helpers/keyUtils"
export let title export let title: string
export let placeholder export let placeholder: string
export let value export let value: string
export let onAdd export let onAdd: () => void
export let search export let search: boolean
let searchInput let searchInput: HTMLInputElement
const openSearch = async () => { const openSearch = async () => {
search = true search = true
@ -22,7 +22,7 @@
value = "" value = ""
} }
const onKeyDown = e => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
closeSearch() closeSearch()
} }

View File

@ -1,46 +1,46 @@
<script> <script lang="ts">
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui" import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import type { UIUser } from "@budibase/types"
export let icon export let icon: string | null
export let iconTooltip export let iconTooltip: string = ""
export let withArrow = false export let withArrow: boolean = false
export let withActions = true export let withActions: boolean = true
export let showActions = false export let showActions: boolean = false
export let indentLevel = 0 export let indentLevel: number = 0
export let text export let text: string
export let border = true export let border: boolean = true
export let selected = false export let selected: boolean = false
export let opened = false export let opened: boolean = false
export let draggable = false export let draggable: boolean = false
export let iconText export let iconText: string = ""
export let iconColor export let iconColor: string = ""
export let scrollable = false export let scrollable: boolean = false
export let highlighted = false export let highlighted: boolean = false
export let rightAlignIcon = false export let rightAlignIcon: boolean = false
export let id export let id: string = ""
export let showTooltip = false export let showTooltip: boolean = false
export let selectedBy = null export let selectedBy: UIUser[] | null = null
export let compact = false export let compact: boolean = false
export let hovering = false export let hovering: boolean = false
export let disabled = false export let disabled: boolean = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let contentRef let contentRef: HTMLDivElement
$: selected && contentRef && scrollToView() $: selected && contentRef && scrollToView()
$: style = getStyle(indentLevel, selectedBy) $: style = getStyle(indentLevel)
const onClick = () => { const onClick = () => {
scrollToView() scrollToView()
dispatch("click") dispatch("click")
} }
const onIconClick = e => { const onIconClick = (e: Event) => {
e.stopPropagation() e.stopPropagation()
dispatch("iconClick") dispatch("iconClick")
} }
@ -53,11 +53,8 @@
scrollApi.scrollTo(bounds) scrollApi.scrollTo(bounds)
} }
const getStyle = (indentLevel, selectedBy) => { const getStyle = (indentLevel: number) => {
let style = `padding-left:calc(${indentLevel * 14}px);` let style = `padding-left:calc(${indentLevel * 14}px);`
if (selectedBy) {
style += `--selected-by-color:${helpers.getUserColor(selectedBy)};`
}
return style return style
} }
</script> </script>

View File

@ -1,102 +0,0 @@
<script>
import { notifications, Button, Icon, Body } from "@budibase/bbui"
import { admin, auth } from "@/stores/portal"
$: user = $auth.user
let loading = false
let complete = false
const resetPassword = async () => {
if (loading || complete) return
loading = true
try {
await fetch(`${$admin.accountPortalUrl}/api/auth/reset`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: user.email }),
})
complete = true
} catch (e) {
notifications.error("There was an issue sending your validation email.")
} finally {
loading = false
}
}
</script>
{#if user?.account?.verified === false}
<section class="banner">
<div class="icon">
<Icon name="Info" />
</div>
<div class="copy">
<Body size="S">
Please verify your account. We've sent the verification link to <span
class="email">{user.email}</span
></Body
>
</div>
<div class="button" class:disabled={loading || complete}>
<Button on:click={resetPassword}
>{complete ? "Email sent" : "Resend email"}</Button
>
</div>
</section>
{/if}
<style>
.banner {
flex-shrink: 0;
display: flex;
background-color: var(--grey-2);
height: 48px;
align-items: center;
padding: 0 15px;
}
.icon {
margin-right: 15px;
color: var(--ink);
display: flex;
}
.copy {
flex-grow: 1;
overflow: hidden;
}
.copy :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
font-weight: 600;
}
.button {
margin-left: 15px;
}
.button :global(button) {
color: var(--ink);
background-color: var(--grey-3);
border: none;
}
.button :global(button):hover {
background-color: var(--grey-4);
}
.disabled :global(button) {
pointer-events: none;
color: var(--ink);
background-color: var(--grey-4);
border: none;
}
</style>

View File

@ -13,7 +13,6 @@
export let value: string = "" export let value: string = ""
export const submit = onPromptSubmit export const submit = onPromptSubmit
$: expanded = expandedOnly || expanded
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let promptInput: HTMLInputElement let promptInput: HTMLInputElement
@ -22,6 +21,7 @@
let switchOnAIModal: Modal let switchOnAIModal: Modal
let addCreditsModal: Modal let addCreditsModal: Modal
$: expanded = expandedOnly || expanded
$: accountPortalAccess = $auth?.user?.accountPortalAccess $: accountPortalAccess = $auth?.user?.accountPortalAccess
$: accountPortal = $admin.accountPortalUrl $: accountPortal = $admin.accountPortalUrl
$: aiEnabled = $auth?.user?.llm $: aiEnabled = $auth?.user?.llm
@ -92,9 +92,12 @@
class="ai-icon" class="ai-icon"
class:loading={promptLoading} class:loading={promptLoading}
class:disabled={expanded && disabled} class:disabled={expanded && disabled}
class:no-toggle={expandedOnly}
on:click={e => { on:click={e => {
e.stopPropagation() if (!expandedOnly) {
toggleExpand() e.stopPropagation()
toggleExpand()
}
}} }}
/> />
{#if expanded} {#if expanded}
@ -290,6 +293,10 @@
z-index: 2; z-index: 2;
} }
.ai-icon.no-toggle {
cursor: default;
}
.ai-gen-text { .ai-gen-text {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@ -1,25 +1,25 @@
<script> <script lang="ts">
import { ModalContent, Input } from "@budibase/bbui" import { ModalContent, Input } from "@budibase/bbui"
import sanitizeUrl from "@/helpers/sanitizeUrl" import sanitizeUrl from "@/helpers/sanitizeUrl"
import { get } from "svelte/store" import { get } from "svelte/store"
import { screenStore } from "@/stores/builder" import { screenStore } from "@/stores/builder"
export let onConfirm export let onConfirm: (_data: { route: string }) => Promise<void>
export let onCancel export let onCancel: (() => Promise<void>) | undefined = undefined
export let route export let route: string
export let role export let role: string | undefined
export let confirmText = "Continue" export let confirmText = "Continue"
const appPrefix = "/app" const appPrefix = "/app"
let touched = false let touched = false
let error let error: string | undefined
let modal let modal: ModalContent
$: appUrl = route $: appUrl = route
? `${window.location.origin}${appPrefix}${route}` ? `${window.location.origin}${appPrefix}${route}`
: `${window.location.origin}${appPrefix}` : `${window.location.origin}${appPrefix}`
const routeChanged = event => { const routeChanged = (event: { detail: string }) => {
if (!event.detail.startsWith("/")) { if (!event.detail.startsWith("/")) {
route = "/" + event.detail route = "/" + event.detail
} }
@ -28,11 +28,11 @@
if (routeExists(route)) { if (routeExists(route)) {
error = "This URL is already taken for this access role" error = "This URL is already taken for this access role"
} else { } else {
error = null error = undefined
} }
} }
const routeExists = url => { const routeExists = (url: string) => {
if (!role) { if (!role) {
return false return false
} }
@ -58,7 +58,7 @@
onConfirm={confirmScreenDetails} onConfirm={confirmScreenDetails}
{onCancel} {onCancel}
cancelText={"Back"} cancelText={"Back"}
disabled={!route || error || !touched} disabled={!route || !!error || !touched}
> >
<form on:submit|preventDefault={() => modal.confirm()}> <form on:submit|preventDefault={() => modal.confirm()}>
<Input <Input

View File

@ -26,6 +26,7 @@ import TopLevelColumnEditor from "./controls/ColumnEditor/TopLevelColumnEditor.s
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte" import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
import BarButtonList from "./controls/BarButtonList.svelte" import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import FilterConfiguration from "./controls/FilterConfiguration/FilterConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte" import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte" import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
import FormStepConfiguration from "./controls/FormStepConfiguration.svelte" import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
@ -33,6 +34,7 @@ import FormStepControls from "./controls/FormStepControls.svelte"
import PaywalledSetting from "./controls/PaywalledSetting.svelte" import PaywalledSetting from "./controls/PaywalledSetting.svelte"
import TableConditionEditor from "./controls/TableConditionEditor.svelte" import TableConditionEditor from "./controls/TableConditionEditor.svelte"
import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte" import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte"
import FilterableSelect from "./controls/FilterableSelect.svelte"
const componentMap = { const componentMap = {
text: DrawerBindableInput, text: DrawerBindableInput,
@ -42,6 +44,7 @@ const componentMap = {
radio: RadioGroup, radio: RadioGroup,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect, "dataSource/s3": S3DataSourceSelect,
"dataSource/filterable": FilterableSelect,
dataProvider: DataProviderSelect, dataProvider: DataProviderSelect,
boolean: Checkbox, boolean: Checkbox,
number: Stepper, number: Stepper,
@ -59,6 +62,7 @@ const componentMap = {
"filter/relationship": RelationshipFilterEditor, "filter/relationship": RelationshipFilterEditor,
url: URLSelect, url: URLSelect,
fieldConfiguration: FieldConfiguration, fieldConfiguration: FieldConfiguration,
filterConfiguration: FilterConfiguration,
buttonConfiguration: ButtonConfiguration, buttonConfiguration: ButtonConfiguration,
stepConfiguration: FormStepConfiguration, stepConfiguration: FormStepConfiguration,
formStepControls: FormStepControls, formStepControls: FormStepControls,

View File

@ -11,7 +11,10 @@
$componentStore.selectedComponentId, $componentStore.selectedComponentId,
"RefreshDatasource", "RefreshDatasource",
{ includeSelf: nested } { includeSelf: nested }
) ).concat({
readableBinding: "All data providers",
runtimeBinding: "all",
})
</script> </script>
<div class="root"> <div class="root">

View File

@ -11,7 +11,7 @@
export let listTypeProps = {} export let listTypeProps = {}
export let listItemKey export let listItemKey
export let draggable = true export let draggable = true
export let focus export let focus = undefined
let zoneType = generate() let zoneType = generate()

View File

@ -0,0 +1,127 @@
<script>
import { Icon, Popover, Layout } from "@budibase/bbui"
import { componentStore, selectedScreen } from "@/stores/builder"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import { getComponentBindableProperties } from "@/dataBinding"
import ComponentSettingsSection from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor
export let componentInstance
export let bindings
export let parseSettings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let isOpen = false
// Auto hide the component when another item is selected
$: if (
open &&
$draggable.selected &&
$draggable.selected !== componentInstance._instanceName
) {
close()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) {
open()
}
$: componentDef = componentStore.getDefinition(componentInstance._component)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
$: componentBindings = getComponentBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
const open = () => {
isOpen = true
drawers = []
$draggable.actions.select(componentInstance._id)
}
const close = () => {
// Slight delay allows us to be able to properly toggle open/close state by
// clicking again on the settings icon
setTimeout(() => {
isOpen = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}, 10)
}
const toggleOpen = () => {
if (isOpen) {
close()
} else {
open()
}
}
const processComponentDefinitionSettings = componentDef => {
if (!componentDef) {
return {}
}
const clone = cloneDeep(componentDef)
if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings)
}
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = componentStore.updateComponentSetting(setting.key, value)
patchFn(nestedComponentInstance)
dispatch("change", nestedComponentInstance)
}
</script>
<Icon name="Settings" hoverable size="S" on:click={toggleOpen} />
<Popover
open={isOpen}
on:close={close}
{anchor}
align="left-outside"
showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
minWidth={360}
maxWidth={360}
offset={18}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<slot name="header" />
<ComponentSettingsSection
includeHidden
{componentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
showSectionTitle={false}
showInstanceName={false}
{bindings}
{componentBindings}
on:drawerShow={e => {
drawers = [...drawers, e.detail]
}}
on:drawerHide={() => {
drawers = drawers.slice(0, -1)
}}
/>
</Layout>
</span>
</Popover>
<style>
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
</style>

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { enrichSchemaWithRelColumns, search } from "@budibase/frontend-core"
import { Toggle } from "@budibase/bbui"
import {
getSchemaForDatasource,
extractLiteralHandlebarsID,
getDatasourceForProvider,
} from "@/dataBinding"
import { selectedScreen } from "@/stores/builder"
import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import FilterSetting from "./FilterSetting.svelte"
import { removeInvalidAddMissing } from "../GridColumnConfiguration/getColumns.js"
import {
FieldType,
type UIFieldSchema,
type Component,
type FilterConfig,
type Screen,
type TableSchema,
type Table,
} from "@budibase/types"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { findComponent } from "@/helpers/components"
import { tables } from "@/stores/builder"
export let value
export let componentInstance
export let bindings
const dispatch = createEventDispatcher()
let selectedAll = false
// Load the component for processing
$: targetId = extractLiteralHandlebarsID(componentInstance.targetComponent)
$: targetComponent =
$selectedScreen && targetId
? findComponent($selectedScreen?.props, targetId)
: null
$: contextDS = getDatasourceForProvider($selectedScreen, targetComponent)
$: schema = $selectedScreen
? getSchema($selectedScreen, contextDS)
: undefined
$: searchable = getSearchableFields(schema, $tables.list)
$: defaultValues = searchable
.filter((column: UIFieldSchema) => !column.nestedJSON)
.map(
(column: UIFieldSchema): FilterConfig => ({
field: column.name,
active: !!value == false ? false : !!column.visible,
columnType: column.type,
})
)
$: parsedColumns = schema
? removeInvalidAddMissing(value || [], [...defaultValues]).map(column => ({
...column,
}))
: []
const itemUpdate = (e: CustomEvent) => {
// The item is a component instance. '_instanceName' === 'field'
const item: Component = e.detail
const { label } = item
const updated = parsedColumns.map(entry => {
if (item._instanceName === entry.field) {
return { ...entry, label }
}
return entry
})
dispatch("change", updated)
}
const listUpdate = (list: FilterConfig[]) => {
dispatch("change", list)
}
const getSearchableFields = (
schema: TableSchema | undefined,
tableList: Table[]
) => {
// Omit calculated fields
const filtered = Object.values(schema || {}).filter(
field => !("calculationType" in field)
)
return search.getFields(tableList, filtered, {
allowLinks: true,
})
}
const getSchema = (screen: Screen, datasource: any) => {
const schema = getSchemaForDatasource(screen, datasource, null)
.schema as Record<string, UIFieldSchema>
if (!schema) {
return
}
// Don't show ID and rev in tables
delete schema._id
delete schema._rev
const excludedTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.AI,
FieldType.SIGNATURE_SINGLE,
]
const filteredSchema = Object.entries(schema || {}).filter(
([_, field]: [string, UIFieldSchema]) => {
return !excludedTypes.includes(field.type)
}
)
const result = enrichSchemaWithRelColumns(
Object.fromEntries(filteredSchema)
)
return result
}
</script>
<div class="filter-configuration">
<div class="toggle-all">
<span>Fields</span>
<Toggle
on:change={() => {
selectedAll = !selectedAll
let update = parsedColumns.map(field => {
return {
...field,
active: selectedAll,
}
})
listUpdate(update)
}}
value={selectedAll}
text=""
/>
</div>
{#if parsedColumns?.length}
<DraggableList
on:change={e => {
listUpdate(e.detail)
}}
on:itemChange={itemUpdate}
items={parsedColumns || []}
listItemKey={"field"}
listType={FilterSetting}
listTypeProps={{
bindings,
}}
/>
{:else}
<InfoDisplay body={"No available columns"} />
{/if}
</div>
<style>
.filter-configuration {
padding-top: 8px;
}
.toggle-all {
display: flex;
justify-content: space-between;
}
.toggle-all :global(.spectrum-Switch) {
margin-right: 0px;
padding-right: calc(var(--spacing-s) - 1px);
min-height: unset;
}
.toggle-all :global(.spectrum-Switch .spectrum-Switch-switch) {
margin-top: 0px;
}
.toggle-all span {
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,135 @@
<script>
import EditFilterPopover from "./EditFilterPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { FIELDS } from "@/constants/backend"
import { Constants } from "@budibase/frontend-core"
import { FieldType } from "@budibase/types"
import { componentStore } from "@/stores/builder"
export let item
export let anchor
export let bindings
const dispatch = createEventDispatcher()
$: fieldIconLookupMap = buildFieldIconLookupMap(FIELDS)
const buildFieldIconLookupMap = fields => {
let map = {}
Object.values(fields).forEach(fieldInfo => {
map[fieldInfo.type] = fieldInfo.icon
})
return map
}
const onToggle = item => {
return e => {
item.active = e.detail
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const parseSettings = settings => {
let columnSettings = settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
// Filter out conditions for invalid types.
// Allow formulas as we have all the data already loaded in the table.
if (
Constants.BannedSearchTypes.includes(item.columnType) &&
item.columnType !== FieldType.FORMULA
) {
return columnSettings.filter(x => x.key !== "conditions")
}
return columnSettings
}
const itemToComponent = item => {
return componentStore.createInstance(
"@budibase/standard-components/filterconfig",
{
_id: item._id,
_instanceName: item.field,
label: item.label,
}
)
}
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFilterPopover
componentInstance={itemToComponent(item)}
{bindings}
{anchor}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={fieldIconLookupMap[item.columnType]} />
<span>{item.field}</span>
</div>
</EditFilterPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div class="list-item-right">
<Toggle
on:click={e => {
e.stopPropagation()
}}
on:change={onToggle(item)}
text=""
value={item.active}
thin
/>
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
.type-icon span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 0;
flex: 1 1 auto;
}
</style>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { Select } from "@budibase/bbui"
import { selectedScreen } from "@/stores/builder"
import { findAllComponents, getComponentContexts } from "@/helpers/components"
import { makePropSafe as safe } from "@budibase/string-templates"
import { type Component } from "@budibase/types"
export let value: string | undefined = undefined
$: providers = getProviders($selectedScreen?.props)
const getProviders = (rootComponent: Component | undefined) => {
if (!rootComponent) {
return []
}
return findAllComponents(rootComponent)
.filter(component => {
return getComponentContexts(component._component).some(ctx =>
ctx.actions?.find(
act =>
act.type === "AddDataProviderQueryExtension" ||
act.type === "AddDataProviderFilterExtension"
)
)
})
.map(provider => ({
label: provider._instanceName,
value: `{{ literal ${safe(provider._id)} }}`,
subtitle: `${
provider?.dataSource?.label || provider?.table?.label || "-"
}`,
}))
}
</script>
<Select {value} options={providers} on:change />

View File

@ -9,7 +9,7 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import FieldSetting from "./FieldSetting.svelte" import FieldSetting from "./FieldSetting.svelte"
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte" import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
import getColumns from "./getColumns.js" import { getColumns } from "./getColumns.js"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
export let value export let value

View File

@ -1,4 +1,4 @@
const modernize = columns => { export const modernize = columns => {
if (!columns) { if (!columns) {
return [] return []
} }
@ -14,9 +14,9 @@ const modernize = columns => {
return columns return columns
} }
const removeInvalidAddMissing = ( export const removeInvalidAddMissing = (
columns = [], columns = [],
defaultColumns, defaultColumns = [],
primaryDisplayColumnName primaryDisplayColumnName
) => { ) => {
const defaultColumnNames = defaultColumns.map(column => column.field) const defaultColumnNames = defaultColumns.map(column => column.field)
@ -47,7 +47,7 @@ const removeInvalidAddMissing = (
return combinedColumns return combinedColumns
} }
const getDefault = (schema = {}) => { export const getDefault = (schema = {}) => {
const defaultValues = Object.values(schema) const defaultValues = Object.values(schema)
.filter(column => !column.nestedJSON) .filter(column => !column.nestedJSON)
.map(column => ({ .map(column => ({
@ -93,7 +93,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
}) })
} }
const getColumns = ({ export const getColumns = ({
columns, columns,
schema, schema,
primaryDisplayColumnName, primaryDisplayColumnName,
@ -132,5 +132,3 @@ const getColumns = ({
}, },
} }
} }
export default getColumns

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest" import { it, expect, describe, beforeEach, vi } from "vitest"
import getColumns from "./getColumns" import { getColumns } from "./getColumns"
describe("getColumns", () => { describe("getColumns", () => {
beforeEach(ctx => { beforeEach(ctx => {

View File

@ -109,13 +109,12 @@
/> />
{/each} {/each}
</List> </List>
<div>
<Button secondary icon="Add" on:click={addOAuth2Configuration}
>Add OAuth2</Button
>
</div>
{/if} {/if}
<div>
<Button secondary icon="Add" on:click={addOAuth2Configuration}
>Add OAuth2</Button
>
</div>
</DetailPopover> </DetailPopover>
<style> <style>

View File

@ -1,18 +1,9 @@
<script> <script lang="ts">
import { import { ColorPicker, Icon, Label, ModalContent } from "@budibase/bbui"
ModalContent,
Icon,
ColorPicker,
Label,
notifications,
} from "@budibase/bbui"
import { appsStore } from "@/stores/portal"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let app export let name: string
export let name export let color: string
export let color
export let autoSave = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -44,17 +35,8 @@
] ]
const save = async () => { const save = async () => {
if (!autoSave) { dispatch("change", { color, name })
dispatch("change", { color, name }) return
return
}
try {
await appsStore.save(app.instance._id, {
icon: { name, color },
})
} catch (error) {
notifications.error("Error updating app")
}
} }
</script> </script>

View File

@ -1424,7 +1424,7 @@ const bindingReplacement = (
* Extracts a component ID from a handlebars expression setting of * Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }} * {{ literal [componentId] }}
*/ */
const extractLiteralHandlebarsID = value => { export const extractLiteralHandlebarsID = value => {
if (!value || typeof value !== "string") { if (!value || typeof value !== "string") {
return null return null
} }

View File

@ -22,7 +22,6 @@
import { isActive, url, goto, layout, redirect } from "@roxi/routify" import { isActive, url, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "@/helpers" import { capitalise } from "@/helpers"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import PreviewOverlay from "./_components/PreviewOverlay.svelte" import PreviewOverlay from "./_components/PreviewOverlay.svelte"
@ -95,7 +94,6 @@
{/if} {/if}
<div class="root" class:blur={$previewStore.showPreview}> <div class="root" class:blur={$previewStore.showPreview}>
<VerificationPromptBanner />
<div class="top-nav"> <div class="top-nav">
{#if $appStore.initialised} {#if $appStore.initialised}
<div class="topleftnav"> <div class="topleftnav">

View File

@ -1,8 +1,8 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
export let title export let title = undefined
export let body export let body = undefined
export let icon = "HelpOutline" export let icon = "HelpOutline"
export let quiet = false export let quiet = false
export let warning = false export let warning = false

View File

@ -27,6 +27,7 @@
"pdftable", "pdftable",
"spreadsheet", "spreadsheet",
"dynamicfilter", "dynamicfilter",
"filter",
"daterangepicker" "daterangepicker"
] ]
}, },

View File

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { Modal, Helpers, notifications, Icon } from "@budibase/bbui" import { Modal, Helpers, notifications, Icon } from "@budibase/bbui"
import { import {
navigationStore, navigationStore,
@ -14,13 +14,14 @@
import { makeComponentUnique } from "@/helpers/components" import { makeComponentUnique } from "@/helpers/components"
import { capitalise } from "@/helpers" import { capitalise } from "@/helpers"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte" import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import type { Screen } from "@budibase/types"
export let screen export let screen
let confirmDeleteDialog let confirmDeleteDialog: ConfirmDialog
let screenDetailsModal let screenDetailsModal: Modal
const createDuplicateScreen = async ({ route }) => { const createDuplicateScreen = async ({ route }: { route: string }) => {
// Create a dupe and ensure it is unique // Create a dupe and ensure it is unique
let duplicateScreen = Helpers.cloneDeep(screen) let duplicateScreen = Helpers.cloneDeep(screen)
delete duplicateScreen._id delete duplicateScreen._id
@ -57,7 +58,7 @@
$: noPaste = !$componentStore.componentToPaste $: noPaste = !$componentStore.componentToPaste
const pasteComponent = mode => { const pasteComponent = (mode: "inside") => {
try { try {
componentStore.paste(screen.props, mode, screen) componentStore.paste(screen.props, mode, screen)
} catch (error) { } catch (error) {
@ -65,7 +66,7 @@
} }
} }
const openContextMenu = (e, screen) => { const openContextMenu = (e: MouseEvent, screen: Screen) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -96,7 +97,7 @@
}, },
] ]
contextMenuStore.open(screen._id, items, { x: e.clientX, y: e.clientY }) contextMenuStore.open(screen._id!, items, { x: e.clientX, y: e.clientY })
} }
</script> </script>

View File

@ -1,16 +1,17 @@
<script> <script lang="ts">
import { Layout } from "@budibase/bbui" import { Layout } from "@budibase/bbui"
import { sortedScreens } from "@/stores/builder" import { sortedScreens } from "@/stores/builder"
import ScreenNavItem from "./ScreenNavItem.svelte" import ScreenNavItem from "./ScreenNavItem.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { getVerticalResizeActions } from "@/components/common/resizable" import { getVerticalResizeActions } from "@/components/common/resizable"
import NavHeader from "@/components/common/NavHeader.svelte" import NavHeader from "@/components/common/NavHeader.svelte"
import type { Screen } from "@budibase/types"
const [resizable, resizableHandle] = getVerticalResizeActions() const [resizable, resizableHandle] = getVerticalResizeActions()
let searching = false let searching = false
let searchValue = "" let searchValue = ""
let screensContainer let screensContainer: HTMLDivElement
let scrolling = false let scrolling = false
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue) $: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
@ -25,13 +26,13 @@
} }
} }
const getFilteredScreens = (screens, searchValue) => { const getFilteredScreens = (screens: Screen[], searchValue: string) => {
return screens.filter(screen => { return screens.filter(screen => {
return !searchValue || screen.routing.route.includes(searchValue) return !searchValue || screen.routing.route.includes(searchValue)
}) })
} }
const handleScroll = e => { const handleScroll = (e: any) => {
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
</script> </script>
@ -62,7 +63,6 @@
<div <div
role="separator" role="separator"
disabled={searching}
class="divider" class="divider"
class:disabled={searching} class:disabled={searching}
use:resizableHandle use:resizableHandle

View File

@ -15,7 +15,6 @@
import Logo from "./_components/Logo.svelte" import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte" import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "@/components/common/HelpMenu.svelte" import HelpMenu from "@/components/common/HelpMenu.svelte"
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import EnterpriseBasicTrialBanner from "@/components/portal/licensing/EnterpriseBasicTrialBanner.svelte" import EnterpriseBasicTrialBanner from "@/components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
@ -74,7 +73,6 @@
{:else} {:else}
<HelpMenu /> <HelpMenu />
<div class="container"> <div class="container">
<VerificationPromptBanner />
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} /> <EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
<div class="nav"> <div class="nav">
<div class="branding"> <div class="branding">

View File

@ -9,6 +9,7 @@
Link, Link,
TooltipWrapper, TooltipWrapper,
} from "@budibase/bbui" } from "@budibase/bbui"
import { Feature } from "@budibase/types"
import { onMount } from "svelte" import { onMount } from "svelte"
import { admin, auth, licensing } from "@/stores/portal" import { admin, auth, licensing } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
@ -33,6 +34,7 @@
const EXCLUDE_QUOTAS = { const EXCLUDE_QUOTAS = {
["Day Passes"]: () => true, ["Day Passes"]: () => true,
[Feature.AI_CUSTOM_CONFIGS]: () => true,
Queries: () => true, Queries: () => true,
Users: license => { Users: license => {
return license.plan.model !== PlanModel.PER_USER return license.plan.model !== PlanModel.PER_USER

View File

@ -1,11 +1,23 @@
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, fireEvent } from "@testing-library/svelte" import { render, waitFor } from "@testing-library/svelte"
import { admin, licensing, featureFlags } from "@/stores/portal" import { admin, licensing, featureFlags } from "@/stores/portal"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { API } from "@/api"
vi.spyOn(notifications, "error").mockImplementation(vi.fn) vi.spyOn(notifications, "error").mockImplementation(vi.fn)
vi.spyOn(notifications, "success").mockImplementation(vi.fn) vi.spyOn(notifications, "success").mockImplementation(vi.fn)
vi.mock("@/api", () => ({
API: {
getConfig: vi.fn().mockResolvedValue({
config: {},
}),
getLicenseKey: vi.fn().mockResolvedValue({
licenseKey: "abc-123",
}),
saveConfig: vi.fn(),
},
}))
const Hosting = { const Hosting = {
Cloud: "cloud", Cloud: "cloud",
@ -15,7 +27,6 @@ const Hosting = {
function setupEnv(hosting, features = {}, flags = {}) { function setupEnv(hosting, features = {}, flags = {}) {
const defaultFeatures = { const defaultFeatures = {
budibaseAIEnabled: false, budibaseAIEnabled: false,
customAIConfigsEnabled: false,
...features, ...features,
} }
const defaultFlags = { const defaultFlags = {
@ -41,33 +52,123 @@ describe("AISettings", () => {
let instance = null let instance = null
const setupDOM = () => { const setupDOM = () => {
instance = render(AISettings, {}) instance = render(AISettings)
const modalContainer = document.createElement("div") const modalContainer = document.createElement("div")
modalContainer.classList.add("modal-container") modalContainer.classList.add("modal-container")
instance.baseElement.appendChild(modalContainer) instance.baseElement.appendChild(modalContainer)
} }
beforeEach(() => {
setupEnv(Hosting.Self)
})
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it("that the AISettings is rendered", () => { describe("Basic rendering", () => {
setupDOM() it("should render the AI header", async () => {
expect(instance).toBeDefined() setupDOM()
await waitFor(() => {
const header = instance.getByText("AI")
expect(header).toBeInTheDocument()
})
})
it("should show 'No LLMs are enabled' when no providers are active", async () => {
API.getConfig.mockResolvedValueOnce({ config: {} })
setupDOM()
await waitFor(() => {
const noEnabledText = instance.getByText("No LLMs are enabled")
expect(noEnabledText).toBeInTheDocument()
})
})
it("should display the 'Enable BB AI' button", async () => {
API.getConfig.mockResolvedValueOnce({ config: {} })
setupDOM()
await waitFor(() => {
const enableButton = instance.getByText("Enable BB AI")
expect(enableButton).toBeInTheDocument()
})
})
}) })
describe("DOM Render tests", () => { describe("Provider rendering", () => {
it("the enable bb ai button should not do anything if the user doesn't have the correct license on self host", async () => { it("should display active provider with active status tag", async () => {
let addAiButton API.getConfig.mockResolvedValueOnce({
let configModal config: {
BudibaseAI: {
provider: "BudibaseAI",
active: true,
isDefault: true,
name: "Budibase AI",
},
},
})
setupEnv(Hosting.Self, { customAIConfigsEnabled: false })
setupDOM() setupDOM()
addAiButton = instance.queryByText("Enable BB AI")
expect(addAiButton).toBeInTheDocument() await waitFor(() => {
await fireEvent.click(addAiButton) const providerName = instance.getByText("Budibase AI")
configModal = instance.queryByText("Custom AI Configuration") expect(providerName).toBeInTheDocument()
expect(configModal).not.toBeInTheDocument()
const statusTags = instance.baseElement.querySelectorAll(".tag.active")
expect(statusTags.length).toBeGreaterThan(0)
let foundEnabledTag = false
statusTags.forEach(tag => {
if (tag.textContent === "Enabled") {
foundEnabledTag = true
}
})
expect(foundEnabledTag).toBe(true)
})
})
it("should display disabled provider with disabled status tag", async () => {
API.getConfig.mockResolvedValueOnce({
config: {
BudibaseAI: {
provider: "BudibaseAI",
active: true,
isDefault: true,
name: "Budibase AI",
},
OpenAI: {
provider: "OpenAI",
active: false,
isDefault: false,
name: "OpenAI",
},
},
})
setupDOM()
await waitFor(async () => {
const disabledProvider = instance.getByText("OpenAI")
expect(disabledProvider).toBeInTheDocument()
const disabledTags =
instance.baseElement.querySelectorAll(".tag.disabled")
expect(disabledTags.length).toBeGreaterThan(0)
let foundDisabledTag = false
disabledTags.forEach(tag => {
if (tag.textContent === "Disabled") {
foundDisabledTag = true
}
})
expect(foundDisabledTag).toBe(true)
const openAIOption = disabledProvider.closest(".option")
expect(openAIOption).not.toBeNull()
const disabledTagNearOpenAI =
openAIOption.querySelector(".tag.disabled")
expect(disabledTagNearOpenAI).not.toBeNull()
expect(disabledTagNearOpenAI.textContent).toBe("Disabled")
})
}) })
}) })
}) })

View File

@ -159,74 +159,76 @@
}) })
</script> </script>
<Layout noPadding> {#if aiConfig}
<Layout gap="XS" noPadding> <Layout noPadding>
<div class="header"> <Layout gap="XS" noPadding>
<Heading size="M">AI</Heading> <div class="header">
</div> <Heading size="M">AI</Heading>
<Body> </div>
Connect an LLM to enable AI features. You can only enable one LLM at a <Body>
time. Connect an LLM to enable AI features. You can only enable one LLM at a
</Body> time.
</Layout> </Body>
<Divider /> </Layout>
<Divider />
{#if !activeProvider && !$bannerStore} {#if !activeProvider && !$bannerStore}
<div class="banner"> <div class="banner">
<div class="banner-content"> <div class="banner-content">
<div class="banner-icon"> <div class="banner-icon">
<img src={BBAI} alt="BB AI" width="24" height="24" /> <img src={BBAI} alt="BB AI" width="24" height="24" />
</div>
<div>Try BB AI for free. 50,000 tokens included. No CC required.</div>
</div> </div>
<div>Try BB AI for free. 50,000 tokens included. No CC required.</div> <div class="banner-buttons">
</div> <Button
<div class="banner-buttons"> primary
<Button cta
primary size="S"
cta on:click={() => handleEnable("BudibaseAI")}
size="S" >
on:click={() => handleEnable("BudibaseAI")} Enable BB AI
> </Button>
Enable BB AI <Icon
</Button> hoverable
<Icon name="Close"
hoverable on:click={() => {
name="Close" setBannerLocalStorageKey()
on:click={() => { bannerStore.set(true)
setBannerLocalStorageKey() }}
bannerStore.set(true)
}}
/>
</div>
</div>
{/if}
<div class="section">
<div class="section-title">Enabled</div>
{#if activeProvider}
<AIConfigTile
config={getProviderConfig(activeProvider).config}
editHandler={() => handleEnable(activeProvider)}
disableHandler={() => disableProvider(activeProvider)}
/>
{:else}
<div class="no-enabled">
<Body size="S">No LLMs are enabled</Body>
</div>
{/if}
{#if disabledProviders.length > 0}
<div class="section-title disabled-title">Disabled</div>
<div class="ai-list">
{#each disabledProviders as { provider, config } (provider)}
<AIConfigTile
{config}
editHandler={() => handleEnable(provider)}
disableHandler={() => disableProvider(provider)}
/> />
{/each} </div>
</div> </div>
{/if} {/if}
</div>
</Layout> <div class="section">
<div class="section-title">Enabled</div>
{#if activeProvider}
<AIConfigTile
config={getProviderConfig(activeProvider).config}
editHandler={() => handleEnable(activeProvider)}
disableHandler={() => disableProvider(activeProvider)}
/>
{:else}
<div class="no-enabled">
<Body size="S">No LLMs are enabled</Body>
</div>
{/if}
{#if disabledProviders.length > 0}
<div class="section-title disabled-title">Disabled</div>
<div class="ai-list">
{#each disabledProviders as { provider, config } (provider)}
<AIConfigTile
{config}
editHandler={() => handleEnable(provider)}
disableHandler={() => disableProvider(provider)}
/>
{/each}
</div>
{/if}
</div>
</Layout>
{/if}
<Modal bind:this={portalModal}> <Modal bind:this={portalModal}>
<PortalModal <PortalModal

View File

@ -65,7 +65,6 @@ export const INITIAL_COMPONENTS_STATE: ComponentState = {
export class ComponentStore extends BudiStore<ComponentState> { export class ComponentStore extends BudiStore<ComponentState> {
constructor() { constructor() {
super(INITIAL_COMPONENTS_STATE) super(INITIAL_COMPONENTS_STATE)
this.reset = this.reset.bind(this) this.reset = this.reset.bind(this)
this.refreshDefinitions = this.refreshDefinitions.bind(this) this.refreshDefinitions = this.refreshDefinitions.bind(this)
this.getDefinition = this.getDefinition.bind(this) this.getDefinition = this.getDefinition.bind(this)
@ -214,7 +213,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
const def = this.getDefinition(enrichedComponent?._component) const def = this.getDefinition(enrichedComponent?._component)
const filterableTypes = def?.settings?.filter(setting => const filterableTypes = def?.settings?.filter(setting =>
setting?.type?.startsWith("filter") ["filter", "filter/relationship"].includes(setting?.type)
) )
for (let setting of filterableTypes || []) { for (let setting of filterableTypes || []) {
const isLegacy = Array.isArray(enrichedComponent[setting.key]) const isLegacy = Array.isArray(enrichedComponent[setting.key])

View File

@ -6,9 +6,12 @@ interface Position {
} }
interface MenuItem { interface MenuItem {
label: string
icon?: string icon?: string
action: () => void name: string
keyBind: string | null
visible: boolean
disabled: boolean
callback: () => void
} }
interface ContextMenuState { interface ContextMenuState {

View File

@ -13,6 +13,7 @@ import {
UnsavedUser, UnsavedUser,
} from "@budibase/types" } from "@budibase/types"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { notifications } from "@budibase/bbui"
interface UserInfo { interface UserInfo {
email: string email: string
@ -43,6 +44,7 @@ class UserStore extends BudiStore<UserState> {
try { try {
return await API.getUser(userId) return await API.getUser(userId)
} catch (err) { } catch (err) {
notifications.error("Error fetching user")
return null return null
} }
} }

View File

@ -87,6 +87,7 @@ export default defineConfig(({ mode }) => {
exclude: ["@roxi/routify", "fsevents"], exclude: ["@roxi/routify", "fsevents"],
}, },
resolve: { resolve: {
conditions: mode === "test" ? ["browser"] : [],
dedupe: ["@roxi/routify"], dedupe: ["@roxi/routify"],
alias: { alias: {
"@budibase/types": path.resolve(__dirname, "../types/src"), "@budibase/types": path.resolve(__dirname, "../types/src"),

View File

@ -5204,7 +5204,7 @@
"icon": "Data", "icon": "Data",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"hasChildren": true, "hasChildren": true,
"actions": ["RefreshDatasource"], "actions": ["RefreshDatasource", "AddDataProviderQueryExtension"],
"size": { "size": {
"width": 500, "width": 500,
"height": 200 "height": 200
@ -5526,7 +5526,59 @@
} }
] ]
}, },
"filter": {
"name": "Filter",
"icon": "Filter",
"size": {
"width": 100,
"height": 35
},
"new": true,
"settings": [
{
"type": "dataSource/filterable",
"label": "Target component",
"required": true,
"key": "targetComponent",
"wide": true
},
{
"label": "",
"type": "filterConfiguration",
"key": "filterConfig",
"nested": true,
"dependsOn": "targetComponent",
"resetOn": "targetComponent"
},
{
"type": "boolean",
"label": "Persist filters",
"key": "persistFilters",
"defaultValue": false
},
{
"type": "boolean",
"label": "Clear filters",
"key": "showClear",
"defaultValue": false
}
]
},
"filterconfig": {
"name": "",
"icon": "",
"editable": true,
"settings": [
{
"type": "plainText",
"label": "Label",
"key": "label"
}
]
},
"dynamicfilter": { "dynamicfilter": {
"deprecated": true,
"name": "Dynamic Filter", "name": "Dynamic Filter",
"icon": "Filter", "icon": "Filter",
"size": { "size": {
@ -7671,7 +7723,8 @@
"setting": "table.type", "setting": "table.type",
"value": "custom", "value": "custom",
"invert": true "invert": true
} },
"resetOn": "table"
}, },
{ {
"type": "field/sortable", "type": "field/sortable",
@ -7823,7 +7876,7 @@
] ]
} }
], ],
"actions": ["RefreshDatasource"] "actions": ["RefreshDatasource", "AddDataProviderFilterExtension"]
}, },
"bbreferencefield": { "bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels", "devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",

View File

@ -22,6 +22,7 @@
environmentStore, environmentStore,
sidePanelStore, sidePanelStore,
modalStore, modalStore,
dataSourceStore,
} from "@/stores" } from "@/stores"
import NotificationDisplay from "./overlay/NotificationDisplay.svelte" import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte" import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
@ -47,11 +48,18 @@
import SnippetsProvider from "./context/SnippetsProvider.svelte" import SnippetsProvider from "./context/SnippetsProvider.svelte"
import EmbedProvider from "./context/EmbedProvider.svelte" import EmbedProvider from "./context/EmbedProvider.svelte"
import DNDSelectionIndicators from "./preview/DNDSelectionIndicators.svelte" import DNDSelectionIndicators from "./preview/DNDSelectionIndicators.svelte"
import { ActionTypes } from "@/constants"
// Provide contexts // Provide contexts
const context = createContextStore()
setContext("sdk", SDK) setContext("sdk", SDK)
setContext("component", writable({ id: null, ancestors: [] })) setContext("component", writable({ id: null, ancestors: [] }))
setContext("context", createContextStore()) setContext("context", context)
// Seed context with an action to refresh all datasources
context.actions.provideAction("all", ActionTypes.RefreshDatasource, () => {
dataSourceStore.actions.refreshAll()
})
let dataLoaded = false let dataLoaded = false
let permissionError = false let permissionError = false

View File

@ -100,6 +100,7 @@
}, },
limit, limit,
primaryDisplay: ($fetch.definition as any)?.primaryDisplay, primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
loaded: $fetch.loaded,
} }
const createFetch = (datasource: ProviderDatasource) => { const createFetch = (datasource: ProviderDatasource) => {

View File

@ -6,6 +6,7 @@
import { featuresStore } from "@/stores" import { featuresStore } from "@/stores"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { UILogicalOperator, EmptyFilterOption } from "@budibase/types"
// table is actually any datasource, but called table for legacy compatibility // table is actually any datasource, but called table for legacy compatibility
export let table export let table
@ -43,6 +44,8 @@
let gridContext let gridContext
let minHeight = 0 let minHeight = 0
let filterExtensions = {}
$: id = $component.id $: id = $component.id
$: currentTheme = $context?.device?.theme $: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light") $: darkMode = !currentTheme?.includes("light")
@ -51,15 +54,73 @@
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context) $: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
$: selectedRows = deriveSelectedRows(gridContext) $: selectedRows = deriveSelectedRows(gridContext)
$: styles = patchStyles($component.styles, minHeight) $: styles = patchStyles($component.styles, minHeight)
$: data = { selectedRows: $selectedRows } $: rowMap = gridContext?.rowLookupMap
$: data = {
selectedRows: $selectedRows,
embeddedData: {
dataSource: table,
componentId: $component.id,
loaded: !!$rowMap,
},
}
$: actions = [ $: actions = [
{ {
type: ActionTypes.RefreshDatasource, type: ActionTypes.RefreshDatasource,
callback: () => gridContext?.rows.actions.refreshData(), callback: () => gridContext?.rows.actions.refreshData(),
metadata: { dataSource: table }, metadata: { dataSource: table },
}, },
{
type: ActionTypes.AddDataProviderFilterExtension,
callback: addFilterExtension,
},
{
type: ActionTypes.RemoveDataProviderFilterExtension,
callback: removeFilterExtension,
},
] ]
$: extendedFilter = extendFilter(initialFilter, filterExtensions)
/**
*
* @param componentId Originating Component id
* @param extension Filter extension
*/
const addFilterExtension = (componentId, extension) => {
if (!componentId || !extension) {
return
}
filterExtensions = { ...filterExtensions, [componentId]: extension }
}
/**
*
* @param componentId Originating Component id
* @param extension Filter extension
*/
const removeFilterExtension = componentId => {
if (!componentId) {
return
}
const { [componentId]: removed, ...rest } = filterExtensions
filterExtensions = { ...rest }
}
const extendFilter = (initialFilter, extensions) => {
if (!Object.keys(extensions || {}).length) {
return initialFilter
}
return {
groups: (initialFilter ? [initialFilter] : []).concat(
Object.values(extensions)
),
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
}
// Provide additional data context for live binding eval // Provide additional data context for live binding eval
export const getAdditionalDataContext = () => { export const getAdditionalDataContext = () => {
const gridContext = grid?.getContext() const gridContext = grid?.getContext()
@ -197,7 +258,7 @@
{stripeRows} {stripeRows}
{quiet} {quiet}
{darkMode} {darkMode}
{initialFilter} initialFilter={extendedFilter}
{initialSortColumn} {initialSortColumn}
{initialSortOrder} {initialSortOrder}
{fixedRowHeight} {fixedRowHeight}

View File

@ -42,13 +42,15 @@
const goToPortal = () => { const goToPortal = () => {
window.location.href = isBuilder ? "/builder/portal/apps" : "/builder/apps" window.location.href = isBuilder ? "/builder/portal/apps" : "/builder/apps"
} }
$: user = $authStore as User
</script> </script>
{#if $authStore} {#if $authStore}
<ActionMenu align={compact ? "right" : "left"}> <ActionMenu align={compact ? "right" : "left"}>
<svelte:fragment slot="control"> <svelte:fragment slot="control">
<div class="container"> <div class="container">
<UserAvatar user={$authStore} size="M" showTooltip={false} /> <UserAvatar {user} size="M" showTooltip={false} />
{#if !compact} {#if !compact}
<div class="text"> <div class="text">
<div class="name"> <div class="name">

View File

@ -0,0 +1,438 @@
<script lang="ts">
import { Button, Helpers } from "@budibase/bbui"
import {
type FilterConfig,
type FieldSchema,
type SearchFilter,
type SearchFilterGroup,
type TableSchema,
type UISearchFilter,
type Component,
type TableDatasource,
ArrayOperator,
EmptyFilterOption,
FieldType,
FilterType,
InternalTable,
RangeOperator,
UILogicalOperator,
} from "@budibase/types"
import { getContext } from "svelte"
import Container from "../container/Container.svelte"
import { getAction } from "@/utils/getAction"
import { ActionTypes } from "@/constants"
import { QueryUtils, fetchData, memo } from "@budibase/frontend-core"
import FilterButton from "./FilterButton.svelte"
import { onDestroy } from "svelte"
import { uiStateStore, componentStore } from "@/stores"
import { onMount, setContext } from "svelte"
import { writable } from "svelte/store"
export let persistFilters: boolean | undefined = false
export let showClear: boolean | undefined = false
export let filterConfig: FilterConfig[] | undefined = []
export let targetComponent: any
const memoFilters = memo({} as Record<string, SearchFilter>)
const component = getContext("component")
const { API, fetchDatasourceSchema, getRelationshipSchemaAdditions } =
getContext("sdk")
const rowCache = writable({})
setContext("rows", rowCache)
// Core filter template
const baseFilter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL, // could be configurable
filters: [],
},
],
}
let userFetch: any
let hydrated = false
// All current filters.
let filters: Record<string, SearchFilter> = {}
let schema: TableSchema | null
// Target component being filtered
let dataComponent: Component | null = null
// Update the memo when the filters are updated
$: memoFilters.set(filters)
// Fully built filter extension
$: filterExtension = buildExtension($memoFilters)
$: query = filterExtension ? QueryUtils.buildQuery(filterExtension) : null
// Embedded datasources are those inside blocks or other components
$: filterDatasource =
targetComponent?.embeddedData?.dataSource ?? targetComponent?.datasource
// The target component
$: componentId =
targetComponent?.embeddedData?.componentId ?? targetComponent?.id
// Process the target component
$: target = componentStore.actions.getComponentById(componentId)
// Ensure the target has intialised
$: loaded =
(targetComponent?.embeddedData?.loaded ?? targetComponent?.loaded) || false
// Init the target component
$: loaded && initTarget(target)
// Update schema when the target instance
$: dataComponent && fetchSchema(filterDatasource)
$: isGridblock =
dataComponent?._component === "@budibase/standard-components/gridblock"
// Must be active and part of the current schema
$: visibleFilters = filterConfig?.filter(
filter => filter.active && schema?.[filter.field]
)
// Choose the appropriate action based on component type
$: addAction = isGridblock
? ActionTypes.AddDataProviderFilterExtension
: ActionTypes.AddDataProviderQueryExtension
$: removeAction = isGridblock
? ActionTypes.RemoveDataProviderFilterExtension
: ActionTypes.RemoveDataProviderQueryExtension
// Register extension actions
$: addExtension = componentId ? getAction(componentId, addAction) : null
$: removeExtension = componentId ? getAction(componentId, removeAction) : null
// If the filters are updated, notify the target of the change
$: hydrated && dataComponent && loaded && fire(filterExtension)
const initTarget = (target: Component) => {
if (!dataComponent && target) {
dataComponent = target
// In the event the underlying component is remounted and requires hydration
if (!hydrated) {
hydrateFilters()
}
} else if (dataComponent && !target) {
hydrated = false
dataComponent = null
filters = {}
schema = null
}
}
const getValidOperatorsForType = (
config: FilterConfig | undefined,
schema: TableSchema | null
): {
value: string
label: string
}[] => {
if (!config?.field || !config.columnType || !schema) {
return []
}
const fieldType = config.columnType || schema[config.field].type
let coreOperators = QueryUtils.getValidOperatorsForType(
{
type: fieldType,
},
config.field,
filterDatasource
)
const isDateTime = fieldType === FieldType.DATETIME
// Label mixins. Override the display text
const opToDisplay: Record<string, any> = {
rangeHigh: isDateTime ? "Before" : undefined,
rangeLow: isDateTime ? "After" : undefined,
[FilterType.EQUAL]: "Is",
[FilterType.NOT_EQUAL]: "Is not",
}
// Add in the range operator for datetime fields
coreOperators = [
...coreOperators,
...(fieldType === FieldType.DATETIME
? [{ value: RangeOperator.RANGE, label: "Between" }]
: []),
]
// Map the operators to set an label overrides
return coreOperators
.filter(op => {
if (isDateTime && op.value === FilterType.ONE_OF) return false
return true
})
.map(op => ({
...op,
label: opToDisplay[op.value] || op.label,
}))
}
const buildExtension = (filters: Record<string, SearchFilter>) => {
const extension = Helpers.cloneDeep(baseFilter)
delete extension.onEmptyFilter
// Pass in current filters
if (extension.groups) {
const groups: SearchFilterGroup[] = extension.groups
groups[0].filters = Object.values(filters)
}
return extension
}
const fire = (extension: UISearchFilter) => {
if (!$component?.id || !componentId) return
if (Object.keys(filters).length == 0) {
removeExtension?.($component.id)
toLocalStorage()
return
}
if (persistFilters) {
toLocalStorage()
}
addExtension?.($component.id, isGridblock ? extension : query)
}
/**
* Serialise any selected config to localstorage to retain state on refresh
*/
const toLocalStorage = () => {
uiStateStore.update(state => ({
...state,
[$component.id]: { sourceId: filterDatasource.resourceId, filters },
}))
}
/**
* Purge the cached component settings when the source changes
*/
const clearLocalStorage = () => {
uiStateStore.update(state => {
delete state[$component.id]
return state
})
}
const excludedTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.AI,
]
async function fetchSchema(datasource: any) {
if (datasource) {
const fetchedSchema: TableSchema | null = await fetchDatasourceSchema(
datasource,
{
enrichRelationships: true,
formSchema: false,
}
)
const filteredSchemaEntries = Object.entries(fetchedSchema || {}).filter(
([_, field]: [string, FieldSchema]) => {
return !excludedTypes.includes(field.type)
}
)
const filteredSchema = Object.fromEntries(filteredSchemaEntries)
// Necessary to ensure that link fields possess all config required
// to render their respective UX
const enrichedSchema = await getRelationshipSchemaAdditions(
filteredSchema as Record<string, any>
)
schema = { ...filteredSchema, ...enrichedSchema }
} else {
schema = null
}
}
const getOperators = (
config: FilterConfig | undefined,
schema: TableSchema | null
) => {
const operators = getValidOperatorsForType(config, schema)
return operators
}
const clearAll = () => {
filters = {}
uiStateStore.update(state => {
delete state[$component.id]
return state
})
}
// InitFetch for RelationShipField Display on load.
// Hardcoded for the user for now
const createFetch = (initValue: any) => {
// field and primary - based on datasource
let searchFilter: SearchFilterGroup = {
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "_id",
operator: ArrayOperator.ONE_OF,
value: Array.isArray(initValue) ? initValue : [initValue],
},
],
}
// Default filter
let initFilter = initValue
? {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
: undefined
const ds: { type: string; tableId: string } = {
type: "user",
tableId: InternalTable.USER_METADATA,
}
return fetchData({
API,
datasource: ds as TableDatasource,
options: {
filter: initFilter,
limit: 100,
},
})
}
// Only dealing with user fields.
const initRelationShips = (filters: Record<string, SearchFilter>) => {
const filtered = Object.entries(filters || {}).filter(
([_, filter]: [string, SearchFilter]) => {
return (
filter.type === FieldType.BB_REFERENCE_SINGLE ||
filter.type === FieldType.BB_REFERENCE
)
}
)
return Object.fromEntries(filtered)
}
// Flatten and gather all configured users ids
const getRelIds = (filters: Record<string, SearchFilter>) => {
const filtered = Object.entries(filters || {}).reduce(
(acc: string[], [_, filter]) => {
if (
(filter.type === FieldType.BB_REFERENCE_SINGLE ||
filter.type === FieldType.BB_REFERENCE) &&
filter.value
) {
acc = [
...acc,
...(Array.isArray(filter.value) ? filter.value : [filter.value]),
]
}
return acc
},
[]
)
const uniqueIds = new Set(filtered)
return Array.from(uniqueIds)
}
$: rels = initRelationShips($memoFilters)
$: relIds = getRelIds(rels)
$: if (!userFetch && relIds.length) {
userFetch = createFetch(relIds)
}
$: fetchedRows = userFetch ? $userFetch?.rows : []
$: if (fetchedRows.length) {
const fetched = fetchedRows.reduce((acc: any, ele: any) => {
acc[ele._id] = ele
return acc
}, {})
// User cache
rowCache.update(state => ({
...state,
...fetched,
}))
}
const hydrateFilters = () => {
// Hydrate with previously set config
if (persistFilters) {
const filterState = $uiStateStore[$component.id]?.filters
filters = Helpers.cloneDeep(filterState || {})
}
hydrated = true
}
onMount(() => {
if (!persistFilters) {
clearLocalStorage()
} else {
hydrateFilters()
}
})
onDestroy(() => {
// Ensure the extension is cleared on destroy
removeExtension($component.id)
})
</script>
<div>
<div class="filters">
<Container wrap direction={"row"} hAlign={"left"} vAlign={"top"} gap={"S"}>
{#each visibleFilters || [] as config}
{@const filter = $memoFilters[config.field]}
<FilterButton
{config}
{filter}
{schema}
operators={getOperators(config, schema)}
on:change={e => {
if (!e.detail) {
const { [config.field]: removed, ...rest } = filters
filters = { ...rest }
} else {
filters = { ...filters, [config.field]: e.detail }
}
}}
on:toggle={() => {
const { [config.field]: removed, ...rest } = filters
filters = { ...rest }
}}
/>
{/each}
{#if showClear && Object.keys(filters).length}
<Button size={"S"} secondary on:click={clearAll}>Clear all</Button>
{/if}
</Container>
</div>
</div>
<style>
.filters {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,250 @@
<script lang="ts">
import FilterPopover from "./FilterPopover.svelte"
import {
type FieldSchema,
type FilterConfig,
type TableSchema,
type SearchFilter,
FieldType,
RangeOperator,
} from "@budibase/types"
import { type PopoverAPI, Helpers } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import { type Writable } from "svelte/store"
import { isArrayOperator } from "@/utils/filtering"
export let disabled = false
export let size = "S"
export let filter: SearchFilter | undefined = undefined
export let config: FilterConfig | undefined = undefined
export let schema: TableSchema | null = null
export let operators:
| {
value: string
label: string
}[]
| undefined = undefined
const dispatch = createEventDispatcher()
const rowCache: Writable<Record<string, any>> = getContext("rows")
let popover: PopoverAPI
let button: HTMLDivElement
let filterMeta: string | undefined
let filterTitle: string | undefined
$: icon = !filter ? "AddToSelection" : "CloseCircle"
$: fieldSchema = config ? schema?.[config?.field] : undefined
$: filterOp = filter
? operators?.find(op => op.value === filter.operator)
: undefined
$: truncate = filterOp?.value !== RangeOperator.RANGE
$: filterDisplay = displayText(filter, fieldSchema)
const parseDateDisplay = (
filter: SearchFilter | undefined,
fieldSchema: FieldSchema | undefined
) => {
if (!filter || !fieldSchema || fieldSchema.type !== FieldType.DATETIME)
return ""
if (filter.operator === RangeOperator.RANGE) {
const enableTime = !fieldSchema.dateOnly
const { high, low } = filter.value
return `${Helpers.getDateDisplayValue(low, { enableTime })}
- ${Helpers.getDateDisplayValue(high, { enableTime })}`
}
const parsed = Helpers.parseDate(filter.value, {
enableTime: !fieldSchema.dateOnly,
})
const display = Helpers.getDateDisplayValue(parsed, {
enableTime: !fieldSchema.dateOnly,
})
return `${display}`
}
const parseMultiDisplay = (value: string[] | undefined) => {
const moreThanOne =
Array.isArray(value) && value?.length > 1
? `+${value?.length - 1} more`
: undefined
filterMeta = moreThanOne
filterTitle = `${value?.join(", ")}`
return `${value?.[0]}`
}
/**
* Determine appropriate display text for the filter button
* @param filter
* @param fieldSchema
*/
const displayText = (
filter: SearchFilter | undefined,
fieldSchema: FieldSchema | undefined
) => {
filterMeta = undefined
filterTitle = undefined
if (!filter || !fieldSchema) return
// Default to the base value. This could be a string or an array
// Some of the values could be refs for users.
let display = filter.value
if (fieldSchema.type === FieldType.BOOLEAN) {
display = Helpers.capitalise(filter.value)
} else if (fieldSchema.type === FieldType.DATETIME) {
display = parseDateDisplay(filter, fieldSchema)
} else if (fieldSchema.type === FieldType.ARRAY) {
if (!isArrayOperator(filter.operator)) {
display = filter.value
} else {
const filterVals = Array.isArray(filter.value)
? filter.value
: [filter.value]
display = parseMultiDisplay(filterVals)
}
} else if (
fieldSchema.type === FieldType.BB_REFERENCE ||
fieldSchema.type === FieldType.BB_REFERENCE_SINGLE
) {
// The display text for the user refs is dependent on
// a row cache.
let userDisplay: string = ""
// Process as single if the operator requires it
if (!isArrayOperator(filter.operator)) {
const userRow = $rowCache?.[filter.value]
userDisplay = userRow?.email ?? filter.value
} else {
const filterVals = Array.isArray(filter.value)
? filter.value
: [filter.value]
// Email is currently the default display field for users.
userDisplay = parseMultiDisplay(
filterVals.map((val: string) => $rowCache?.[val]?.email ?? val)
)
}
display = userDisplay
}
return `${filterOp?.label.toLowerCase()} ${filter.noValue ? "" : display}`
}
</script>
<FilterPopover
bind:this={popover}
{filter}
{operators}
{schema}
{config}
on:change
>
<div slot="anchor">
<div class="filter-button-wrap" class:inactive={!filter}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={button}
class:is-disabled={disabled}
class="new-styles spectrum-Button spectrum-Button--secondary spectrum-Button--size{size.toUpperCase()}"
title={filterTitle || filterDisplay}
>
<div
class="toggle-wrap"
on:click={e => {
if (filter) {
e.stopPropagation()
}
if (!disabled) {
dispatch("toggle")
}
}}
>
<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
</div>
<span class="spectrum-Button-label" class:truncate>
<span class="field">{config?.label || config?.field}</span>
{#if filter}
<span class="display">
{filterDisplay}
</span>
{/if}
</span>
{#if filterMeta}
<span class="filterMeta">{filterMeta}</span>
{/if}
</div>
</div>
</div>
</FilterPopover>
<style>
.toggle-wrap {
z-index: 1;
}
.toggle-wrap svg {
width: 12px;
pointer-events: none;
}
.filter-button-wrap.inactive .spectrum-Button .spectrum-Icon {
color: var(--grey-6);
}
.spectrum-Button {
position: relative;
display: flex;
gap: var(--spacing-xs);
}
.spectrum-Button.is-disabled {
cursor: default;
}
.filter-button-wrap.inactive .spectrum-Button {
background: var(--spectrum-global-color-gray-50);
border: 1px dashed var(--spectrum-global-color-gray-200);
}
.spectrum-Button-label.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.spectrum-Button-label .display {
color: var(--spectrum-global-color-blue-600);
}
.spectrum-Button--secondary.new-styles {
background: var(--spectrum-global-color-gray-200);
border: none;
border-color: transparent;
color: var(--spectrum-global-color-gray-900);
}
.spectrum-Button--secondary.new-styles:not(.is-disabled):hover {
background: var(--spectrum-global-color-gray-300);
}
.spectrum-Button--secondary.new-styles.is-disabled {
color: var(--spectrum-global-color-gray-500);
}
.spectrum-Button .spectrum-Button-label {
padding: 0px;
}
</style>

View File

@ -0,0 +1,387 @@
<script lang="ts">
import {
type PopoverAPI,
Popover,
PopoverAlignment,
Input,
Button,
Select,
Helpers,
CoreCheckboxGroup,
CoreRadioGroup,
DatePicker,
DateRangePicker,
} from "@budibase/bbui"
import {
FilterValueType,
OperatorOptions,
} from "@budibase/frontend-core/src/constants"
import {
type FieldSchema,
type FilterConfig,
type TableSchema,
type SearchFilter,
ArrayOperator,
BasicOperator,
FieldType,
} from "@budibase/types"
import { createEventDispatcher } from "svelte"
import BbReferenceField from "../forms/BBReferenceField.svelte"
import { type Writable } from "svelte/store"
import { getContext } from "svelte"
import { isArrayOperator } from "@/utils/filtering"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
dayjs.extend(utc)
export const show = () => popover?.show()
export const hide = () => popover?.hide()
export let align: PopoverAlignment = PopoverAlignment.Left
export let showPopover: boolean = true
export let filter: SearchFilter | undefined = undefined
export let schema: TableSchema | null = null
export let config: FilterConfig | undefined = undefined
export let operators:
| {
value: string
label: string
}[]
| undefined = undefined
const dispatch = createEventDispatcher()
const rowCache: Writable<Record<string, any>> = getContext("rows")
let popover: PopoverAPI | undefined
let anchor: HTMLElement | undefined
let open: boolean = false
// Date/time
let enableTime: boolean
let timeOnly: boolean
let ignoreTimezones: boolean
// Change on update
$: editableFilter = getDefaultFilter(filter, schema, config)
$: fieldSchema = config ? schema?.[config?.field] : undefined
$: options = getOptions(fieldSchema)
$: if (fieldSchema?.type === FieldType.DATETIME) {
enableTime = !fieldSchema?.dateOnly
timeOnly = !!fieldSchema?.timeOnly
ignoreTimezones = !!fieldSchema?.ignoreTimezones
}
const parseDateRange = (
range: { high: string; low: string } | undefined
): string[] | undefined => {
if (!range) {
return
}
const values = [range.low, range.high]
if (values.filter(value => value).length === 2) {
return values
}
}
const sanitizeOperator = (
filter: SearchFilter | undefined
): SearchFilter | undefined => {
if (!filter) return
const clone = Helpers.cloneDeep(filter)
const isOperatorArray = isArrayOperator(filter.operator)
// Ensure the correct filter value types when switching between operators.
// Accommodates the user picker which always returns an array.
// Also ensures that strings using 'oneOf' operator are left as strings
if (
isOperatorArray &&
typeof filter.value === "string" &&
![FieldType.STRING, FieldType.NUMBER].includes(filter.type!)
) {
clone.value = [filter.value]
} else if (isOperatorArray && !filter?.value?.length) {
delete clone.value
} else if (!isOperatorArray && Array.isArray(filter.value)) {
clone.value = filter.value[0]
}
// Update the noValue flag if the operator does not take a value
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
clone.noValue = noValueOptions.includes(clone.operator)
// Clear out the value when the operator is unset or the value
if (!clone?.operator) {
delete clone.value
}
return clone
}
const getOptions = (schema: FieldSchema | undefined) => {
if (!schema) return []
const constraints = fieldSchema?.constraints
const opts = constraints?.inclusion ?? []
return opts
}
const getDefaultFilter = (
filter: SearchFilter | undefined,
schema: TableSchema | null,
config: FilterConfig | undefined
) => {
if (filter) {
return Helpers.cloneDeep(filter)
} else if (!schema || !config) {
return
}
const schemaField = schema[config.field]
const defaultValue =
schemaField?.type === FieldType.BOOLEAN ? "true" : undefined
const defaultFilter: SearchFilter = {
valueType: FilterValueType.VALUE,
field: config.field,
type: schemaField?.type,
operator: BasicOperator.EMPTY,
value: defaultValue,
}
return {
...defaultFilter,
operator: operators?.[0]?.value,
} as SearchFilter
}
const changeUser = (update: { value: string[] }) => {
if (!update || !editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: update.value,
})
}
const cacheUserRows = (e: CustomEvent) => {
const retrieved = e.detail.reduce((acc: any, ele: any) => {
acc[ele._id] = ele
return acc
}, {})
rowCache.update(state => ({
...state,
...retrieved,
}))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="anchor" bind:this={anchor} on:click={show}>
<slot name="anchor" />
</div>
<Popover
bind:this={popover}
bind:open
minWidth={300}
maxWidth={300}
{anchor}
{align}
{showPopover}
on:open
on:close
customZIndex={5}
>
<div class="filter-popover">
<div class="filter-popover-body">
{#if editableFilter && fieldSchema}
<div class="operator">
<Select
quiet
value={editableFilter.operator}
disabled={!editableFilter.field}
options={operators}
autoWidth
on:change={e => {
if (!editableFilter) return
const sanitized = sanitizeOperator({
...editableFilter,
operator: e.detail,
})
editableFilter = { ...(sanitized || editableFilter) }
}}
/>
</div>
{#if editableFilter?.type && [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA, FieldType.AI].includes(editableFilter.type)}
<Input
disabled={editableFilter.noValue}
value={editableFilter.value}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if (editableFilter?.type && editableFilter?.type === FieldType.ARRAY) || (editableFilter.type === FieldType.OPTIONS && editableFilter.operator === ArrayOperator.ONE_OF)}
{@const isMulti = isArrayOperator(editableFilter.operator)}
{@const type = isMulti ? CoreCheckboxGroup : CoreRadioGroup}
{#key type}
<svelte:component
this={type}
value={editableFilter.value || []}
disabled={editableFilter.noValue}
{options}
direction={"vertical"}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{/key}
{:else if editableFilter.type === FieldType.OPTIONS}
<CoreRadioGroup
value={editableFilter.value}
disabled={editableFilter.noValue}
{options}
direction={"vertical"}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type === FieldType.DATETIME && editableFilter.operator === "range"}
<DateRangePicker
{enableTime}
{timeOnly}
{ignoreTimezones}
value={parseDateRange(editableFilter.value)}
on:change={e => {
const [from, to] = e.detail
const parsedFrom = enableTime
? from.utc().format()
: Helpers.stringifyDate(from, {
enableTime,
timeOnly,
ignoreTimezones,
})
const parsedTo = enableTime
? to.utc().format()
: Helpers.stringifyDate(to, {
enableTime,
timeOnly,
ignoreTimezones,
})
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: {
low: parsedFrom,
high: parsedTo,
},
})
}}
/>
{:else if editableFilter.type === FieldType.DATETIME}
<DatePicker
{enableTime}
{timeOnly}
{ignoreTimezones}
disabled={editableFilter.noValue}
value={editableFilter.value}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type === FieldType.BOOLEAN}
<Select
value={editableFilter.value}
disabled={editableFilter.noValue}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
on:change={e => {
if (!editableFilter) return
editableFilter = sanitizeOperator({
...editableFilter,
value: e.detail,
})
}}
/>
{:else if editableFilter.type && [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(editableFilter.type)}
<!-- Multi relates to the operator, not the type -->
{@const multi = isArrayOperator(editableFilter.operator)}
{#key multi}
<BbReferenceField
disabled={editableFilter.noValue}
defaultValue={editableFilter.value}
{multi}
onChange={changeUser}
defaultRows={Object.values($rowCache)}
on:rows={cacheUserRows}
/>
{/key}
{:else}
<Input disabled />
{/if}
<!-- Needs to be disabled if there is nothing-->
<Button
cta
on:click={() => {
const sanitized = sanitizeOperator(editableFilter)
const { noValue, value, operator } = sanitized || {}
// Check for empty filter. if empty on invalid set it to undefined.
const update =
(!noValue && !value) || !operator ? undefined : sanitized
dispatch("change", update)
hide()
}}
>
Apply
</Button>
{/if}
</div>
</div>
</Popover>
<style>
.operator {
display: flex;
}
.operator :global(.spectrum-Picker) {
height: auto;
}
.filter-popover {
background-color: var(--spectrum-alias-background-color-primary);
}
.filter-popover-body {
padding: var(--spacing-m);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-m);
}
</style>

View File

@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types" import { FieldType, type Row } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte" import RelationshipField from "./RelationshipField.svelte"
export let defaultValue: string export let defaultValue: string
export let type = FieldType.BB_REFERENCE export let type = FieldType.BB_REFERENCE
export let multi: boolean | undefined = undefined
export let defaultRows: Row[] | undefined = []
function updateUserIDs(value: string | string[]) { function updateUserIDs(value: string | string[]) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -33,4 +35,7 @@
datasourceType={"user"} datasourceType={"user"}
primaryDisplay={"email"} primaryDisplay={"email"}
defaultValue={updatedDefaultValue} defaultValue={updatedDefaultValue}
{defaultRows}
{multi}
on:rows
/> />

View File

@ -14,13 +14,15 @@
type LegacyFilter, type LegacyFilter,
type SearchFilterGroup, type SearchFilterGroup,
type UISearchFilter, type UISearchFilter,
type RelationshipFieldMetadata,
type Row,
} from "@budibase/types" } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import type { RelationshipFieldMetadata, Row } from "@budibase/types"
import type { FieldApi, FieldState, FieldValidation } from "@/types" import type { FieldApi, FieldState, FieldValidation } from "@/types"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { createEventDispatcher } from "svelte"
export let field: string | undefined = undefined export let field: string | undefined = undefined
export let label: string | undefined = undefined export let label: string | undefined = undefined
@ -30,7 +32,7 @@
export let validation: FieldValidation | undefined = undefined export let validation: FieldValidation | undefined = undefined
export let autocomplete: boolean = true export let autocomplete: boolean = true
export let defaultValue: ValueType | undefined = undefined export let defaultValue: ValueType | undefined = undefined
export let onChange: (_props: { value: ValueType }) => void export let onChange: (_props: { value: ValueType; label?: string }) => void
export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
export let datasourceType: "table" | "user" = "table" export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined export let primaryDisplay: string | undefined = undefined
@ -41,8 +43,14 @@
| FieldType.BB_REFERENCE | FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK | FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
export let multi: boolean | undefined = undefined
export let tableId: string | undefined = undefined
export let defaultRows: Row[] | undefined = []
const { API } = getContext("sdk") const { API } = getContext("sdk")
const dispatch = createEventDispatcher()
// Field state // Field state
let fieldState: FieldState<string | string[]> | undefined let fieldState: FieldState<string | string[]> | undefined
let fieldApi: FieldApi let fieldApi: FieldApi
@ -59,11 +67,11 @@
// Reset the available options when our base filter changes // Reset the available options when our base filter changes
$: filter, (optionsMap = {}) $: filter, (optionsMap = {})
// Determine if we can select multiple rows or not // Determine if we can select multiple rows or not
$: multiselect = $: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) && multi ??
fieldSchema?.relationshipType !== "one-to-many" ([FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many")
// Get the proper string representation of the value // Get the proper string representation of the value
$: realValue = fieldState?.value as ValueType $: realValue = fieldState?.value as ValueType
@ -71,7 +79,7 @@
$: selectedIDs = getSelectedIDs(selectedValue) $: selectedIDs = getSelectedIDs(selectedValue)
// If writable, we use a fetch to load options // If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = tableId ?? fieldSchema?.tableId
$: writable = !disabled && !readonly $: writable = !disabled && !readonly
$: migratedFilter = migrateFilter(filter) $: migratedFilter = migrateFilter(filter)
$: fetch = createFetch( $: fetch = createFetch(
@ -87,7 +95,13 @@
// Build our options map // Build our options map
$: rows = $fetch?.rows || [] $: rows = $fetch?.rows || []
$: processOptions(realValue, rows, primaryDisplayField) $: rows && dispatch("rows", rows)
$: processOptions(
realValue,
[...rows, ...(defaultRows || [])],
primaryDisplayField
)
// If we ever have a value selected for which we don't have an option, we must // If we ever have a value selected for which we don't have an option, we must
// fetch those rows to ensure we can render them as options // fetch those rows to ensure we can render them as options

View File

@ -35,6 +35,7 @@ export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte" export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte" export { default as gridblock } from "./GridBlock.svelte"
export { default as textv2 } from "./Text.svelte" export { default as textv2 } from "./Text.svelte"
export { default as filter } from "./filter/Filter.svelte"
export { default as accordion } from "./Accordion.svelte" export { default as accordion } from "./Accordion.svelte"
export { default as singlerowprovider } from "./SingleRowProvider.svelte" export { default as singlerowprovider } from "./SingleRowProvider.svelte"
export * from "./charts" export * from "./charts"

View File

@ -6,6 +6,8 @@ export const ActionTypes = {
RefreshDatasource: "RefreshDatasource", RefreshDatasource: "RefreshDatasource",
AddDataProviderQueryExtension: "AddDataProviderQueryExtension", AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension", RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
AddDataProviderFilterExtension: "AddDataProviderFilterExtension",
RemoveDataProviderFilterExtension: "RemoveDataProviderFilterExtension",
SetDataProviderSorting: "SetDataProviderSorting", SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm", ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep", ChangeFormStep: "ChangeFormStep",

View File

@ -96,7 +96,10 @@ export interface SDK {
ActionTypes: typeof ActionTypes ActionTypes: typeof ActionTypes
fetchDatasourceSchema: any fetchDatasourceSchema: any
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table> fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
getRelationshipSchemaAdditions: (schema: Record<string, any>) => Promise<any>
enrichButtonActions: any
generateGoldenSample: any generateGoldenSample: any
createContextStore: any
builderStore: typeof builderStore builderStore: typeof builderStore
authStore: typeof authStore authStore: typeof authStore
notificationStore: typeof notificationStore notificationStore: typeof notificationStore

View File

@ -29,6 +29,7 @@ import { ActionTypes } from "./constants"
import { import {
fetchDatasourceSchema, fetchDatasourceSchema,
fetchDatasourceDefinition, fetchDatasourceDefinition,
getRelationshipSchemaAdditions,
} from "./utils/schema" } from "./utils/schema"
import { getAPIKey } from "./utils/api.js" import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js" import { enrichButtonActions } from "./utils/buttonActions.js"
@ -71,6 +72,7 @@ export default {
getAction, getAction,
fetchDatasourceSchema, fetchDatasourceSchema,
fetchDatasourceDefinition, fetchDatasourceDefinition,
getRelationshipSchemaAdditions,
fetchData, fetchData,
QueryUtils, QueryUtils,
ContextScopes: Constants.ContextScopes, ContextScopes: Constants.ContextScopes,

View File

@ -115,9 +115,18 @@ export const createDataSourceStore = () => {
}) })
} }
const refreshAll = () => {
get(store).forEach(instance => instance.refresh())
}
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
actions: { registerDataSource, unregisterInstance, invalidateDataSource }, actions: {
registerDataSource,
unregisterInstance,
invalidateDataSource,
refreshAll,
},
} }
} }

View File

@ -8,6 +8,7 @@ export { dataSourceStore } from "./dataSource"
export { confirmationStore } from "./confirmation" export { confirmationStore } from "./confirmation"
export { peekStore } from "./peek" export { peekStore } from "./peek"
export { stateStore } from "./state" export { stateStore } from "./state"
export { uiStateStore } from "./uiState"
export { themeStore } from "./theme" export { themeStore } from "./theme"
export { devToolsStore } from "./devTools" export { devToolsStore } from "./devTools"
export { componentStore } from "./components" export { componentStore } from "./components"

View File

@ -0,0 +1,26 @@
import { Writable } from "svelte/store"
import { createLocalStorageStore } from "@budibase/frontend-core"
/**
* Creates a generic app store for persisting component instance settings
* Keyed by component _id
*/
export class UIStateStore {
appId: string
localStorageKey: string
persistentStore: Writable<Record<string, any>>
subscribe: Writable<Record<string, any>>["subscribe"]
set: Writable<Record<string, any>>["set"]
update: Writable<Record<string, any>>["update"]
constructor() {
this.appId = window["##BUDIBASE_APP_ID##"] || "app"
this.localStorageKey = `${this.appId}.ui`
this.persistentStore = createLocalStorageStore(this.localStorageKey, {})
this.subscribe = this.persistentStore.subscribe
this.set = this.persistentStore.set
this.update = this.persistentStore.update
}
}
export const uiStateStore = new UIStateStore()

View File

@ -1,3 +1,4 @@
import { ArrayOperator, BasicOperator, RangeOperator } from "@budibase/types"
import { Readable } from "svelte/store" import { Readable } from "svelte/store"
export * from "./components" export * from "./components"
@ -5,3 +6,10 @@ export * from "./fields"
export * from "./forms" export * from "./forms"
export type Context = Readable<Record<string, any>> export type Context = Readable<Record<string, any>>
export type Operator =
| BasicOperator
| RangeOperator
| ArrayOperator
| "rangeLow"
| "rangeHigh"

View File

@ -0,0 +1,10 @@
import { type Operator } from "@/types"
import { ArrayOperator } from "@budibase/types"
/**
* Check if the supplied filter operator is for an array
* @param op
*/
export function isArrayOperator(op: Operator): op is ArrayOperator {
return op === ArrayOperator.CONTAINS_ANY || op === ArrayOperator.ONE_OF
}

View File

@ -121,6 +121,7 @@ export const getRelationshipSchemaAdditions = async (
relationshipAdditions[`${fieldKey}.${linkKey}`] = { relationshipAdditions[`${fieldKey}.${linkKey}`] = {
type: linkSchema[linkKey].type, type: linkSchema[linkKey].type,
externalType: linkSchema[linkKey].externalType, externalType: linkSchema[linkKey].externalType,
constraints: linkSchema[linkKey].constraints,
} }
}) })
} }

View File

@ -1,16 +1,17 @@
<script> <script lang="ts">
import { Avatar, AbsTooltip, TooltipPosition } from "@budibase/bbui" import { Avatar, AbsTooltip, TooltipPosition } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import type { User } from "@budibase/types"
export let user export let user: User
export let size = "S" export let size: "XS" | "S" | "M" = "S"
export let tooltipPosition = TooltipPosition.Top export let tooltipPosition: TooltipPosition = TooltipPosition.Top
export let showTooltip = true export let showTooltip: boolean = true
</script> </script>
{#if user} {#if user}
<AbsTooltip <AbsTooltip
text={showTooltip ? helpers.getUserLabel(user) : null} text={showTooltip ? helpers.getUserLabel(user) : ""}
position={tooltipPosition} position={tooltipPosition}
color={helpers.getUserColor(user)} color={helpers.getUserColor(user)}
> >

View File

@ -1,27 +1,31 @@
<script> <script lang="ts">
import { UserAvatar } from "@budibase/frontend-core" import { UserAvatar } from "@budibase/frontend-core"
import { TooltipPosition, Avatar } from "@budibase/bbui" import { TooltipPosition, Avatar } from "@budibase/bbui"
import type { UIUser } from "@budibase/types"
export let users = [] type OrderType = "ltr" | "rtl"
export let order = "ltr"
export let size = "S"
export let tooltipPosition = TooltipPosition.Top
$: uniqueUsers = unique(users, order) export let users: UIUser[] = []
export let order: OrderType = "ltr"
export let size: "XS" | "S" = "S"
export let tooltipPosition: TooltipPosition = TooltipPosition.Top
$: uniqueUsers = unique(users)
$: avatars = getAvatars(uniqueUsers, order) $: avatars = getAvatars(uniqueUsers, order)
const unique = users => { const unique = (users: UIUser[]) => {
let uniqueUsers = {} const uniqueUsers: Record<string, UIUser> = {}
users?.forEach(user => { users.forEach(user => {
uniqueUsers[user.email] = user uniqueUsers[user.email] = user
}) })
return Object.values(uniqueUsers) return Object.values(uniqueUsers)
} }
const getAvatars = (users, order) => { type Overflow = { _id: "overflow"; label: string }
const avatars = users.slice(0, 3) const getAvatars = (users: UIUser[], order: OrderType) => {
const avatars: (Overflow | UIUser)[] = users.slice(0, 3)
if (users.length > 3) { if (users.length > 3) {
const overflow = { const overflow: Overflow = {
_id: "overflow", _id: "overflow",
label: `+${users.length - 3}`, label: `+${users.length - 3}`,
} }
@ -31,17 +35,22 @@
avatars.unshift(overflow) avatars.unshift(overflow)
} }
} }
return avatars.map((user, idx) => ({ return avatars.map((user, idx) => ({
...user, ...user,
zIndex: order === "ltr" ? idx : uniqueUsers.length - idx, zIndex: order === "ltr" ? idx : uniqueUsers.length - idx,
})) }))
} }
function isUser(value: Overflow | UIUser): value is UIUser {
return value._id !== "overflow"
}
</script> </script>
<div class="avatars"> <div class="avatars">
{#each avatars as user} {#each avatars as user}
<span style="z-index:{user.zIndex};"> <span style="z-index:{user.zIndex};">
{#if user._id === "overflow"} {#if !isUser(user)}
<Avatar <Avatar
{size} {size}
initials={user.label} initials={user.label}

View File

@ -18,7 +18,6 @@ import {
generateDevAppID, generateDevAppID,
generateScreenID, generateScreenID,
getLayoutParams, getLayoutParams,
getScreenParams,
} from "../../db/utils" } from "../../db/utils"
import { import {
cache, cache,
@ -90,17 +89,6 @@ async function getLayouts() {
).rows.map(row => row.doc!) ).rows.map(row => row.doc!)
} }
async function getScreens() {
const db = context.getAppDB()
return (
await db.allDocs<Screen>(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map(row => row.doc!)
}
function getUserRoleId(ctx: UserCtx) { function getUserRoleId(ctx: UserCtx) {
return !ctx.user?.role || !ctx.user.role._id return !ctx.user?.role || !ctx.user.role._id
? roles.BUILTIN_ROLE_IDS.PUBLIC ? roles.BUILTIN_ROLE_IDS.PUBLIC
@ -241,7 +229,7 @@ export async function fetchAppDefinition(
const userRoleId = getUserRoleId(ctx) const userRoleId = getUserRoleId(ctx)
const accessController = new roles.AccessController() const accessController = new roles.AccessController()
const screens = await accessController.checkScreensAccess( const screens = await accessController.checkScreensAccess(
await getScreens(), await sdk.screens.fetch(),
userRoleId userRoleId
) )
ctx.body = { ctx.body = {
@ -257,7 +245,7 @@ export async function fetchAppPackage(
const appId = context.getAppId() const appId = context.getAppId()
const application = await sdk.applications.metadata.get() const application = await sdk.applications.metadata.get()
const layouts = await getLayouts() const layouts = await getLayouts()
let screens = await getScreens() let screens = await sdk.screens.fetch()
const license = await licensing.cache.getCachedLicense() const license = await licensing.cache.getCachedLicense()
// Enrich plugin URLs // Enrich plugin URLs
@ -915,7 +903,7 @@ async function migrateAppNavigation() {
const db = context.getAppDB() const db = context.getAppDB()
const existing = await sdk.applications.metadata.get() const existing = await sdk.applications.metadata.get()
const layouts: Layout[] = await getLayouts() const layouts: Layout[] = await getLayouts()
const screens: Screen[] = await getScreens() const screens: Screen[] = await sdk.screens.fetch()
// Migrate all screens, removing custom layouts // Migrate all screens, removing custom layouts
for (let screen of screens) { for (let screen of screens) {

View File

@ -1,13 +1,13 @@
import { EMPTY_LAYOUT } from "../../constants/layouts" import { EMPTY_LAYOUT } from "../../constants/layouts"
import { generateLayoutID, getScreenParams } from "../../db/utils" import { generateLayoutID } from "../../db/utils"
import { events, context } from "@budibase/backend-core" import { events, context } from "@budibase/backend-core"
import { import {
DeleteLayoutResponse, DeleteLayoutResponse,
Layout,
SaveLayoutRequest, SaveLayoutRequest,
SaveLayoutResponse, SaveLayoutResponse,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk"
export async function save( export async function save(
ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse> ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse>
@ -36,13 +36,9 @@ export async function destroy(ctx: UserCtx<void, DeleteLayoutResponse>) {
const layoutId = ctx.params.layoutId, const layoutId = ctx.params.layoutId,
layoutRev = ctx.params.layoutRev layoutRev = ctx.params.layoutRev
const layoutsUsedByScreens = ( const layoutsUsedByScreens = (await sdk.screens.fetch()).map(
await db.allDocs<Layout>( element => element.layoutId
getScreenParams(null, { )
include_docs: true,
})
)
).rows.map(element => element.doc!.layoutId)
if (layoutsUsedByScreens.includes(layoutId)) { if (layoutsUsedByScreens.includes(layoutId)) {
ctx.throw(400, "Cannot delete a layout that's being used by a screen") ctx.throw(400, "Cannot delete a layout that's being used by a screen")
} }

View File

@ -918,7 +918,9 @@ if (descriptions.length) {
describe("get", () => { describe("get", () => {
it("reads an existing row successfully", async () => { it("reads an existing row successfully", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
const res = await config.api.row.get(table._id!, existing._id!) const res = await config.api.row.get(table._id!, existing._id!)
@ -930,7 +932,7 @@ if (descriptions.length) {
it("returns 404 when row does not exist", async () => { it("returns 404 when row does not exist", async () => {
const table = await config.api.table.save(defaultTable()) const table = await config.api.table.save(defaultTable())
await config.api.row.save(table._id!, {}) await config.api.row.save(table._id!, { name: "foo" })
await config.api.row.get(table._id!, "1234567", { await config.api.row.get(table._id!, "1234567", {
status: 404, status: 404,
}) })
@ -958,8 +960,8 @@ if (descriptions.length) {
it("fetches all rows for given tableId", async () => { it("fetches all rows for given tableId", async () => {
const table = await config.api.table.save(defaultTable()) const table = await config.api.table.save(defaultTable())
const rows = await Promise.all([ const rows = await Promise.all([
config.api.row.save(table._id!, {}), config.api.row.save(table._id!, { name: "foo" }),
config.api.row.save(table._id!, {}), config.api.row.save(table._id!, { name: "bar" }),
]) ])
const res = await config.api.row.fetch(table._id!) const res = await config.api.row.fetch(table._id!)
@ -975,7 +977,9 @@ if (descriptions.length) {
describe("update", () => { describe("update", () => {
it("updates an existing row successfully", async () => { it("updates an existing row successfully", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(0, async () => { await expectRowUsage(0, async () => {
const res = await config.api.row.save(table._id!, { const res = await config.api.row.save(table._id!, {
@ -1166,7 +1170,9 @@ if (descriptions.length) {
}) })
it("should update only the fields that are supplied", async () => { it("should update only the fields that are supplied", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(0, async () => { await expectRowUsage(0, async () => {
const row = await config.api.row.patch(table._id!, { const row = await config.api.row.patch(table._id!, {
@ -1186,6 +1192,22 @@ if (descriptions.length) {
}) })
}) })
it("should not require the primary display", async () => {
const existing = await config.api.row.save(table._id!, {
name: "foo",
description: "bar",
})
await expectRowUsage(0, async () => {
const row = await config.api.row.patch(table._id!, {
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
description: "baz",
})
expect(row.description).toEqual("baz")
})
})
it("should update only the fields that are supplied and emit the correct oldRow", async () => { it("should update only the fields that are supplied and emit the correct oldRow", async () => {
let beforeRow = await config.api.row.save(table._id!, { let beforeRow = await config.api.row.save(table._id!, {
name: "test", name: "test",
@ -1213,7 +1235,9 @@ if (descriptions.length) {
}) })
it("should throw an error when given improper types", async () => { it("should throw an error when given improper types", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(0, async () => { await expectRowUsage(0, async () => {
await config.api.row.patch( await config.api.row.patch(
@ -1289,6 +1313,7 @@ if (descriptions.length) {
description: "test", description: "test",
}) })
const { _id } = await config.api.row.save(table._id!, { const { _id } = await config.api.row.save(table._id!, {
name: "test",
relationship: [{ _id: row._id }, { _id: row2._id }], relationship: [{ _id: row._id }, { _id: row2._id }],
}) })
const relatedRow = await config.api.row.get(table._id!, _id!, { const relatedRow = await config.api.row.get(table._id!, _id!, {
@ -1440,7 +1465,9 @@ if (descriptions.length) {
}) })
it("should be able to delete a row", async () => { it("should be able to delete a row", async () => {
const createdRow = await config.api.row.save(table._id!, {}) const createdRow = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(isInternal ? -1 : 0, async () => { await expectRowUsage(isInternal ? -1 : 0, async () => {
const res = await config.api.row.bulkDelete(table._id!, { const res = await config.api.row.bulkDelete(table._id!, {
@ -1451,7 +1478,9 @@ if (descriptions.length) {
}) })
it("should be able to delete a row with ID only", async () => { it("should be able to delete a row with ID only", async () => {
const createdRow = await config.api.row.save(table._id!, {}) const createdRow = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(isInternal ? -1 : 0, async () => { await expectRowUsage(isInternal ? -1 : 0, async () => {
const res = await config.api.row.bulkDelete(table._id!, { const res = await config.api.row.bulkDelete(table._id!, {
@ -1463,8 +1492,12 @@ if (descriptions.length) {
}) })
it("should be able to bulk delete rows, including a row that doesn't exist", async () => { it("should be able to bulk delete rows, including a row that doesn't exist", async () => {
const createdRow = await config.api.row.save(table._id!, {}) const createdRow = await config.api.row.save(table._id!, {
const createdRow2 = await config.api.row.save(table._id!, {}) name: "foo",
})
const createdRow2 = await config.api.row.save(table._id!, {
name: "bar",
})
const res = await config.api.row.bulkDelete(table._id!, { const res = await config.api.row.bulkDelete(table._id!, {
rows: [createdRow, createdRow2, { _id: "9999999" }], rows: [createdRow, createdRow2, { _id: "9999999" }],
@ -1581,8 +1614,8 @@ if (descriptions.length) {
}) })
it("should be able to delete a bulk set of rows", async () => { it("should be able to delete a bulk set of rows", async () => {
const row1 = await config.api.row.save(table._id!, {}) const row1 = await config.api.row.save(table._id!, { name: "foo" })
const row2 = await config.api.row.save(table._id!, {}) const row2 = await config.api.row.save(table._id!, { name: "bar" })
await expectRowUsage(isInternal ? -2 : 0, async () => { await expectRowUsage(isInternal ? -2 : 0, async () => {
const res = await config.api.row.bulkDelete(table._id!, { const res = await config.api.row.bulkDelete(table._id!, {
@ -1596,9 +1629,9 @@ if (descriptions.length) {
it("should be able to delete a variety of row set types", async () => { it("should be able to delete a variety of row set types", async () => {
const [row1, row2, row3] = await Promise.all([ const [row1, row2, row3] = await Promise.all([
config.api.row.save(table._id!, {}), config.api.row.save(table._id!, { name: "foo" }),
config.api.row.save(table._id!, {}), config.api.row.save(table._id!, { name: "bar" }),
config.api.row.save(table._id!, {}), config.api.row.save(table._id!, { name: "baz" }),
]) ])
await expectRowUsage(isInternal ? -3 : 0, async () => { await expectRowUsage(isInternal ? -3 : 0, async () => {
@ -1612,7 +1645,7 @@ if (descriptions.length) {
}) })
it("should accept a valid row object and delete the row", async () => { it("should accept a valid row object and delete the row", async () => {
const row1 = await config.api.row.save(table._id!, {}) const row1 = await config.api.row.save(table._id!, { name: "foo" })
await expectRowUsage(isInternal ? -1 : 0, async () => { await expectRowUsage(isInternal ? -1 : 0, async () => {
const res = await config.api.row.delete( const res = await config.api.row.delete(
@ -2347,7 +2380,9 @@ if (descriptions.length) {
!isInternal && !isInternal &&
it("should allow exporting all columns", async () => { it("should allow exporting all columns", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
const res = await config.api.row.exportRows(table._id!, { const res = await config.api.row.exportRows(table._id!, {
rows: [existing._id!], rows: [existing._id!],
}) })
@ -2363,7 +2398,9 @@ if (descriptions.length) {
}) })
it("should allow exporting without filtering", async () => { it("should allow exporting without filtering", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
const res = await config.api.row.exportRows(table._id!) const res = await config.api.row.exportRows(table._id!)
const results = JSON.parse(res) const results = JSON.parse(res)
expect(results.length).toEqual(1) expect(results.length).toEqual(1)
@ -2373,7 +2410,9 @@ if (descriptions.length) {
}) })
it("should allow exporting only certain columns", async () => { it("should allow exporting only certain columns", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
const res = await config.api.row.exportRows(table._id!, { const res = await config.api.row.exportRows(table._id!, {
rows: [existing._id!], rows: [existing._id!],
columns: ["_id"], columns: ["_id"],
@ -2388,7 +2427,9 @@ if (descriptions.length) {
}) })
it("should handle single quotes in row filtering", async () => { it("should handle single quotes in row filtering", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
const res = await config.api.row.exportRows(table._id!, { const res = await config.api.row.exportRows(table._id!, {
rows: [`['${existing._id!}']`], rows: [`['${existing._id!}']`],
}) })
@ -2399,7 +2440,9 @@ if (descriptions.length) {
}) })
it("should return an error if no table is found", async () => { it("should return an error if no table is found", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {
name: "foo",
})
await config.api.row.exportRows( await config.api.row.exportRows(
"1234567", "1234567",
{ rows: [existing._id!] }, { rows: [existing._id!] },

View File

@ -1897,7 +1897,7 @@ if (descriptions.length) {
tableOrViewId = await createTableOrView({ tableOrViewId = await createTableOrView({
product: { name: "product", type: FieldType.STRING }, product: { name: "product", type: FieldType.STRING },
ai: { ai: {
name: "AI", name: "ai",
type: FieldType.AI, type: FieldType.AI,
operation: AIOperationEnum.PROMPT, operation: AIOperationEnum.PROMPT,
prompt: "Translate '{{ product }}' into German", prompt: "Translate '{{ product }}' into German",

View File

@ -10,6 +10,7 @@ import {
SourceName, SourceName,
VirtualDocumentType, VirtualDocumentType,
LinkDocument, LinkDocument,
AIFieldMetadata,
} from "@budibase/types" } from "@budibase/types"
export { DocumentType, VirtualDocumentType } from "@budibase/types" export { DocumentType, VirtualDocumentType } from "@budibase/types"
@ -338,6 +339,10 @@ export function isRelationshipColumn(
return column.type === FieldType.LINK return column.type === FieldType.LINK
} }
export function isAIColumn(column: FieldSchema): column is AIFieldMetadata {
return column.type === FieldType.AI
}
/** /**
* Generates a new row actions ID. * Generates a new row actions ID.
* @returns The new row actions ID which the row actions doc can be stored under. * @returns The new row actions ID which the row actions doc can be stored under.

View File

@ -4,8 +4,8 @@ import {
getDatasourceParams, getDatasourceParams,
getTableParams, getTableParams,
getAutomationParams, getAutomationParams,
getScreenParams,
} from "../../../db/utils" } from "../../../db/utils"
import sdk from "../.."
async function runInContext(appId: string, cb: any, db?: Database) { async function runInContext(appId: string, cb: any, db?: Database) {
if (db) { if (db) {
@ -46,8 +46,8 @@ export async function calculateScreenCount(appId: string, db?: Database) {
return runInContext( return runInContext(
appId, appId,
async (db: Database) => { async (db: Database) => {
const screenList = await db.allDocs(getScreenParams()) const screenList = await sdk.screens.fetch(db)
return screenList.rows.length return screenList.length
}, },
db db
) )

View File

@ -375,4 +375,31 @@ describe("validate", () => {
}) })
}) })
}) })
describe("primary display", () => {
const getTable = (): Table => ({
type: "table",
_id: generateTableID(),
name: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
primaryDisplay: "foo",
schema: {
foo: {
name: "foo",
type: FieldType.STRING,
},
},
})
it("should always require primary display column", async () => {
const row = {}
const table = getTable()
const output = await validate({ source: table, row })
expect(output.valid).toBe(false)
expect(output.errors).toStrictEqual({
foo: ["can't be blank"],
})
})
})
}) })

View File

@ -206,8 +206,14 @@ export async function validate({
] ]
for (let fieldName of Object.keys(table.schema)) { for (let fieldName of Object.keys(table.schema)) {
const column = table.schema[fieldName] const column = table.schema[fieldName]
const constraints = cloneDeep(column.constraints)
const type = column.type const type = column.type
let constraints = cloneDeep(column.constraints)
// Ensure display column is required
if (table.primaryDisplay === fieldName) {
constraints = { ...constraints, presence: true }
}
// foreign keys are likely to be enriched // foreign keys are likely to be enriched
if (isForeignKey(fieldName, table)) { if (isForeignKey(fieldName, table)) {
continue continue

View File

@ -2,9 +2,7 @@ import { getScreenParams } from "../../../db/utils"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { Screen } from "@budibase/types" import { Screen } from "@budibase/types"
export async function fetch(): Promise<Screen[]> { export async function fetch(db = context.getAppDB()): Promise<Screen[]> {
const db = context.getAppDB()
return ( return (
await db.allDocs<Screen>( await db.allDocs<Screen>(
getScreenParams(null, { getScreenParams(null, {

View File

@ -17,177 +17,234 @@ import datasources from "../datasources"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { ensureQueryUISet } from "../views/utils" import { ensureQueryUISet } from "../views/utils"
import { isV2 } from "../views" import { isV2 } from "../views"
import { tracer } from "dd-trace"
export async function processTable(table: Table): Promise<Table> { export async function processTable(table: Table): Promise<Table> {
if (!table) { return await tracer.trace("processTable", async span => {
return table if (!table) {
} return table
}
table = { ...table } span.addTags({ tableId: table._id })
if (table.views) {
for (const [key, view] of Object.entries(table.views)) { table = { ...table }
if (!isV2(view)) { if (table.views) {
continue span.addTags({ numViews: Object.keys(table.views).length })
for (const [key, view] of Object.entries(table.views)) {
if (!isV2(view)) {
continue
}
table.views[key] = ensureQueryUISet(view)
} }
table.views[key] = ensureQueryUISet(view)
} }
} if (table._id && isExternalTableID(table._id)) {
if (table._id && isExternalTableID(table._id)) { span.addTags({ isExternal: true })
// Old created external tables via Budibase might have a missing field name breaking some UI such as filters // Old created external tables via Budibase might have a missing field name breaking some UI such as filters
if (table.schema["id"] && !table.schema["id"].name) { if (table.schema["id"] && !table.schema["id"].name) {
table.schema["id"].name = "id" table.schema["id"].name = "id"
}
return {
...table,
type: "table",
sourceType: TableSourceType.EXTERNAL,
}
} else {
span.addTags({ isExternal: false })
const processed: Table = {
...table,
type: "table",
primary: ["_id"], // internal tables must always use _id as primary key
sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
sql: true,
}
return processed
} }
return { })
...table,
type: "table",
sourceType: TableSourceType.EXTERNAL,
}
} else {
const processed: Table = {
...table,
type: "table",
primary: ["_id"], // internal tables must always use _id as primary key
sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
sql: true,
}
return processed
}
} }
export async function processTables(tables: Table[]): Promise<Table[]> { export async function processTables(tables: Table[]): Promise<Table[]> {
return await Promise.all(tables.map(table => processTable(table))) return await tracer.trace("processTables", async span => {
span.addTags({ numTables: tables.length })
return await Promise.all(tables.map(table => processTable(table)))
})
} }
async function processEntities(tables: Record<string, Table>) { async function processEntities(tables: Record<string, Table>) {
for (let key of Object.keys(tables)) { return await tracer.trace("processEntities", async span => {
tables[key] = await processTable(tables[key]) span.addTags({ numTables: Object.keys(tables).length })
} for (let key of Object.keys(tables)) {
return tables tables[key] = await processTable(tables[key])
}
return tables
})
} }
export async function getAllInternalTables(db?: Database): Promise<Table[]> { export async function getAllInternalTables(db?: Database): Promise<Table[]> {
if (!db) { return await tracer.trace("getAllInternalTables", async span => {
db = context.getAppDB() if (!db) {
} db = context.getAppDB()
const internalTables = await db.allDocs<Table>( }
getTableParams(null, { span.addTags({ db: db.name })
include_docs: true, const internalTables = await db.allDocs<Table>(
}) getTableParams(null, {
) include_docs: true,
return await processTables(internalTables.rows.map(row => row.doc!)) })
)
span.addTags({ numTables: internalTables.rows.length })
return await processTables(internalTables.rows.map(row => row.doc!))
})
} }
async function getAllExternalTables(): Promise<Table[]> { async function getAllExternalTables(): Promise<Table[]> {
// this is all datasources, we'll need to filter out internal return await tracer.trace("getAllExternalTables", async span => {
const datasources = await sdk.datasources.fetch({ enriched: true }) // this is all datasources, we'll need to filter out internal
const allEntities = datasources const datasources = await sdk.datasources.fetch({ enriched: true })
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID) span.addTags({ numDatasources: datasources.length })
.map(datasource => datasource.entities)
let final: Table[] = [] const allEntities = datasources
for (let entities of allEntities) { .filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
if (entities) { .map(datasource => datasource.entities)
final = final.concat(Object.values(entities)) span.addTags({ numEntities: allEntities.length })
let final: Table[] = []
for (let entities of allEntities) {
if (entities) {
final = final.concat(Object.values(entities))
}
} }
} span.addTags({ numTables: final.length })
return await processTables(final) return await processTables(final)
})
} }
export async function getExternalTable( export async function getExternalTable(
datasourceId: string, datasourceId: string,
tableName: string tableName: string
): Promise<Table> { ): Promise<Table> {
const entities = await getExternalTablesInDatasource(datasourceId) return await tracer.trace("getExternalTable", async span => {
if (!entities[tableName]) { span.addTags({ datasourceId, tableName })
throw new Error(`Unable to find table named "${tableName}"`) const entities = await getExternalTablesInDatasource(datasourceId)
} if (!entities[tableName]) {
const table = await processTable(entities[tableName]) throw new Error(`Unable to find table named "${tableName}"`)
if (!table.sourceId) { }
table.sourceId = datasourceId const table = await processTable(entities[tableName])
} if (!table.sourceId) {
return table table.sourceId = datasourceId
}
return table
})
} }
export async function getTable(tableId: string): Promise<Table> { export async function getTable(tableId: string): Promise<Table> {
const db = context.getAppDB() return await tracer.trace("getTable", async span => {
let output: Table const db = context.getAppDB()
if (tableId && isExternalTableID(tableId)) { span.addTags({ tableId, db: db.name })
let { datasourceId, tableName } = breakExternalTableId(tableId) let output: Table
const datasource = await datasources.get(datasourceId) if (tableId && isExternalTableID(tableId)) {
const table = await getExternalTable(datasourceId, tableName) let { datasourceId, tableName } = breakExternalTableId(tableId)
output = { ...table, sql: isSQL(datasource) } span.addTags({ isExternal: true, datasourceId, tableName })
} else { const datasource = await datasources.get(datasourceId)
output = await db.get<Table>(tableId) const table = await getExternalTable(datasourceId, tableName)
} output = { ...table, sql: isSQL(datasource) }
return await processTable(output) span.addTags({ isSQL: isSQL(datasource) })
} else {
output = await db.get<Table>(tableId)
}
return await processTable(output)
})
} }
export async function doesTableExist(tableId: string): Promise<boolean> { export async function doesTableExist(tableId: string): Promise<boolean> {
try { return await tracer.trace("doesTableExist", async span => {
const table = await getTable(tableId) span.addTags({ tableId })
return !!table try {
} catch (err) { const table = await getTable(tableId)
return false span.addTags({ tableExists: !!table })
} return !!table
} catch (err) {
span.addTags({ tableExists: false })
return false
}
})
} }
export async function getAllTables() { export async function getAllTables() {
const [internal, external] = await Promise.all([ return await tracer.trace("getAllTables", async span => {
getAllInternalTables(), const [internal, external] = await Promise.all([
getAllExternalTables(), getAllInternalTables(),
]) getAllExternalTables(),
return await processTables([...internal, ...external]) ])
span.addTags({
numInternalTables: internal.length,
numExternalTables: external.length,
})
return await processTables([...internal, ...external])
})
} }
export async function getExternalTablesInDatasource( export async function getExternalTablesInDatasource(
datasourceId: string datasourceId: string
): Promise<Record<string, Table>> { ): Promise<Record<string, Table>> {
const datasource = await datasources.get(datasourceId, { enriched: true }) return await tracer.trace("getExternalTablesInDatasource", async span => {
if (!datasource || !datasource.entities) { const datasource = await datasources.get(datasourceId, { enriched: true })
throw new Error("Datasource is not configured fully.") if (!datasource || !datasource.entities) {
} throw new Error("Datasource is not configured fully.")
return await processEntities(datasource.entities) }
span.addTags({
datasourceId,
numEntities: Object.keys(datasource.entities).length,
})
return await processEntities(datasource.entities)
})
} }
export async function getTables(tableIds: string[]): Promise<Table[]> { export async function getTables(tableIds: string[]): Promise<Table[]> {
const externalTableIds = tableIds.filter(tableId => return tracer.trace("getTables", async span => {
isExternalTableID(tableId) span.addTags({ numTableIds: tableIds.length })
), const externalTableIds = tableIds.filter(tableId =>
internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId)) isExternalTableID(tableId)
let tables: Table[] = [] ),
if (externalTableIds.length) { internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
const externalTables = await getAllExternalTables() let tables: Table[] = []
tables = tables.concat( if (externalTableIds.length) {
externalTables.filter( const externalTables = await getAllExternalTables()
table => externalTableIds.indexOf(table._id!) !== -1 tables = tables.concat(
externalTables.filter(
table => externalTableIds.indexOf(table._id!) !== -1
)
) )
) }
} if (internalTableIds.length) {
if (internalTableIds.length) { const db = context.getAppDB()
const db = context.getAppDB() const internalTables = await db.getMultiple<Table>(internalTableIds, {
const internalTables = await db.getMultiple<Table>(internalTableIds, { allowMissing: true,
allowMissing: true, })
}) tables = tables.concat(internalTables)
tables = tables.concat(internalTables) }
} span.addTags({ numTables: tables.length })
return await processTables(tables) return await processTables(tables)
})
} }
export async function enrichViewSchemas( export async function enrichViewSchemas(
table: Table table: Table
): Promise<FindTableResponse> { ): Promise<FindTableResponse> {
const views = [] return await tracer.trace("enrichViewSchemas", async span => {
for (const view of Object.values(table.views ?? [])) { span.addTags({ tableId: table._id })
if (sdk.views.isV2(view)) { const views = []
views.push(await sdk.views.enrichSchema(view, table.schema)) for (const view of Object.values(table.views ?? [])) {
} else views.push(view) if (sdk.views.isV2(view)) {
} views.push(await sdk.views.enrichSchema(view, table.schema))
} else views.push(view)
}
return { return {
...table, ...table,
views: views.reduce((p, v) => { views: views.reduce((p, v) => {
p[v.name!] = v p[v.name!] = v
return p return p
}, {} as TableViewsResponse), }, {} as TableViewsResponse),
} }
})
} }

View File

@ -1,21 +1,20 @@
import { AutoFieldDefaultNames } from "../../constants" import { context } from "@budibase/backend-core"
import { ai } from "@budibase/pro"
import { OperationFields } from "@budibase/shared-core"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { import {
AutoColumnFieldMetadata, AutoColumnFieldMetadata,
AutoFieldSubType,
FieldSchema, FieldSchema,
FieldType,
FormulaType,
OperationFieldTypeEnum,
Row, Row,
Table, Table,
FormulaType,
AutoFieldSubType,
FieldType,
OperationFieldTypeEnum,
AIOperationEnum,
AIFieldMetadata,
} from "@budibase/types" } from "@budibase/types"
import { OperationFields } from "@budibase/shared-core"
import tracer from "dd-trace" import tracer from "dd-trace"
import { context } from "@budibase/backend-core" import { AutoFieldDefaultNames } from "../../constants"
import { ai } from "@budibase/pro" import { isAIColumn } from "../../db/utils"
import { coerce } from "./index" import { coerce } from "./index"
interface FormulaOpts { interface FormulaOpts {
@ -122,52 +121,56 @@ export async function processAIColumns<T extends Row | Row[]>(
inputRows: T, inputRows: T,
{ contextRows }: FormulaOpts { contextRows }: FormulaOpts
): Promise<T> { ): Promise<T> {
const aiColumns = Object.values(table.schema).filter(isAIColumn)
if (!aiColumns.length) {
return inputRows
}
return tracer.trace("processAIColumns", {}, async span => { return tracer.trace("processAIColumns", {}, async span => {
const numRows = Array.isArray(inputRows) ? inputRows.length : 1 const numRows = Array.isArray(inputRows) ? inputRows.length : 1
span?.addTags({ table_id: table._id, numRows }) span?.addTags({ table_id: table._id, numRows })
const rows = Array.isArray(inputRows) ? inputRows : [inputRows] const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
const llm = await ai.getLLM() const llm = await ai.getLLM()
if (rows && llm) { if (rows && llm) {
// Ensure we have snippet context // Ensure we have snippet context
await context.ensureSnippetContext() await context.ensureSnippetContext()
for (let [column, schema] of Object.entries(table.schema)) { const aiColumns = Object.values(table.schema).filter(isAIColumn)
if (schema.type !== FieldType.AI) {
continue
}
const operation = schema.operation const rowUpdates = rows.flatMap((row, i) => {
const aiSchema: AIFieldMetadata = schema const contextRow = contextRows ? contextRows[i] : row
const rowUpdates = rows.map((row, i) => {
const contextRow = contextRows ? contextRows[i] : row return aiColumns.map(aiColumn => {
const column = aiColumn.name
// Check if the type is bindable and pass through HBS if so // Check if the type is bindable and pass through HBS if so
const operationField = OperationFields[operation as AIOperationEnum] const operationField = OperationFields[aiColumn.operation]
for (const key in schema) { for (const key in aiColumn) {
const fieldType = operationField[key as keyof typeof operationField] const fieldType = operationField[key as keyof typeof operationField]
if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) { if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) {
// @ts-ignore // @ts-expect-error: keys are not casted
schema[key] = processStringSync(schema[key], contextRow) aiColumn[key] = processStringSync(aiColumn[key], contextRow)
} }
} }
return tracer.trace("processAIColumn", {}, async span => { return tracer.trace("processAIColumn", {}, async span => {
span?.addTags({ table_id: table._id, column }) span?.addTags({ table_id: table._id, column })
const llmResponse = await llm.operation(aiSchema, row) const llmResponse = await llm.operation(aiColumn, row)
return { return {
...row, rowIndex: i,
[column]: llmResponse.message, columnName: column,
value: llmResponse.message,
} }
}) })
}) })
})
const processedRows = await Promise.all(rowUpdates) const processedAIColumns = await Promise.all(rowUpdates)
// Promise.all is deterministic so can rely on the indexing here processedAIColumns.forEach(aiColumn => {
processedRows.forEach( rows[aiColumn.rowIndex][aiColumn.columnName] = aiColumn.value
(processedRow, index) => (rows[index] = processedRow) })
)
}
} }
return Array.isArray(inputRows) ? rows : rows[0] return Array.isArray(inputRows) ? rows : rows[0]
}) })

View File

@ -323,7 +323,13 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
if (!value) { if (!value) {
return return
} }
value = new Date(value).toISOString() if (typeof value === "string") {
value = new Date(value).toISOString()
} else if (isRangeSearchOperator(operator)) {
query[operator] ??= {}
query[operator][field] = value
return query
}
} }
break break
case FieldType.NUMBER: case FieldType.NUMBER:
@ -349,7 +355,6 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
} }
break break
} }
if (isRangeSearchOperator(operator)) { if (isRangeSearchOperator(operator)) {
const key = externalType as keyof typeof SqlNumberTypeRangeMap const key = externalType as keyof typeof SqlNumberTypeRangeMap
const limits = SqlNumberTypeRangeMap[key] || { const limits = SqlNumberTypeRangeMap[key] || {
@ -637,7 +642,6 @@ export function runQuery<T extends Record<string, any>>(
if (docValue == null || docValue === "") { if (docValue == null || docValue === "") {
return false return false
} }
if (isPlainObject(testValue.low) && isEmpty(testValue.low)) { if (isPlainObject(testValue.low) && isEmpty(testValue.low)) {
testValue.low = undefined testValue.low = undefined
} }

View File

@ -1,3 +1,5 @@
import { FieldType } from "@budibase/types"
export * from "./codeEditor" export * from "./codeEditor"
export * from "./errors" export * from "./errors"
@ -83,3 +85,11 @@ export const enum ComponentContextScopes {
Local = "local", Local = "local",
Global = "global", Global = "global",
} }
export type FilterConfig = {
active: boolean
field: string
label?: string
_id?: string
columnType?: FieldType
}