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",
"version": "3.9.5",
"version": "3.10.1",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

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

View File

@ -7,6 +7,8 @@
export let type = "number"
$: style = width ? `width:${width}px;` : ""
const selectAll = event => event.target.select()
</script>
<input
@ -16,7 +18,7 @@
{value}
{min}
{max}
onclick="this.select()"
on:click={selectAll}
on:change
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 Icon from "../../Icon/Icon.svelte"
import { parseDate } from "../../helpers"
import { writable } from "svelte/store"
let fromDate
let toDate
export let enableTime: boolean | undefined = false
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>
<div class="date-range">
<CoreDatePicker
value={fromDate}
on:change={e => (fromDate = e.detail)}
enableTime={false}
value={parsedFrom}
on:change={e => onChangeFrom(e.detail)}
{enableTime}
{timeOnly}
{ignoreTimezones}
/>
<div class="arrow">
<Icon name="ChevronRight" />
</div>
<CoreDatePicker
value={toDate}
on:change={e => (toDate = e.detail)}
enableTime={false}
value={parsedTo}
on:change={e => onChangeTo(e.detail)}
{enableTime}
{timeOnly}
{ignoreTimezones}
/>
</div>

View File

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

View File

@ -11,7 +11,7 @@
export let hoverColor: string | undefined = undefined
export let tooltip: string | undefined = undefined
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 tooltipWrap: boolean = true
export let newStyles: boolean = false

View File

@ -1,7 +1,7 @@
<script lang="ts">
export let horizontal: boolean = false
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 gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M"
export let noGap: boolean = false

View File

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

View File

@ -1,3 +1,4 @@
export * from "./actionMenu"
export * from "./envDropdown"
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 value: string | null = ""
export let expandedOnly: boolean = false
export let parentWidth: number | null = null
const dispatch = createEventDispatcher<{
update: { code: string }
accept: void
@ -26,11 +26,11 @@
const thresholdExpansionWidth = 350
$: expanded =
$: shouldAlwaysBeExpanded =
expandedOnly ||
(parentWidth !== null && parentWidth > thresholdExpansionWidth)
? true
: expanded
$: expanded = shouldAlwaysBeExpanded || expanded
async function generateJs(prompt: string) {
promptText = ""
@ -78,15 +78,17 @@
prompt: promptText,
})
dispatch("reject", { code: previousContents })
reset()
reset(false)
}
function reset() {
function reset(clearPrompt: boolean = true) {
if (clearPrompt) {
promptText = ""
inputValue = ""
}
suggestedCode = null
previousContents = null
promptText = ""
expanded = false
inputValue = ""
}
</script>
@ -108,7 +110,7 @@
bind:expanded
bind:value={inputValue}
readonly={!!suggestedCode}
{expandedOnly}
expandedOnly={shouldAlwaysBeExpanded}
/>
</div>

View File

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

View File

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

View File

@ -1,46 +1,46 @@
<script>
<script lang="ts">
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core"
import type { UIUser } from "@budibase/types"
export let icon
export let iconTooltip
export let withArrow = false
export let withActions = true
export let showActions = false
export let indentLevel = 0
export let text
export let border = true
export let selected = false
export let opened = false
export let draggable = false
export let iconText
export let iconColor
export let scrollable = false
export let highlighted = false
export let rightAlignIcon = false
export let id
export let showTooltip = false
export let selectedBy = null
export let compact = false
export let hovering = false
export let disabled = false
export let icon: string | null
export let iconTooltip: string = ""
export let withArrow: boolean = false
export let withActions: boolean = true
export let showActions: boolean = false
export let indentLevel: number = 0
export let text: string
export let border: boolean = true
export let selected: boolean = false
export let opened: boolean = false
export let draggable: boolean = false
export let iconText: string = ""
export let iconColor: string = ""
export let scrollable: boolean = false
export let highlighted: boolean = false
export let rightAlignIcon: boolean = false
export let id: string = ""
export let showTooltip: boolean = false
export let selectedBy: UIUser[] | null = null
export let compact: boolean = false
export let hovering: boolean = false
export let disabled: boolean = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
let contentRef
let contentRef: HTMLDivElement
$: selected && contentRef && scrollToView()
$: style = getStyle(indentLevel, selectedBy)
$: style = getStyle(indentLevel)
const onClick = () => {
scrollToView()
dispatch("click")
}
const onIconClick = e => {
const onIconClick = (e: Event) => {
e.stopPropagation()
dispatch("iconClick")
}
@ -53,11 +53,8 @@
scrollApi.scrollTo(bounds)
}
const getStyle = (indentLevel, selectedBy) => {
const getStyle = (indentLevel: number) => {
let style = `padding-left:calc(${indentLevel * 14}px);`
if (selectedBy) {
style += `--selected-by-color:${helpers.getUserColor(selectedBy)};`
}
return style
}
</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 const submit = onPromptSubmit
$: expanded = expandedOnly || expanded
const dispatch = createEventDispatcher()
let promptInput: HTMLInputElement
@ -22,6 +21,7 @@
let switchOnAIModal: Modal
let addCreditsModal: Modal
$: expanded = expandedOnly || expanded
$: accountPortalAccess = $auth?.user?.accountPortalAccess
$: accountPortal = $admin.accountPortalUrl
$: aiEnabled = $auth?.user?.llm
@ -92,9 +92,12 @@
class="ai-icon"
class:loading={promptLoading}
class:disabled={expanded && disabled}
class:no-toggle={expandedOnly}
on:click={e => {
e.stopPropagation()
toggleExpand()
if (!expandedOnly) {
e.stopPropagation()
toggleExpand()
}
}}
/>
{#if expanded}
@ -290,6 +293,10 @@
z-index: 2;
}
.ai-icon.no-toggle {
cursor: default;
}
.ai-gen-text {
white-space: nowrap;
overflow: hidden;

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@
export let listTypeProps = {}
export let listItemKey
export let draggable = true
export let focus
export let focus = undefined
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 FieldSetting from "./FieldSetting.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"
export let value

View File

@ -1,4 +1,4 @@
const modernize = columns => {
export const modernize = columns => {
if (!columns) {
return []
}
@ -14,9 +14,9 @@ const modernize = columns => {
return columns
}
const removeInvalidAddMissing = (
export const removeInvalidAddMissing = (
columns = [],
defaultColumns,
defaultColumns = [],
primaryDisplayColumnName
) => {
const defaultColumnNames = defaultColumns.map(column => column.field)
@ -47,7 +47,7 @@ const removeInvalidAddMissing = (
return combinedColumns
}
const getDefault = (schema = {}) => {
export const getDefault = (schema = {}) => {
const defaultValues = Object.values(schema)
.filter(column => !column.nestedJSON)
.map(column => ({
@ -93,7 +93,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
})
}
const getColumns = ({
export const getColumns = ({
columns,
schema,
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 getColumns from "./getColumns"
import { getColumns } from "./getColumns"
describe("getColumns", () => {
beforeEach(ctx => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,23 @@
import { it, expect, describe, vi } from "vitest"
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 { notifications } from "@budibase/bbui"
import { API } from "@/api"
vi.spyOn(notifications, "error").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 = {
Cloud: "cloud",
@ -15,7 +27,6 @@ const Hosting = {
function setupEnv(hosting, features = {}, flags = {}) {
const defaultFeatures = {
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
...features,
}
const defaultFlags = {
@ -41,33 +52,123 @@ describe("AISettings", () => {
let instance = null
const setupDOM = () => {
instance = render(AISettings, {})
instance = render(AISettings)
const modalContainer = document.createElement("div")
modalContainer.classList.add("modal-container")
instance.baseElement.appendChild(modalContainer)
}
beforeEach(() => {
setupEnv(Hosting.Self)
})
afterEach(() => {
vi.restoreAllMocks()
})
it("that the AISettings is rendered", () => {
setupDOM()
expect(instance).toBeDefined()
describe("Basic rendering", () => {
it("should render the AI header", async () => {
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", () => {
it("the enable bb ai button should not do anything if the user doesn't have the correct license on self host", async () => {
let addAiButton
let configModal
describe("Provider rendering", () => {
it("should display active provider with active status tag", async () => {
API.getConfig.mockResolvedValueOnce({
config: {
BudibaseAI: {
provider: "BudibaseAI",
active: true,
isDefault: true,
name: "Budibase AI",
},
},
})
setupEnv(Hosting.Self, { customAIConfigsEnabled: false })
setupDOM()
addAiButton = instance.queryByText("Enable BB AI")
expect(addAiButton).toBeInTheDocument()
await fireEvent.click(addAiButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).not.toBeInTheDocument()
await waitFor(() => {
const providerName = instance.getByText("Budibase AI")
expect(providerName).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>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="header">
<Heading size="M">AI</Heading>
</div>
<Body>
Connect an LLM to enable AI features. You can only enable one LLM at a
time.
</Body>
</Layout>
<Divider />
{#if aiConfig}
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="header">
<Heading size="M">AI</Heading>
</div>
<Body>
Connect an LLM to enable AI features. You can only enable one LLM at a
time.
</Body>
</Layout>
<Divider />
{#if !activeProvider && !$bannerStore}
<div class="banner">
<div class="banner-content">
<div class="banner-icon">
<img src={BBAI} alt="BB AI" width="24" height="24" />
{#if !activeProvider && !$bannerStore}
<div class="banner">
<div class="banner-content">
<div class="banner-icon">
<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>Try BB AI for free. 50,000 tokens included. No CC required.</div>
</div>
<div class="banner-buttons">
<Button
primary
cta
size="S"
on:click={() => handleEnable("BudibaseAI")}
>
Enable BB AI
</Button>
<Icon
hoverable
name="Close"
on:click={() => {
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)}
<div class="banner-buttons">
<Button
primary
cta
size="S"
on:click={() => handleEnable("BudibaseAI")}
>
Enable BB AI
</Button>
<Icon
hoverable
name="Close"
on:click={() => {
setBannerLocalStorageKey()
bannerStore.set(true)
}}
/>
{/each}
</div>
</div>
{/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}>
<PortalModal

View File

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

View File

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

View File

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

View File

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

View File

@ -5204,7 +5204,7 @@
"icon": "Data",
"illegalChildren": ["section"],
"hasChildren": true,
"actions": ["RefreshDatasource"],
"actions": ["RefreshDatasource", "AddDataProviderQueryExtension"],
"size": {
"width": 500,
"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": {
"deprecated": true,
"name": "Dynamic Filter",
"icon": "Filter",
"size": {
@ -7671,7 +7723,8 @@
"setting": "table.type",
"value": "custom",
"invert": true
}
},
"resetOn": "table"
},
{
"type": "field/sortable",
@ -7823,7 +7876,7 @@
]
}
],
"actions": ["RefreshDatasource"]
"actions": ["RefreshDatasource", "AddDataProviderFilterExtension"]
},
"bbreferencefield": {
"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,
sidePanelStore,
modalStore,
dataSourceStore,
} from "@/stores"
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
@ -47,11 +48,18 @@
import SnippetsProvider from "./context/SnippetsProvider.svelte"
import EmbedProvider from "./context/EmbedProvider.svelte"
import DNDSelectionIndicators from "./preview/DNDSelectionIndicators.svelte"
import { ActionTypes } from "@/constants"
// Provide contexts
const context = createContextStore()
setContext("sdk", SDK)
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 permissionError = false

View File

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

View File

@ -6,6 +6,7 @@
import { featuresStore } from "@/stores"
import { Grid } from "@budibase/frontend-core"
import { processStringSync } from "@budibase/string-templates"
import { UILogicalOperator, EmptyFilterOption } from "@budibase/types"
// table is actually any datasource, but called table for legacy compatibility
export let table
@ -43,6 +44,8 @@
let gridContext
let minHeight = 0
let filterExtensions = {}
$: id = $component.id
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
@ -51,15 +54,73 @@
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
$: selectedRows = deriveSelectedRows(gridContext)
$: styles = patchStyles($component.styles, minHeight)
$: data = { selectedRows: $selectedRows }
$: rowMap = gridContext?.rowLookupMap
$: data = {
selectedRows: $selectedRows,
embeddedData: {
dataSource: table,
componentId: $component.id,
loaded: !!$rowMap,
},
}
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => gridContext?.rows.actions.refreshData(),
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
export const getAdditionalDataContext = () => {
const gridContext = grid?.getContext()
@ -197,7 +258,7 @@
{stripeRows}
{quiet}
{darkMode}
{initialFilter}
initialFilter={extendedFilter}
{initialSortColumn}
{initialSortOrder}
{fixedRowHeight}

View File

@ -42,13 +42,15 @@
const goToPortal = () => {
window.location.href = isBuilder ? "/builder/portal/apps" : "/builder/apps"
}
$: user = $authStore as User
</script>
{#if $authStore}
<ActionMenu align={compact ? "right" : "left"}>
<svelte:fragment slot="control">
<div class="container">
<UserAvatar user={$authStore} size="M" showTooltip={false} />
<UserAvatar {user} size="M" showTooltip={false} />
{#if !compact}
<div class="text">
<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">
import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import { FieldType, type Row } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue: string
export let type = FieldType.BB_REFERENCE
export let multi: boolean | undefined = undefined
export let defaultRows: Row[] | undefined = []
function updateUserIDs(value: string | string[]) {
if (Array.isArray(value)) {
@ -33,4 +35,7 @@
datasourceType={"user"}
primaryDisplay={"email"}
defaultValue={updatedDefaultValue}
{defaultRows}
{multi}
on:rows
/>

View File

@ -14,13 +14,15 @@
type LegacyFilter,
type SearchFilterGroup,
type UISearchFilter,
type RelationshipFieldMetadata,
type Row,
} from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import type { RelationshipFieldMetadata, Row } from "@budibase/types"
import type { FieldApi, FieldState, FieldValidation } from "@/types"
import { utils } from "@budibase/shared-core"
import { createEventDispatcher } from "svelte"
export let field: string | undefined = undefined
export let label: string | undefined = undefined
@ -30,7 +32,7 @@
export let validation: FieldValidation | undefined = undefined
export let autocomplete: boolean = true
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 datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined
@ -41,8 +43,14 @@
| FieldType.BB_REFERENCE
| 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 dispatch = createEventDispatcher()
// Field state
let fieldState: FieldState<string | string[]> | undefined
let fieldApi: FieldApi
@ -59,11 +67,11 @@
// Reset the available options when our base filter changes
$: filter, (optionsMap = {})
// Determine if we can select multiple rows or not
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
multi ??
([FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many")
// Get the proper string representation of the value
$: realValue = fieldState?.value as ValueType
@ -71,7 +79,7 @@
$: selectedIDs = getSelectedIDs(selectedValue)
// If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId
$: linkedTableId = tableId ?? fieldSchema?.tableId
$: writable = !disabled && !readonly
$: migratedFilter = migrateFilter(filter)
$: fetch = createFetch(
@ -87,7 +95,13 @@
// Build our options map
$: 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
// 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 gridblock } from "./GridBlock.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 singlerowprovider } from "./SingleRowProvider.svelte"
export * from "./charts"

View File

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

View File

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

View File

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

View File

@ -115,9 +115,18 @@ export const createDataSourceStore = () => {
})
}
const refreshAll = () => {
get(store).forEach(instance => instance.refresh())
}
return {
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 { peekStore } from "./peek"
export { stateStore } from "./state"
export { uiStateStore } from "./uiState"
export { themeStore } from "./theme"
export { devToolsStore } from "./devTools"
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"
export * from "./components"
@ -5,3 +6,10 @@ export * from "./fields"
export * from "./forms"
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}`] = {
type: linkSchema[linkKey].type,
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 { helpers } from "@budibase/shared-core"
import type { User } from "@budibase/types"
export let user
export let size = "S"
export let tooltipPosition = TooltipPosition.Top
export let showTooltip = true
export let user: User
export let size: "XS" | "S" | "M" = "S"
export let tooltipPosition: TooltipPosition = TooltipPosition.Top
export let showTooltip: boolean = true
</script>
{#if user}
<AbsTooltip
text={showTooltip ? helpers.getUserLabel(user) : null}
text={showTooltip ? helpers.getUserLabel(user) : ""}
position={tooltipPosition}
color={helpers.getUserColor(user)}
>

View File

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

View File

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

View File

@ -1,13 +1,13 @@
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 {
DeleteLayoutResponse,
Layout,
SaveLayoutRequest,
SaveLayoutResponse,
UserCtx,
} from "@budibase/types"
import sdk from "../../sdk"
export async function save(
ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse>
@ -36,13 +36,9 @@ export async function destroy(ctx: UserCtx<void, DeleteLayoutResponse>) {
const layoutId = ctx.params.layoutId,
layoutRev = ctx.params.layoutRev
const layoutsUsedByScreens = (
await db.allDocs<Layout>(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map(element => element.doc!.layoutId)
const layoutsUsedByScreens = (await sdk.screens.fetch()).map(
element => element.layoutId
)
if (layoutsUsedByScreens.includes(layoutId)) {
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", () => {
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!)
@ -930,7 +932,7 @@ if (descriptions.length) {
it("returns 404 when row does not exist", async () => {
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", {
status: 404,
})
@ -958,8 +960,8 @@ if (descriptions.length) {
it("fetches all rows for given tableId", async () => {
const table = await config.api.table.save(defaultTable())
const rows = await Promise.all([
config.api.row.save(table._id!, {}),
config.api.row.save(table._id!, {}),
config.api.row.save(table._id!, { name: "foo" }),
config.api.row.save(table._id!, { name: "bar" }),
])
const res = await config.api.row.fetch(table._id!)
@ -975,7 +977,9 @@ if (descriptions.length) {
describe("update", () => {
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 () => {
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 () => {
const existing = await config.api.row.save(table._id!, {})
const existing = await config.api.row.save(table._id!, {
name: "foo",
})
await expectRowUsage(0, async () => {
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 () => {
let beforeRow = await config.api.row.save(table._id!, {
name: "test",
@ -1213,7 +1235,9 @@ if (descriptions.length) {
})
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 config.api.row.patch(
@ -1289,6 +1313,7 @@ if (descriptions.length) {
description: "test",
})
const { _id } = await config.api.row.save(table._id!, {
name: "test",
relationship: [{ _id: row._id }, { _id: row2._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 () => {
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 () => {
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 () => {
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 () => {
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 () => {
const createdRow = await config.api.row.save(table._id!, {})
const createdRow2 = await config.api.row.save(table._id!, {})
const createdRow = 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!, {
rows: [createdRow, createdRow2, { _id: "9999999" }],
@ -1581,8 +1614,8 @@ if (descriptions.length) {
})
it("should be able to delete a bulk set of rows", async () => {
const row1 = await config.api.row.save(table._id!, {})
const row2 = 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!, { name: "bar" })
await expectRowUsage(isInternal ? -2 : 0, async () => {
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 () => {
const [row1, row2, row3] = await Promise.all([
config.api.row.save(table._id!, {}),
config.api.row.save(table._id!, {}),
config.api.row.save(table._id!, {}),
config.api.row.save(table._id!, { name: "foo" }),
config.api.row.save(table._id!, { name: "bar" }),
config.api.row.save(table._id!, { name: "baz" }),
])
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 () => {
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 () => {
const res = await config.api.row.delete(
@ -2347,7 +2380,9 @@ if (descriptions.length) {
!isInternal &&
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!, {
rows: [existing._id!],
})
@ -2363,7 +2398,9 @@ if (descriptions.length) {
})
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 results = JSON.parse(res)
expect(results.length).toEqual(1)
@ -2373,7 +2410,9 @@ if (descriptions.length) {
})
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!, {
rows: [existing._id!],
columns: ["_id"],
@ -2388,7 +2427,9 @@ if (descriptions.length) {
})
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!, {
rows: [`['${existing._id!}']`],
})
@ -2399,7 +2440,9 @@ if (descriptions.length) {
})
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(
"1234567",
{ rows: [existing._id!] },

View File

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

View File

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

View File

@ -4,8 +4,8 @@ import {
getDatasourceParams,
getTableParams,
getAutomationParams,
getScreenParams,
} from "../../../db/utils"
import sdk from "../.."
async function runInContext(appId: string, cb: any, db?: Database) {
if (db) {
@ -46,8 +46,8 @@ export async function calculateScreenCount(appId: string, db?: Database) {
return runInContext(
appId,
async (db: Database) => {
const screenList = await db.allDocs(getScreenParams())
return screenList.rows.length
const screenList = await sdk.screens.fetch(db)
return screenList.length
},
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)) {
const column = table.schema[fieldName]
const constraints = cloneDeep(column.constraints)
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
if (isForeignKey(fieldName, table)) {
continue

View File

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

View File

@ -17,177 +17,234 @@ import datasources from "../datasources"
import sdk from "../../../sdk"
import { ensureQueryUISet } from "../views/utils"
import { isV2 } from "../views"
import { tracer } from "dd-trace"
export async function processTable(table: Table): Promise<Table> {
if (!table) {
return table
}
return await tracer.trace("processTable", async span => {
if (!table) {
return table
}
table = { ...table }
if (table.views) {
for (const [key, view] of Object.entries(table.views)) {
if (!isV2(view)) {
continue
span.addTags({ tableId: table._id })
table = { ...table }
if (table.views) {
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)) {
// 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) {
table.schema["id"].name = "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
if (table.schema["id"] && !table.schema["id"].name) {
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[]> {
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>) {
for (let key of Object.keys(tables)) {
tables[key] = await processTable(tables[key])
}
return tables
return await tracer.trace("processEntities", async span => {
span.addTags({ numTables: Object.keys(tables).length })
for (let key of Object.keys(tables)) {
tables[key] = await processTable(tables[key])
}
return tables
})
}
export async function getAllInternalTables(db?: Database): Promise<Table[]> {
if (!db) {
db = context.getAppDB()
}
const internalTables = await db.allDocs<Table>(
getTableParams(null, {
include_docs: true,
})
)
return await processTables(internalTables.rows.map(row => row.doc!))
return await tracer.trace("getAllInternalTables", async span => {
if (!db) {
db = context.getAppDB()
}
span.addTags({ db: db.name })
const internalTables = await db.allDocs<Table>(
getTableParams(null, {
include_docs: true,
})
)
span.addTags({ numTables: internalTables.rows.length })
return await processTables(internalTables.rows.map(row => row.doc!))
})
}
async function getAllExternalTables(): Promise<Table[]> {
// this is all datasources, we'll need to filter out internal
const datasources = await sdk.datasources.fetch({ enriched: true })
const allEntities = datasources
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
.map(datasource => datasource.entities)
let final: Table[] = []
for (let entities of allEntities) {
if (entities) {
final = final.concat(Object.values(entities))
return await tracer.trace("getAllExternalTables", async span => {
// this is all datasources, we'll need to filter out internal
const datasources = await sdk.datasources.fetch({ enriched: true })
span.addTags({ numDatasources: datasources.length })
const allEntities = datasources
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
.map(datasource => datasource.entities)
span.addTags({ numEntities: allEntities.length })
let final: Table[] = []
for (let entities of allEntities) {
if (entities) {
final = final.concat(Object.values(entities))
}
}
}
return await processTables(final)
span.addTags({ numTables: final.length })
return await processTables(final)
})
}
export async function getExternalTable(
datasourceId: string,
tableName: string
): Promise<Table> {
const entities = await getExternalTablesInDatasource(datasourceId)
if (!entities[tableName]) {
throw new Error(`Unable to find table named "${tableName}"`)
}
const table = await processTable(entities[tableName])
if (!table.sourceId) {
table.sourceId = datasourceId
}
return table
return await tracer.trace("getExternalTable", async span => {
span.addTags({ datasourceId, tableName })
const entities = await getExternalTablesInDatasource(datasourceId)
if (!entities[tableName]) {
throw new Error(`Unable to find table named "${tableName}"`)
}
const table = await processTable(entities[tableName])
if (!table.sourceId) {
table.sourceId = datasourceId
}
return table
})
}
export async function getTable(tableId: string): Promise<Table> {
const db = context.getAppDB()
let output: Table
if (tableId && isExternalTableID(tableId)) {
let { datasourceId, tableName } = breakExternalTableId(tableId)
const datasource = await datasources.get(datasourceId)
const table = await getExternalTable(datasourceId, tableName)
output = { ...table, sql: isSQL(datasource) }
} else {
output = await db.get<Table>(tableId)
}
return await processTable(output)
return await tracer.trace("getTable", async span => {
const db = context.getAppDB()
span.addTags({ tableId, db: db.name })
let output: Table
if (tableId && isExternalTableID(tableId)) {
let { datasourceId, tableName } = breakExternalTableId(tableId)
span.addTags({ isExternal: true, datasourceId, tableName })
const datasource = await datasources.get(datasourceId)
const table = await getExternalTable(datasourceId, tableName)
output = { ...table, sql: isSQL(datasource) }
span.addTags({ isSQL: isSQL(datasource) })
} else {
output = await db.get<Table>(tableId)
}
return await processTable(output)
})
}
export async function doesTableExist(tableId: string): Promise<boolean> {
try {
const table = await getTable(tableId)
return !!table
} catch (err) {
return false
}
return await tracer.trace("doesTableExist", async span => {
span.addTags({ tableId })
try {
const table = await getTable(tableId)
span.addTags({ tableExists: !!table })
return !!table
} catch (err) {
span.addTags({ tableExists: false })
return false
}
})
}
export async function getAllTables() {
const [internal, external] = await Promise.all([
getAllInternalTables(),
getAllExternalTables(),
])
return await processTables([...internal, ...external])
return await tracer.trace("getAllTables", async span => {
const [internal, external] = await Promise.all([
getAllInternalTables(),
getAllExternalTables(),
])
span.addTags({
numInternalTables: internal.length,
numExternalTables: external.length,
})
return await processTables([...internal, ...external])
})
}
export async function getExternalTablesInDatasource(
datasourceId: string
): Promise<Record<string, Table>> {
const datasource = await datasources.get(datasourceId, { enriched: true })
if (!datasource || !datasource.entities) {
throw new Error("Datasource is not configured fully.")
}
return await processEntities(datasource.entities)
return await tracer.trace("getExternalTablesInDatasource", async span => {
const datasource = await datasources.get(datasourceId, { enriched: true })
if (!datasource || !datasource.entities) {
throw new Error("Datasource is not configured fully.")
}
span.addTags({
datasourceId,
numEntities: Object.keys(datasource.entities).length,
})
return await processEntities(datasource.entities)
})
}
export async function getTables(tableIds: string[]): Promise<Table[]> {
const externalTableIds = tableIds.filter(tableId =>
isExternalTableID(tableId)
),
internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
let tables: Table[] = []
if (externalTableIds.length) {
const externalTables = await getAllExternalTables()
tables = tables.concat(
externalTables.filter(
table => externalTableIds.indexOf(table._id!) !== -1
return tracer.trace("getTables", async span => {
span.addTags({ numTableIds: tableIds.length })
const externalTableIds = tableIds.filter(tableId =>
isExternalTableID(tableId)
),
internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
let tables: Table[] = []
if (externalTableIds.length) {
const externalTables = await getAllExternalTables()
tables = tables.concat(
externalTables.filter(
table => externalTableIds.indexOf(table._id!) !== -1
)
)
)
}
if (internalTableIds.length) {
const db = context.getAppDB()
const internalTables = await db.getMultiple<Table>(internalTableIds, {
allowMissing: true,
})
tables = tables.concat(internalTables)
}
return await processTables(tables)
}
if (internalTableIds.length) {
const db = context.getAppDB()
const internalTables = await db.getMultiple<Table>(internalTableIds, {
allowMissing: true,
})
tables = tables.concat(internalTables)
}
span.addTags({ numTables: tables.length })
return await processTables(tables)
})
}
export async function enrichViewSchemas(
table: Table
): Promise<FindTableResponse> {
const views = []
for (const view of Object.values(table.views ?? [])) {
if (sdk.views.isV2(view)) {
views.push(await sdk.views.enrichSchema(view, table.schema))
} else views.push(view)
}
return await tracer.trace("enrichViewSchemas", async span => {
span.addTags({ tableId: table._id })
const views = []
for (const view of Object.values(table.views ?? [])) {
if (sdk.views.isV2(view)) {
views.push(await sdk.views.enrichSchema(view, table.schema))
} else views.push(view)
}
return {
...table,
views: views.reduce((p, v) => {
p[v.name!] = v
return p
}, {} as TableViewsResponse),
}
return {
...table,
views: views.reduce((p, v) => {
p[v.name!] = v
return p
}, {} 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 {
AutoColumnFieldMetadata,
AutoFieldSubType,
FieldSchema,
FieldType,
FormulaType,
OperationFieldTypeEnum,
Row,
Table,
FormulaType,
AutoFieldSubType,
FieldType,
OperationFieldTypeEnum,
AIOperationEnum,
AIFieldMetadata,
} from "@budibase/types"
import { OperationFields } from "@budibase/shared-core"
import tracer from "dd-trace"
import { context } from "@budibase/backend-core"
import { ai } from "@budibase/pro"
import { AutoFieldDefaultNames } from "../../constants"
import { isAIColumn } from "../../db/utils"
import { coerce } from "./index"
interface FormulaOpts {
@ -122,52 +121,56 @@ export async function processAIColumns<T extends Row | Row[]>(
inputRows: T,
{ contextRows }: FormulaOpts
): Promise<T> {
const aiColumns = Object.values(table.schema).filter(isAIColumn)
if (!aiColumns.length) {
return inputRows
}
return tracer.trace("processAIColumns", {}, async span => {
const numRows = Array.isArray(inputRows) ? inputRows.length : 1
span?.addTags({ table_id: table._id, numRows })
const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
const llm = await ai.getLLM()
if (rows && llm) {
// Ensure we have snippet context
await context.ensureSnippetContext()
for (let [column, schema] of Object.entries(table.schema)) {
if (schema.type !== FieldType.AI) {
continue
}
const aiColumns = Object.values(table.schema).filter(isAIColumn)
const operation = schema.operation
const aiSchema: AIFieldMetadata = schema
const rowUpdates = rows.map((row, i) => {
const contextRow = contextRows ? contextRows[i] : row
const rowUpdates = rows.flatMap((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
const operationField = OperationFields[operation as AIOperationEnum]
for (const key in schema) {
const operationField = OperationFields[aiColumn.operation]
for (const key in aiColumn) {
const fieldType = operationField[key as keyof typeof operationField]
if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) {
// @ts-ignore
schema[key] = processStringSync(schema[key], contextRow)
// @ts-expect-error: keys are not casted
aiColumn[key] = processStringSync(aiColumn[key], contextRow)
}
}
return tracer.trace("processAIColumn", {}, async span => {
span?.addTags({ table_id: table._id, column })
const llmResponse = await llm.operation(aiSchema, row)
const llmResponse = await llm.operation(aiColumn, row)
return {
...row,
[column]: llmResponse.message,
rowIndex: i,
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
processedRows.forEach(
(processedRow, index) => (rows[index] = processedRow)
)
}
processedAIColumns.forEach(aiColumn => {
rows[aiColumn.rowIndex][aiColumn.columnName] = aiColumn.value
})
}
return Array.isArray(inputRows) ? rows : rows[0]
})

View File

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

View File

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