Merge branch 'master' of github.com:Budibase/budibase into remove-tour
This commit is contained in:
commit
ce2c06684f
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./actionMenu"
|
||||
export * from "./envDropdown"
|
||||
export * from "./modalContext"
|
||||
export * from "./scrollContext"
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export interface ScrollContext {
|
||||
scrollTo: (bounds: DOMRect) => void
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -11,7 +11,10 @@
|
|||
$componentStore.selectedComponentId,
|
||||
"RefreshDatasource",
|
||||
{ includeSelf: nested }
|
||||
)
|
||||
).concat({
|
||||
readableBinding: "All data providers",
|
||||
runtimeBinding: "all",
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
export let listTypeProps = {}
|
||||
export let listItemKey
|
||||
export let draggable = true
|
||||
export let focus
|
||||
export let focus = undefined
|
||||
|
||||
let zoneType = generate()
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 />
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { it, expect, describe, beforeEach, vi } from "vitest"
|
||||
import getColumns from "./getColumns"
|
||||
import { getColumns } from "./getColumns"
|
||||
|
||||
describe("getColumns", () => {
|
||||
beforeEach(ctx => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"pdftable",
|
||||
"spreadsheet",
|
||||
"dynamicfilter",
|
||||
"filter",
|
||||
"daterangepicker"
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -100,6 +100,7 @@
|
|||
},
|
||||
limit,
|
||||
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
|
||||
loaded: $fetch.loaded,
|
||||
}
|
||||
|
||||
const createFetch = (datasource: ProviderDatasource) => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -6,6 +6,8 @@ export const ActionTypes = {
|
|||
RefreshDatasource: "RefreshDatasource",
|
||||
AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
|
||||
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
|
||||
AddDataProviderFilterExtension: "AddDataProviderFilterExtension",
|
||||
RemoveDataProviderFilterExtension: "RemoveDataProviderFilterExtension",
|
||||
SetDataProviderSorting: "SetDataProviderSorting",
|
||||
ClearForm: "ClearForm",
|
||||
ChangeFormStep: "ChangeFormStep",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -121,6 +121,7 @@ export const getRelationshipSchemaAdditions = async (
|
|||
relationshipAdditions[`${fieldKey}.${linkKey}`] = {
|
||||
type: linkSchema[linkKey].type,
|
||||
externalType: linkSchema[linkKey].externalType,
|
||||
constraints: linkSchema[linkKey].constraints,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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!] },
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue