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",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "3.9.5",
|
"version": "3.10.1",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"concurrency": 20,
|
"concurrency": 20,
|
||||||
"command": {
|
"command": {
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
export let error = null
|
export let error = null
|
||||||
export let enableTime = true
|
export let enableTime = true
|
||||||
export let value = null
|
export let value = undefined
|
||||||
export let placeholder = null
|
export let placeholder = null
|
||||||
export let timeOnly = false
|
export let timeOnly = false
|
||||||
export let ignoreTimezones = false
|
export let ignoreTimezones = false
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
export let type = "number"
|
export let type = "number"
|
||||||
|
|
||||||
$: style = width ? `width:${width}px;` : ""
|
$: style = width ? `width:${width}px;` : ""
|
||||||
|
|
||||||
|
const selectAll = event => event.target.select()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
@ -16,7 +18,7 @@
|
||||||
{value}
|
{value}
|
||||||
{min}
|
{min}
|
||||||
{max}
|
{max}
|
||||||
onclick="this.select()"
|
on:click={selectAll}
|
||||||
on:change
|
on:change
|
||||||
on:input
|
on:input
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,24 +1,87 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import dayjs, { type Dayjs } from "dayjs"
|
||||||
|
import { createEventDispatcher } from "svelte"
|
||||||
import CoreDatePicker from "./DatePicker/DatePicker.svelte"
|
import CoreDatePicker from "./DatePicker/DatePicker.svelte"
|
||||||
import Icon from "../../Icon/Icon.svelte"
|
import Icon from "../../Icon/Icon.svelte"
|
||||||
|
import { parseDate } from "../../helpers"
|
||||||
|
import { writable } from "svelte/store"
|
||||||
|
|
||||||
let fromDate
|
export let enableTime: boolean | undefined = false
|
||||||
let toDate
|
export let timeOnly: boolean | undefined = false
|
||||||
|
export let ignoreTimezones: boolean | undefined = false
|
||||||
|
export let value: string[] | undefined = []
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
const valueStore = writable<string[]>()
|
||||||
|
|
||||||
|
let fromDate: Dayjs | null
|
||||||
|
let toDate: Dayjs | null
|
||||||
|
|
||||||
|
$: valueStore.set(value || [])
|
||||||
|
$: parseValue($valueStore)
|
||||||
|
|
||||||
|
$: parsedFrom = fromDate ? parseDate(fromDate, { enableTime }) : undefined
|
||||||
|
$: parsedTo = toDate ? parseDate(toDate, { enableTime }) : undefined
|
||||||
|
|
||||||
|
const parseValue = (value: string[]) => {
|
||||||
|
if (!Array.isArray(value) || !value[0] || !value[1]) {
|
||||||
|
fromDate = null
|
||||||
|
toDate = null
|
||||||
|
} else {
|
||||||
|
fromDate = dayjs(value[0])
|
||||||
|
toDate = dayjs(value[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeFrom = (utc: string) => {
|
||||||
|
// Preserve the time if its editable
|
||||||
|
const fromDate = utc
|
||||||
|
? enableTime
|
||||||
|
? dayjs(utc)
|
||||||
|
: dayjs(utc).startOf("day")
|
||||||
|
: null
|
||||||
|
if (fromDate && (!toDate || fromDate.isAfter(toDate))) {
|
||||||
|
toDate = !enableTime ? fromDate.endOf("day") : fromDate
|
||||||
|
} else if (!fromDate) {
|
||||||
|
toDate = null
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch("change", [fromDate, toDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeTo = (utc: string) => {
|
||||||
|
// Preserve the time if its editable
|
||||||
|
const toDate = utc
|
||||||
|
? enableTime
|
||||||
|
? dayjs(utc)
|
||||||
|
: dayjs(utc).startOf("day")
|
||||||
|
: null
|
||||||
|
if (toDate && (!fromDate || toDate.isBefore(fromDate))) {
|
||||||
|
fromDate = !enableTime ? toDate.startOf("day") : toDate
|
||||||
|
} else if (!toDate) {
|
||||||
|
fromDate = null
|
||||||
|
}
|
||||||
|
dispatch("change", [fromDate, toDate])
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="date-range">
|
<div class="date-range">
|
||||||
<CoreDatePicker
|
<CoreDatePicker
|
||||||
value={fromDate}
|
value={parsedFrom}
|
||||||
on:change={e => (fromDate = e.detail)}
|
on:change={e => onChangeFrom(e.detail)}
|
||||||
enableTime={false}
|
{enableTime}
|
||||||
|
{timeOnly}
|
||||||
|
{ignoreTimezones}
|
||||||
/>
|
/>
|
||||||
<div class="arrow">
|
<div class="arrow">
|
||||||
<Icon name="ChevronRight" />
|
<Icon name="ChevronRight" />
|
||||||
</div>
|
</div>
|
||||||
<CoreDatePicker
|
<CoreDatePicker
|
||||||
value={toDate}
|
value={parsedTo}
|
||||||
on:change={e => (toDate = e.detail)}
|
on:change={e => onChangeTo(e.detail)}
|
||||||
enableTime={false}
|
{enableTime}
|
||||||
|
{timeOnly}
|
||||||
|
{ignoreTimezones}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import DateRangePicker from "./Core/DateRangePicker.svelte"
|
import DateRangePicker from "./Core/DateRangePicker.svelte"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
|
||||||
export let value = null
|
export let value = undefined
|
||||||
export let label = null
|
export let label = null
|
||||||
export let labelPosition = "above"
|
export let labelPosition = "above"
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
@ -12,6 +12,8 @@
|
||||||
export let helpText = null
|
export let helpText = null
|
||||||
export let appendTo = undefined
|
export let appendTo = undefined
|
||||||
export let ignoreTimezones = false
|
export let ignoreTimezones = false
|
||||||
|
export let enableTime = false
|
||||||
|
export let timeOnly = false
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -29,6 +31,8 @@
|
||||||
{value}
|
{value}
|
||||||
{appendTo}
|
{appendTo}
|
||||||
{ignoreTimezones}
|
{ignoreTimezones}
|
||||||
|
{enableTime}
|
||||||
|
{timeOnly}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
export let hoverColor: string | undefined = undefined
|
export let hoverColor: string | undefined = undefined
|
||||||
export let tooltip: string | undefined = undefined
|
export let tooltip: string | undefined = undefined
|
||||||
export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom
|
export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom
|
||||||
export let tooltipType = TooltipType.Default
|
export let tooltipType: TooltipType = TooltipType.Default
|
||||||
export let tooltipColor: string | undefined = undefined
|
export let tooltipColor: string | undefined = undefined
|
||||||
export let tooltipWrap: boolean = true
|
export let tooltipWrap: boolean = true
|
||||||
export let newStyles: boolean = false
|
export let newStyles: boolean = false
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let horizontal: boolean = false
|
export let horizontal: boolean = false
|
||||||
export let paddingX: "S" | "M" | "L" | "XL" | "XXL" = "M"
|
export let paddingX: "S" | "M" | "L" | "XL" | "XXL" = "M"
|
||||||
export let paddingY: "S" | "M" | "L" | "XL" | "XXL" = "M"
|
export let paddingY: "none" | "S" | "M" | "L" | "XL" | "XXL" = "M"
|
||||||
export let noPadding: boolean = false
|
export let noPadding: boolean = false
|
||||||
export let gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M"
|
export let gap: "XXS" | "XS" | "S" | "M" | "L" | "XL" = "M"
|
||||||
export let noGap: boolean = false
|
export let noGap: boolean = false
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { ActionMenu } from "./types"
|
import { ActionMenu, ModalContext, ScrollContext } from "./types"
|
||||||
import { ModalContext } from "./types"
|
|
||||||
|
|
||||||
declare module "svelte" {
|
declare module "svelte" {
|
||||||
export function getContext(key: "actionMenu"): ActionMenu | undefined
|
export function getContext(key: "actionMenu"): ActionMenu | undefined
|
||||||
export function getContext(key: "bbui-modal"): ModalContext
|
export function getContext(key: "bbui-modal"): ModalContext
|
||||||
|
export function getContext(key: "scroll"): ScrollContext
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = "bbui-modal"
|
export const Modal = "bbui-modal"
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./actionMenu"
|
export * from "./actionMenu"
|
||||||
export * from "./envDropdown"
|
export * from "./envDropdown"
|
||||||
export * from "./modalContext"
|
export * from "./modalContext"
|
||||||
|
export * from "./scrollContext"
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ScrollContext {
|
||||||
|
scrollTo: (bounds: DOMRect) => void
|
||||||
|
}
|
|
@ -10,8 +10,8 @@
|
||||||
export let bindings: EnrichedBinding[] = []
|
export let bindings: EnrichedBinding[] = []
|
||||||
export let value: string | null = ""
|
export let value: string | null = ""
|
||||||
export let expandedOnly: boolean = false
|
export let expandedOnly: boolean = false
|
||||||
|
|
||||||
export let parentWidth: number | null = null
|
export let parentWidth: number | null = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
update: { code: string }
|
update: { code: string }
|
||||||
accept: void
|
accept: void
|
||||||
|
@ -26,11 +26,11 @@
|
||||||
|
|
||||||
const thresholdExpansionWidth = 350
|
const thresholdExpansionWidth = 350
|
||||||
|
|
||||||
$: expanded =
|
$: shouldAlwaysBeExpanded =
|
||||||
expandedOnly ||
|
expandedOnly ||
|
||||||
(parentWidth !== null && parentWidth > thresholdExpansionWidth)
|
(parentWidth !== null && parentWidth > thresholdExpansionWidth)
|
||||||
? true
|
|
||||||
: expanded
|
$: expanded = shouldAlwaysBeExpanded || expanded
|
||||||
|
|
||||||
async function generateJs(prompt: string) {
|
async function generateJs(prompt: string) {
|
||||||
promptText = ""
|
promptText = ""
|
||||||
|
@ -78,15 +78,17 @@
|
||||||
prompt: promptText,
|
prompt: promptText,
|
||||||
})
|
})
|
||||||
dispatch("reject", { code: previousContents })
|
dispatch("reject", { code: previousContents })
|
||||||
reset()
|
reset(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset(clearPrompt: boolean = true) {
|
||||||
|
if (clearPrompt) {
|
||||||
|
promptText = ""
|
||||||
|
inputValue = ""
|
||||||
|
}
|
||||||
suggestedCode = null
|
suggestedCode = null
|
||||||
previousContents = null
|
previousContents = null
|
||||||
promptText = ""
|
|
||||||
expanded = false
|
expanded = false
|
||||||
inputValue = ""
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -108,7 +110,7 @@
|
||||||
bind:expanded
|
bind:expanded
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
readonly={!!suggestedCode}
|
readonly={!!suggestedCode}
|
||||||
{expandedOnly}
|
expandedOnly={shouldAlwaysBeExpanded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Icon, Modal } from "@budibase/bbui"
|
|
||||||
import ChooseIconModal from "@/components/start/ChooseIconModal.svelte"
|
import ChooseIconModal from "@/components/start/ChooseIconModal.svelte"
|
||||||
|
import { Icon, Modal } from "@budibase/bbui"
|
||||||
|
|
||||||
export let name
|
export let name: string
|
||||||
export let size = "M"
|
export let size: "M" = "M"
|
||||||
export let app
|
export let color: string
|
||||||
export let color
|
export let disabled: boolean = false
|
||||||
export let autoSave = false
|
|
||||||
export let disabled = false
|
|
||||||
|
|
||||||
let modal
|
let modal: Modal
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
@ -28,7 +26,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal bind:this={modal}>
|
<Modal bind:this={modal}>
|
||||||
<ChooseIconModal {name} {color} {app} {autoSave} on:change />
|
<ChooseIconModal {name} {color} on:change />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { tick } from "svelte"
|
import { tick } from "svelte"
|
||||||
import { Icon, Body } from "@budibase/bbui"
|
import { Icon, Body } from "@budibase/bbui"
|
||||||
import { keyUtils } from "@/helpers/keyUtils"
|
import { keyUtils } from "@/helpers/keyUtils"
|
||||||
|
|
||||||
export let title
|
export let title: string
|
||||||
export let placeholder
|
export let placeholder: string
|
||||||
export let value
|
export let value: string
|
||||||
export let onAdd
|
export let onAdd: () => void
|
||||||
export let search
|
export let search: boolean
|
||||||
|
|
||||||
let searchInput
|
let searchInput: HTMLInputElement
|
||||||
|
|
||||||
const openSearch = async () => {
|
const openSearch = async () => {
|
||||||
search = true
|
search = true
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
value = ""
|
value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = e => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
closeSearch()
|
closeSearch()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,46 +1,46 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
|
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
|
||||||
import { createEventDispatcher, getContext } from "svelte"
|
import { createEventDispatcher, getContext } from "svelte"
|
||||||
import { helpers } from "@budibase/shared-core"
|
|
||||||
import { UserAvatars } from "@budibase/frontend-core"
|
import { UserAvatars } from "@budibase/frontend-core"
|
||||||
|
import type { UIUser } from "@budibase/types"
|
||||||
|
|
||||||
export let icon
|
export let icon: string | null
|
||||||
export let iconTooltip
|
export let iconTooltip: string = ""
|
||||||
export let withArrow = false
|
export let withArrow: boolean = false
|
||||||
export let withActions = true
|
export let withActions: boolean = true
|
||||||
export let showActions = false
|
export let showActions: boolean = false
|
||||||
export let indentLevel = 0
|
export let indentLevel: number = 0
|
||||||
export let text
|
export let text: string
|
||||||
export let border = true
|
export let border: boolean = true
|
||||||
export let selected = false
|
export let selected: boolean = false
|
||||||
export let opened = false
|
export let opened: boolean = false
|
||||||
export let draggable = false
|
export let draggable: boolean = false
|
||||||
export let iconText
|
export let iconText: string = ""
|
||||||
export let iconColor
|
export let iconColor: string = ""
|
||||||
export let scrollable = false
|
export let scrollable: boolean = false
|
||||||
export let highlighted = false
|
export let highlighted: boolean = false
|
||||||
export let rightAlignIcon = false
|
export let rightAlignIcon: boolean = false
|
||||||
export let id
|
export let id: string = ""
|
||||||
export let showTooltip = false
|
export let showTooltip: boolean = false
|
||||||
export let selectedBy = null
|
export let selectedBy: UIUser[] | null = null
|
||||||
export let compact = false
|
export let compact: boolean = false
|
||||||
export let hovering = false
|
export let hovering: boolean = false
|
||||||
export let disabled = false
|
export let disabled: boolean = false
|
||||||
|
|
||||||
const scrollApi = getContext("scroll")
|
const scrollApi = getContext("scroll")
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let contentRef
|
let contentRef: HTMLDivElement
|
||||||
|
|
||||||
$: selected && contentRef && scrollToView()
|
$: selected && contentRef && scrollToView()
|
||||||
$: style = getStyle(indentLevel, selectedBy)
|
$: style = getStyle(indentLevel)
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
scrollToView()
|
scrollToView()
|
||||||
dispatch("click")
|
dispatch("click")
|
||||||
}
|
}
|
||||||
|
|
||||||
const onIconClick = e => {
|
const onIconClick = (e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
dispatch("iconClick")
|
dispatch("iconClick")
|
||||||
}
|
}
|
||||||
|
@ -53,11 +53,8 @@
|
||||||
scrollApi.scrollTo(bounds)
|
scrollApi.scrollTo(bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyle = (indentLevel, selectedBy) => {
|
const getStyle = (indentLevel: number) => {
|
||||||
let style = `padding-left:calc(${indentLevel * 14}px);`
|
let style = `padding-left:calc(${indentLevel * 14}px);`
|
||||||
if (selectedBy) {
|
|
||||||
style += `--selected-by-color:${helpers.getUserColor(selectedBy)};`
|
|
||||||
}
|
|
||||||
return style
|
return style
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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 let value: string = ""
|
||||||
export const submit = onPromptSubmit
|
export const submit = onPromptSubmit
|
||||||
|
|
||||||
$: expanded = expandedOnly || expanded
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let promptInput: HTMLInputElement
|
let promptInput: HTMLInputElement
|
||||||
|
@ -22,6 +21,7 @@
|
||||||
let switchOnAIModal: Modal
|
let switchOnAIModal: Modal
|
||||||
let addCreditsModal: Modal
|
let addCreditsModal: Modal
|
||||||
|
|
||||||
|
$: expanded = expandedOnly || expanded
|
||||||
$: accountPortalAccess = $auth?.user?.accountPortalAccess
|
$: accountPortalAccess = $auth?.user?.accountPortalAccess
|
||||||
$: accountPortal = $admin.accountPortalUrl
|
$: accountPortal = $admin.accountPortalUrl
|
||||||
$: aiEnabled = $auth?.user?.llm
|
$: aiEnabled = $auth?.user?.llm
|
||||||
|
@ -92,9 +92,12 @@
|
||||||
class="ai-icon"
|
class="ai-icon"
|
||||||
class:loading={promptLoading}
|
class:loading={promptLoading}
|
||||||
class:disabled={expanded && disabled}
|
class:disabled={expanded && disabled}
|
||||||
|
class:no-toggle={expandedOnly}
|
||||||
on:click={e => {
|
on:click={e => {
|
||||||
e.stopPropagation()
|
if (!expandedOnly) {
|
||||||
toggleExpand()
|
e.stopPropagation()
|
||||||
|
toggleExpand()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
|
@ -290,6 +293,10 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-icon.no-toggle {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-gen-text {
|
.ai-gen-text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { ModalContent, Input } from "@budibase/bbui"
|
import { ModalContent, Input } from "@budibase/bbui"
|
||||||
import sanitizeUrl from "@/helpers/sanitizeUrl"
|
import sanitizeUrl from "@/helpers/sanitizeUrl"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { screenStore } from "@/stores/builder"
|
import { screenStore } from "@/stores/builder"
|
||||||
|
|
||||||
export let onConfirm
|
export let onConfirm: (_data: { route: string }) => Promise<void>
|
||||||
export let onCancel
|
export let onCancel: (() => Promise<void>) | undefined = undefined
|
||||||
export let route
|
export let route: string
|
||||||
export let role
|
export let role: string | undefined
|
||||||
export let confirmText = "Continue"
|
export let confirmText = "Continue"
|
||||||
|
|
||||||
const appPrefix = "/app"
|
const appPrefix = "/app"
|
||||||
let touched = false
|
let touched = false
|
||||||
let error
|
let error: string | undefined
|
||||||
let modal
|
let modal: ModalContent
|
||||||
|
|
||||||
$: appUrl = route
|
$: appUrl = route
|
||||||
? `${window.location.origin}${appPrefix}${route}`
|
? `${window.location.origin}${appPrefix}${route}`
|
||||||
: `${window.location.origin}${appPrefix}`
|
: `${window.location.origin}${appPrefix}`
|
||||||
|
|
||||||
const routeChanged = event => {
|
const routeChanged = (event: { detail: string }) => {
|
||||||
if (!event.detail.startsWith("/")) {
|
if (!event.detail.startsWith("/")) {
|
||||||
route = "/" + event.detail
|
route = "/" + event.detail
|
||||||
}
|
}
|
||||||
|
@ -28,11 +28,11 @@
|
||||||
if (routeExists(route)) {
|
if (routeExists(route)) {
|
||||||
error = "This URL is already taken for this access role"
|
error = "This URL is already taken for this access role"
|
||||||
} else {
|
} else {
|
||||||
error = null
|
error = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeExists = url => {
|
const routeExists = (url: string) => {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@
|
||||||
onConfirm={confirmScreenDetails}
|
onConfirm={confirmScreenDetails}
|
||||||
{onCancel}
|
{onCancel}
|
||||||
cancelText={"Back"}
|
cancelText={"Back"}
|
||||||
disabled={!route || error || !touched}
|
disabled={!route || !!error || !touched}
|
||||||
>
|
>
|
||||||
<form on:submit|preventDefault={() => modal.confirm()}>
|
<form on:submit|preventDefault={() => modal.confirm()}>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -26,6 +26,7 @@ import TopLevelColumnEditor from "./controls/ColumnEditor/TopLevelColumnEditor.s
|
||||||
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
|
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
|
||||||
import BarButtonList from "./controls/BarButtonList.svelte"
|
import BarButtonList from "./controls/BarButtonList.svelte"
|
||||||
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
|
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
|
||||||
|
import FilterConfiguration from "./controls/FilterConfiguration/FilterConfiguration.svelte"
|
||||||
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
|
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
|
||||||
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
|
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
|
||||||
import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
|
import FormStepConfiguration from "./controls/FormStepConfiguration.svelte"
|
||||||
|
@ -33,6 +34,7 @@ import FormStepControls from "./controls/FormStepControls.svelte"
|
||||||
import PaywalledSetting from "./controls/PaywalledSetting.svelte"
|
import PaywalledSetting from "./controls/PaywalledSetting.svelte"
|
||||||
import TableConditionEditor from "./controls/TableConditionEditor.svelte"
|
import TableConditionEditor from "./controls/TableConditionEditor.svelte"
|
||||||
import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte"
|
import MultilineDrawerBindableInput from "@/components/common/MultilineDrawerBindableInput.svelte"
|
||||||
|
import FilterableSelect from "./controls/FilterableSelect.svelte"
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
text: DrawerBindableInput,
|
text: DrawerBindableInput,
|
||||||
|
@ -42,6 +44,7 @@ const componentMap = {
|
||||||
radio: RadioGroup,
|
radio: RadioGroup,
|
||||||
dataSource: DataSourceSelect,
|
dataSource: DataSourceSelect,
|
||||||
"dataSource/s3": S3DataSourceSelect,
|
"dataSource/s3": S3DataSourceSelect,
|
||||||
|
"dataSource/filterable": FilterableSelect,
|
||||||
dataProvider: DataProviderSelect,
|
dataProvider: DataProviderSelect,
|
||||||
boolean: Checkbox,
|
boolean: Checkbox,
|
||||||
number: Stepper,
|
number: Stepper,
|
||||||
|
@ -59,6 +62,7 @@ const componentMap = {
|
||||||
"filter/relationship": RelationshipFilterEditor,
|
"filter/relationship": RelationshipFilterEditor,
|
||||||
url: URLSelect,
|
url: URLSelect,
|
||||||
fieldConfiguration: FieldConfiguration,
|
fieldConfiguration: FieldConfiguration,
|
||||||
|
filterConfiguration: FilterConfiguration,
|
||||||
buttonConfiguration: ButtonConfiguration,
|
buttonConfiguration: ButtonConfiguration,
|
||||||
stepConfiguration: FormStepConfiguration,
|
stepConfiguration: FormStepConfiguration,
|
||||||
formStepControls: FormStepControls,
|
formStepControls: FormStepControls,
|
||||||
|
|
|
@ -11,7 +11,10 @@
|
||||||
$componentStore.selectedComponentId,
|
$componentStore.selectedComponentId,
|
||||||
"RefreshDatasource",
|
"RefreshDatasource",
|
||||||
{ includeSelf: nested }
|
{ includeSelf: nested }
|
||||||
)
|
).concat({
|
||||||
|
readableBinding: "All data providers",
|
||||||
|
runtimeBinding: "all",
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
export let listTypeProps = {}
|
export let listTypeProps = {}
|
||||||
export let listItemKey
|
export let listItemKey
|
||||||
export let draggable = true
|
export let draggable = true
|
||||||
export let focus
|
export let focus = undefined
|
||||||
|
|
||||||
let zoneType = generate()
|
let zoneType = generate()
|
||||||
|
|
||||||
|
|
|
@ -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 { createEventDispatcher } from "svelte"
|
||||||
import FieldSetting from "./FieldSetting.svelte"
|
import FieldSetting from "./FieldSetting.svelte"
|
||||||
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
|
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
|
||||||
import getColumns from "./getColumns.js"
|
import { getColumns } from "./getColumns.js"
|
||||||
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
const modernize = columns => {
|
export const modernize = columns => {
|
||||||
if (!columns) {
|
if (!columns) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,9 @@ const modernize = columns => {
|
||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeInvalidAddMissing = (
|
export const removeInvalidAddMissing = (
|
||||||
columns = [],
|
columns = [],
|
||||||
defaultColumns,
|
defaultColumns = [],
|
||||||
primaryDisplayColumnName
|
primaryDisplayColumnName
|
||||||
) => {
|
) => {
|
||||||
const defaultColumnNames = defaultColumns.map(column => column.field)
|
const defaultColumnNames = defaultColumns.map(column => column.field)
|
||||||
|
@ -47,7 +47,7 @@ const removeInvalidAddMissing = (
|
||||||
return combinedColumns
|
return combinedColumns
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefault = (schema = {}) => {
|
export const getDefault = (schema = {}) => {
|
||||||
const defaultValues = Object.values(schema)
|
const defaultValues = Object.values(schema)
|
||||||
.filter(column => !column.nestedJSON)
|
.filter(column => !column.nestedJSON)
|
||||||
.map(column => ({
|
.map(column => ({
|
||||||
|
@ -93,7 +93,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColumns = ({
|
export const getColumns = ({
|
||||||
columns,
|
columns,
|
||||||
schema,
|
schema,
|
||||||
primaryDisplayColumnName,
|
primaryDisplayColumnName,
|
||||||
|
@ -132,5 +132,3 @@ const getColumns = ({
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default getColumns
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { it, expect, describe, beforeEach, vi } from "vitest"
|
import { it, expect, describe, beforeEach, vi } from "vitest"
|
||||||
import getColumns from "./getColumns"
|
import { getColumns } from "./getColumns"
|
||||||
|
|
||||||
describe("getColumns", () => {
|
describe("getColumns", () => {
|
||||||
beforeEach(ctx => {
|
beforeEach(ctx => {
|
||||||
|
|
|
@ -109,13 +109,12 @@
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button secondary icon="Add" on:click={addOAuth2Configuration}
|
|
||||||
>Add OAuth2</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div>
|
||||||
|
<Button secondary icon="Add" on:click={addOAuth2Configuration}
|
||||||
|
>Add OAuth2</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</DetailPopover>
|
</DetailPopover>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -1,18 +1,9 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import {
|
import { ColorPicker, Icon, Label, ModalContent } from "@budibase/bbui"
|
||||||
ModalContent,
|
|
||||||
Icon,
|
|
||||||
ColorPicker,
|
|
||||||
Label,
|
|
||||||
notifications,
|
|
||||||
} from "@budibase/bbui"
|
|
||||||
import { appsStore } from "@/stores/portal"
|
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
|
||||||
export let app
|
export let name: string
|
||||||
export let name
|
export let color: string
|
||||||
export let color
|
|
||||||
export let autoSave = false
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -44,17 +35,8 @@
|
||||||
]
|
]
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!autoSave) {
|
dispatch("change", { color, name })
|
||||||
dispatch("change", { color, name })
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await appsStore.save(app.instance._id, {
|
|
||||||
icon: { name, color },
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error("Error updating app")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1424,7 +1424,7 @@ const bindingReplacement = (
|
||||||
* Extracts a component ID from a handlebars expression setting of
|
* Extracts a component ID from a handlebars expression setting of
|
||||||
* {{ literal [componentId] }}
|
* {{ literal [componentId] }}
|
||||||
*/
|
*/
|
||||||
const extractLiteralHandlebarsID = value => {
|
export const extractLiteralHandlebarsID = value => {
|
||||||
if (!value || typeof value !== "string") {
|
if (!value || typeof value !== "string") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@
|
||||||
import { isActive, url, goto, layout, redirect } from "@roxi/routify"
|
import { isActive, url, goto, layout, redirect } from "@roxi/routify"
|
||||||
import { capitalise } from "@/helpers"
|
import { capitalise } from "@/helpers"
|
||||||
import { onMount, onDestroy } from "svelte"
|
import { onMount, onDestroy } from "svelte"
|
||||||
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
|
|
||||||
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
|
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
|
||||||
import { UserAvatars } from "@budibase/frontend-core"
|
import { UserAvatars } from "@budibase/frontend-core"
|
||||||
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
|
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
|
||||||
|
@ -95,7 +94,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="root" class:blur={$previewStore.showPreview}>
|
<div class="root" class:blur={$previewStore.showPreview}>
|
||||||
<VerificationPromptBanner />
|
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
{#if $appStore.initialised}
|
{#if $appStore.initialised}
|
||||||
<div class="topleftnav">
|
<div class="topleftnav">
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { Icon } from "@budibase/bbui"
|
import { Icon } from "@budibase/bbui"
|
||||||
|
|
||||||
export let title
|
export let title = undefined
|
||||||
export let body
|
export let body = undefined
|
||||||
export let icon = "HelpOutline"
|
export let icon = "HelpOutline"
|
||||||
export let quiet = false
|
export let quiet = false
|
||||||
export let warning = false
|
export let warning = false
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"pdftable",
|
"pdftable",
|
||||||
"spreadsheet",
|
"spreadsheet",
|
||||||
"dynamicfilter",
|
"dynamicfilter",
|
||||||
|
"filter",
|
||||||
"daterangepicker"
|
"daterangepicker"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Modal, Helpers, notifications, Icon } from "@budibase/bbui"
|
import { Modal, Helpers, notifications, Icon } from "@budibase/bbui"
|
||||||
import {
|
import {
|
||||||
navigationStore,
|
navigationStore,
|
||||||
|
@ -14,13 +14,14 @@
|
||||||
import { makeComponentUnique } from "@/helpers/components"
|
import { makeComponentUnique } from "@/helpers/components"
|
||||||
import { capitalise } from "@/helpers"
|
import { capitalise } from "@/helpers"
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
|
||||||
|
import type { Screen } from "@budibase/types"
|
||||||
|
|
||||||
export let screen
|
export let screen
|
||||||
|
|
||||||
let confirmDeleteDialog
|
let confirmDeleteDialog: ConfirmDialog
|
||||||
let screenDetailsModal
|
let screenDetailsModal: Modal
|
||||||
|
|
||||||
const createDuplicateScreen = async ({ route }) => {
|
const createDuplicateScreen = async ({ route }: { route: string }) => {
|
||||||
// Create a dupe and ensure it is unique
|
// Create a dupe and ensure it is unique
|
||||||
let duplicateScreen = Helpers.cloneDeep(screen)
|
let duplicateScreen = Helpers.cloneDeep(screen)
|
||||||
delete duplicateScreen._id
|
delete duplicateScreen._id
|
||||||
|
@ -57,7 +58,7 @@
|
||||||
|
|
||||||
$: noPaste = !$componentStore.componentToPaste
|
$: noPaste = !$componentStore.componentToPaste
|
||||||
|
|
||||||
const pasteComponent = mode => {
|
const pasteComponent = (mode: "inside") => {
|
||||||
try {
|
try {
|
||||||
componentStore.paste(screen.props, mode, screen)
|
componentStore.paste(screen.props, mode, screen)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -65,7 +66,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openContextMenu = (e, screen) => {
|
const openContextMenu = (e: MouseEvent, screen: Screen) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
@ -96,7 +97,7 @@
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
contextMenuStore.open(screen._id, items, { x: e.clientX, y: e.clientY })
|
contextMenuStore.open(screen._id!, items, { x: e.clientX, y: e.clientY })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Layout } from "@budibase/bbui"
|
import { Layout } from "@budibase/bbui"
|
||||||
import { sortedScreens } from "@/stores/builder"
|
import { sortedScreens } from "@/stores/builder"
|
||||||
import ScreenNavItem from "./ScreenNavItem.svelte"
|
import ScreenNavItem from "./ScreenNavItem.svelte"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { getVerticalResizeActions } from "@/components/common/resizable"
|
import { getVerticalResizeActions } from "@/components/common/resizable"
|
||||||
import NavHeader from "@/components/common/NavHeader.svelte"
|
import NavHeader from "@/components/common/NavHeader.svelte"
|
||||||
|
import type { Screen } from "@budibase/types"
|
||||||
|
|
||||||
const [resizable, resizableHandle] = getVerticalResizeActions()
|
const [resizable, resizableHandle] = getVerticalResizeActions()
|
||||||
|
|
||||||
let searching = false
|
let searching = false
|
||||||
let searchValue = ""
|
let searchValue = ""
|
||||||
let screensContainer
|
let screensContainer: HTMLDivElement
|
||||||
let scrolling = false
|
let scrolling = false
|
||||||
|
|
||||||
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
|
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
|
||||||
|
@ -25,13 +26,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilteredScreens = (screens, searchValue) => {
|
const getFilteredScreens = (screens: Screen[], searchValue: string) => {
|
||||||
return screens.filter(screen => {
|
return screens.filter(screen => {
|
||||||
return !searchValue || screen.routing.route.includes(searchValue)
|
return !searchValue || screen.routing.route.includes(searchValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = e => {
|
const handleScroll = (e: any) => {
|
||||||
scrolling = e.target.scrollTop !== 0
|
scrolling = e.target.scrollTop !== 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -62,7 +63,6 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
disabled={searching}
|
|
||||||
class="divider"
|
class="divider"
|
||||||
class:disabled={searching}
|
class:disabled={searching}
|
||||||
use:resizableHandle
|
use:resizableHandle
|
||||||
|
|
|
@ -15,7 +15,6 @@
|
||||||
import Logo from "./_components/Logo.svelte"
|
import Logo from "./_components/Logo.svelte"
|
||||||
import UserDropdown from "./_components/UserDropdown.svelte"
|
import UserDropdown from "./_components/UserDropdown.svelte"
|
||||||
import HelpMenu from "@/components/common/HelpMenu.svelte"
|
import HelpMenu from "@/components/common/HelpMenu.svelte"
|
||||||
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
|
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
import EnterpriseBasicTrialBanner from "@/components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
|
import EnterpriseBasicTrialBanner from "@/components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
@ -74,7 +73,6 @@
|
||||||
{:else}
|
{:else}
|
||||||
<HelpMenu />
|
<HelpMenu />
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<VerificationPromptBanner />
|
|
||||||
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
|
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<div class="branding">
|
<div class="branding">
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
Link,
|
Link,
|
||||||
TooltipWrapper,
|
TooltipWrapper,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
import { Feature } from "@budibase/types"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { admin, auth, licensing } from "@/stores/portal"
|
import { admin, auth, licensing } from "@/stores/portal"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
@ -33,6 +34,7 @@
|
||||||
|
|
||||||
const EXCLUDE_QUOTAS = {
|
const EXCLUDE_QUOTAS = {
|
||||||
["Day Passes"]: () => true,
|
["Day Passes"]: () => true,
|
||||||
|
[Feature.AI_CUSTOM_CONFIGS]: () => true,
|
||||||
Queries: () => true,
|
Queries: () => true,
|
||||||
Users: license => {
|
Users: license => {
|
||||||
return license.plan.model !== PlanModel.PER_USER
|
return license.plan.model !== PlanModel.PER_USER
|
||||||
|
|
|
@ -1,11 +1,23 @@
|
||||||
import { it, expect, describe, vi } from "vitest"
|
import { it, expect, describe, vi } from "vitest"
|
||||||
import AISettings from "./index.svelte"
|
import AISettings from "./index.svelte"
|
||||||
import { render, fireEvent } from "@testing-library/svelte"
|
import { render, waitFor } from "@testing-library/svelte"
|
||||||
import { admin, licensing, featureFlags } from "@/stores/portal"
|
import { admin, licensing, featureFlags } from "@/stores/portal"
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
|
import { API } from "@/api"
|
||||||
|
|
||||||
vi.spyOn(notifications, "error").mockImplementation(vi.fn)
|
vi.spyOn(notifications, "error").mockImplementation(vi.fn)
|
||||||
vi.spyOn(notifications, "success").mockImplementation(vi.fn)
|
vi.spyOn(notifications, "success").mockImplementation(vi.fn)
|
||||||
|
vi.mock("@/api", () => ({
|
||||||
|
API: {
|
||||||
|
getConfig: vi.fn().mockResolvedValue({
|
||||||
|
config: {},
|
||||||
|
}),
|
||||||
|
getLicenseKey: vi.fn().mockResolvedValue({
|
||||||
|
licenseKey: "abc-123",
|
||||||
|
}),
|
||||||
|
saveConfig: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const Hosting = {
|
const Hosting = {
|
||||||
Cloud: "cloud",
|
Cloud: "cloud",
|
||||||
|
@ -15,7 +27,6 @@ const Hosting = {
|
||||||
function setupEnv(hosting, features = {}, flags = {}) {
|
function setupEnv(hosting, features = {}, flags = {}) {
|
||||||
const defaultFeatures = {
|
const defaultFeatures = {
|
||||||
budibaseAIEnabled: false,
|
budibaseAIEnabled: false,
|
||||||
customAIConfigsEnabled: false,
|
|
||||||
...features,
|
...features,
|
||||||
}
|
}
|
||||||
const defaultFlags = {
|
const defaultFlags = {
|
||||||
|
@ -41,33 +52,123 @@ describe("AISettings", () => {
|
||||||
let instance = null
|
let instance = null
|
||||||
|
|
||||||
const setupDOM = () => {
|
const setupDOM = () => {
|
||||||
instance = render(AISettings, {})
|
instance = render(AISettings)
|
||||||
const modalContainer = document.createElement("div")
|
const modalContainer = document.createElement("div")
|
||||||
modalContainer.classList.add("modal-container")
|
modalContainer.classList.add("modal-container")
|
||||||
instance.baseElement.appendChild(modalContainer)
|
instance.baseElement.appendChild(modalContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupEnv(Hosting.Self)
|
||||||
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("that the AISettings is rendered", () => {
|
describe("Basic rendering", () => {
|
||||||
setupDOM()
|
it("should render the AI header", async () => {
|
||||||
expect(instance).toBeDefined()
|
setupDOM()
|
||||||
|
await waitFor(() => {
|
||||||
|
const header = instance.getByText("AI")
|
||||||
|
expect(header).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it("should show 'No LLMs are enabled' when no providers are active", async () => {
|
||||||
|
API.getConfig.mockResolvedValueOnce({ config: {} })
|
||||||
|
setupDOM()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const noEnabledText = instance.getByText("No LLMs are enabled")
|
||||||
|
expect(noEnabledText).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display the 'Enable BB AI' button", async () => {
|
||||||
|
API.getConfig.mockResolvedValueOnce({ config: {} })
|
||||||
|
setupDOM()
|
||||||
|
await waitFor(() => {
|
||||||
|
const enableButton = instance.getByText("Enable BB AI")
|
||||||
|
expect(enableButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("DOM Render tests", () => {
|
describe("Provider rendering", () => {
|
||||||
it("the enable bb ai button should not do anything if the user doesn't have the correct license on self host", async () => {
|
it("should display active provider with active status tag", async () => {
|
||||||
let addAiButton
|
API.getConfig.mockResolvedValueOnce({
|
||||||
let configModal
|
config: {
|
||||||
|
BudibaseAI: {
|
||||||
|
provider: "BudibaseAI",
|
||||||
|
active: true,
|
||||||
|
isDefault: true,
|
||||||
|
name: "Budibase AI",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
setupEnv(Hosting.Self, { customAIConfigsEnabled: false })
|
|
||||||
setupDOM()
|
setupDOM()
|
||||||
addAiButton = instance.queryByText("Enable BB AI")
|
|
||||||
expect(addAiButton).toBeInTheDocument()
|
await waitFor(() => {
|
||||||
await fireEvent.click(addAiButton)
|
const providerName = instance.getByText("Budibase AI")
|
||||||
configModal = instance.queryByText("Custom AI Configuration")
|
expect(providerName).toBeInTheDocument()
|
||||||
expect(configModal).not.toBeInTheDocument()
|
|
||||||
|
const statusTags = instance.baseElement.querySelectorAll(".tag.active")
|
||||||
|
expect(statusTags.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
let foundEnabledTag = false
|
||||||
|
statusTags.forEach(tag => {
|
||||||
|
if (tag.textContent === "Enabled") {
|
||||||
|
foundEnabledTag = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(foundEnabledTag).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display disabled provider with disabled status tag", async () => {
|
||||||
|
API.getConfig.mockResolvedValueOnce({
|
||||||
|
config: {
|
||||||
|
BudibaseAI: {
|
||||||
|
provider: "BudibaseAI",
|
||||||
|
active: true,
|
||||||
|
isDefault: true,
|
||||||
|
name: "Budibase AI",
|
||||||
|
},
|
||||||
|
OpenAI: {
|
||||||
|
provider: "OpenAI",
|
||||||
|
active: false,
|
||||||
|
isDefault: false,
|
||||||
|
name: "OpenAI",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setupDOM()
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const disabledProvider = instance.getByText("OpenAI")
|
||||||
|
expect(disabledProvider).toBeInTheDocument()
|
||||||
|
|
||||||
|
const disabledTags =
|
||||||
|
instance.baseElement.querySelectorAll(".tag.disabled")
|
||||||
|
expect(disabledTags.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
let foundDisabledTag = false
|
||||||
|
disabledTags.forEach(tag => {
|
||||||
|
if (tag.textContent === "Disabled") {
|
||||||
|
foundDisabledTag = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(foundDisabledTag).toBe(true)
|
||||||
|
|
||||||
|
const openAIOption = disabledProvider.closest(".option")
|
||||||
|
expect(openAIOption).not.toBeNull()
|
||||||
|
const disabledTagNearOpenAI =
|
||||||
|
openAIOption.querySelector(".tag.disabled")
|
||||||
|
expect(disabledTagNearOpenAI).not.toBeNull()
|
||||||
|
expect(disabledTagNearOpenAI.textContent).toBe("Disabled")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -159,74 +159,76 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout noPadding>
|
{#if aiConfig}
|
||||||
<Layout gap="XS" noPadding>
|
<Layout noPadding>
|
||||||
<div class="header">
|
<Layout gap="XS" noPadding>
|
||||||
<Heading size="M">AI</Heading>
|
<div class="header">
|
||||||
</div>
|
<Heading size="M">AI</Heading>
|
||||||
<Body>
|
</div>
|
||||||
Connect an LLM to enable AI features. You can only enable one LLM at a
|
<Body>
|
||||||
time.
|
Connect an LLM to enable AI features. You can only enable one LLM at a
|
||||||
</Body>
|
time.
|
||||||
</Layout>
|
</Body>
|
||||||
<Divider />
|
</Layout>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
{#if !activeProvider && !$bannerStore}
|
{#if !activeProvider && !$bannerStore}
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<div class="banner-content">
|
<div class="banner-content">
|
||||||
<div class="banner-icon">
|
<div class="banner-icon">
|
||||||
<img src={BBAI} alt="BB AI" width="24" height="24" />
|
<img src={BBAI} alt="BB AI" width="24" height="24" />
|
||||||
|
</div>
|
||||||
|
<div>Try BB AI for free. 50,000 tokens included. No CC required.</div>
|
||||||
</div>
|
</div>
|
||||||
<div>Try BB AI for free. 50,000 tokens included. No CC required.</div>
|
<div class="banner-buttons">
|
||||||
</div>
|
<Button
|
||||||
<div class="banner-buttons">
|
primary
|
||||||
<Button
|
cta
|
||||||
primary
|
size="S"
|
||||||
cta
|
on:click={() => handleEnable("BudibaseAI")}
|
||||||
size="S"
|
>
|
||||||
on:click={() => handleEnable("BudibaseAI")}
|
Enable BB AI
|
||||||
>
|
</Button>
|
||||||
Enable BB AI
|
<Icon
|
||||||
</Button>
|
hoverable
|
||||||
<Icon
|
name="Close"
|
||||||
hoverable
|
on:click={() => {
|
||||||
name="Close"
|
setBannerLocalStorageKey()
|
||||||
on:click={() => {
|
bannerStore.set(true)
|
||||||
setBannerLocalStorageKey()
|
}}
|
||||||
bannerStore.set(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Enabled</div>
|
|
||||||
{#if activeProvider}
|
|
||||||
<AIConfigTile
|
|
||||||
config={getProviderConfig(activeProvider).config}
|
|
||||||
editHandler={() => handleEnable(activeProvider)}
|
|
||||||
disableHandler={() => disableProvider(activeProvider)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="no-enabled">
|
|
||||||
<Body size="S">No LLMs are enabled</Body>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if disabledProviders.length > 0}
|
|
||||||
<div class="section-title disabled-title">Disabled</div>
|
|
||||||
<div class="ai-list">
|
|
||||||
{#each disabledProviders as { provider, config } (provider)}
|
|
||||||
<AIConfigTile
|
|
||||||
{config}
|
|
||||||
editHandler={() => handleEnable(provider)}
|
|
||||||
disableHandler={() => disableProvider(provider)}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</Layout>
|
<div class="section">
|
||||||
|
<div class="section-title">Enabled</div>
|
||||||
|
{#if activeProvider}
|
||||||
|
<AIConfigTile
|
||||||
|
config={getProviderConfig(activeProvider).config}
|
||||||
|
editHandler={() => handleEnable(activeProvider)}
|
||||||
|
disableHandler={() => disableProvider(activeProvider)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="no-enabled">
|
||||||
|
<Body size="S">No LLMs are enabled</Body>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if disabledProviders.length > 0}
|
||||||
|
<div class="section-title disabled-title">Disabled</div>
|
||||||
|
<div class="ai-list">
|
||||||
|
{#each disabledProviders as { provider, config } (provider)}
|
||||||
|
<AIConfigTile
|
||||||
|
{config}
|
||||||
|
editHandler={() => handleEnable(provider)}
|
||||||
|
disableHandler={() => disableProvider(provider)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Modal bind:this={portalModal}>
|
<Modal bind:this={portalModal}>
|
||||||
<PortalModal
|
<PortalModal
|
||||||
|
|
|
@ -65,7 +65,6 @@ export const INITIAL_COMPONENTS_STATE: ComponentState = {
|
||||||
export class ComponentStore extends BudiStore<ComponentState> {
|
export class ComponentStore extends BudiStore<ComponentState> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(INITIAL_COMPONENTS_STATE)
|
super(INITIAL_COMPONENTS_STATE)
|
||||||
|
|
||||||
this.reset = this.reset.bind(this)
|
this.reset = this.reset.bind(this)
|
||||||
this.refreshDefinitions = this.refreshDefinitions.bind(this)
|
this.refreshDefinitions = this.refreshDefinitions.bind(this)
|
||||||
this.getDefinition = this.getDefinition.bind(this)
|
this.getDefinition = this.getDefinition.bind(this)
|
||||||
|
@ -214,7 +213,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
||||||
|
|
||||||
const def = this.getDefinition(enrichedComponent?._component)
|
const def = this.getDefinition(enrichedComponent?._component)
|
||||||
const filterableTypes = def?.settings?.filter(setting =>
|
const filterableTypes = def?.settings?.filter(setting =>
|
||||||
setting?.type?.startsWith("filter")
|
["filter", "filter/relationship"].includes(setting?.type)
|
||||||
)
|
)
|
||||||
for (let setting of filterableTypes || []) {
|
for (let setting of filterableTypes || []) {
|
||||||
const isLegacy = Array.isArray(enrichedComponent[setting.key])
|
const isLegacy = Array.isArray(enrichedComponent[setting.key])
|
||||||
|
|
|
@ -6,9 +6,12 @@ interface Position {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
label: string
|
|
||||||
icon?: string
|
icon?: string
|
||||||
action: () => void
|
name: string
|
||||||
|
keyBind: string | null
|
||||||
|
visible: boolean
|
||||||
|
disabled: boolean
|
||||||
|
callback: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
UnsavedUser,
|
UnsavedUser,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { BudiStore } from "../BudiStore"
|
import { BudiStore } from "../BudiStore"
|
||||||
|
import { notifications } from "@budibase/bbui"
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
email: string
|
email: string
|
||||||
|
@ -43,6 +44,7 @@ class UserStore extends BudiStore<UserState> {
|
||||||
try {
|
try {
|
||||||
return await API.getUser(userId)
|
return await API.getUser(userId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
notifications.error("Error fetching user")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,6 +87,7 @@ export default defineConfig(({ mode }) => {
|
||||||
exclude: ["@roxi/routify", "fsevents"],
|
exclude: ["@roxi/routify", "fsevents"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
conditions: mode === "test" ? ["browser"] : [],
|
||||||
dedupe: ["@roxi/routify"],
|
dedupe: ["@roxi/routify"],
|
||||||
alias: {
|
alias: {
|
||||||
"@budibase/types": path.resolve(__dirname, "../types/src"),
|
"@budibase/types": path.resolve(__dirname, "../types/src"),
|
||||||
|
|
|
@ -5204,7 +5204,7 @@
|
||||||
"icon": "Data",
|
"icon": "Data",
|
||||||
"illegalChildren": ["section"],
|
"illegalChildren": ["section"],
|
||||||
"hasChildren": true,
|
"hasChildren": true,
|
||||||
"actions": ["RefreshDatasource"],
|
"actions": ["RefreshDatasource", "AddDataProviderQueryExtension"],
|
||||||
"size": {
|
"size": {
|
||||||
"width": 500,
|
"width": 500,
|
||||||
"height": 200
|
"height": 200
|
||||||
|
@ -5526,7 +5526,59 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"filter": {
|
||||||
|
"name": "Filter",
|
||||||
|
"icon": "Filter",
|
||||||
|
"size": {
|
||||||
|
"width": 100,
|
||||||
|
"height": 35
|
||||||
|
},
|
||||||
|
"new": true,
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "dataSource/filterable",
|
||||||
|
"label": "Target component",
|
||||||
|
"required": true,
|
||||||
|
"key": "targetComponent",
|
||||||
|
"wide": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "",
|
||||||
|
"type": "filterConfiguration",
|
||||||
|
"key": "filterConfig",
|
||||||
|
"nested": true,
|
||||||
|
"dependsOn": "targetComponent",
|
||||||
|
"resetOn": "targetComponent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Persist filters",
|
||||||
|
"key": "persistFilters",
|
||||||
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Clear filters",
|
||||||
|
"key": "showClear",
|
||||||
|
"defaultValue": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"filterconfig": {
|
||||||
|
"name": "",
|
||||||
|
"icon": "",
|
||||||
|
"editable": true,
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "plainText",
|
||||||
|
"label": "Label",
|
||||||
|
"key": "label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"dynamicfilter": {
|
"dynamicfilter": {
|
||||||
|
"deprecated": true,
|
||||||
"name": "Dynamic Filter",
|
"name": "Dynamic Filter",
|
||||||
"icon": "Filter",
|
"icon": "Filter",
|
||||||
"size": {
|
"size": {
|
||||||
|
@ -7671,7 +7723,8 @@
|
||||||
"setting": "table.type",
|
"setting": "table.type",
|
||||||
"value": "custom",
|
"value": "custom",
|
||||||
"invert": true
|
"invert": true
|
||||||
}
|
},
|
||||||
|
"resetOn": "table"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "field/sortable",
|
"type": "field/sortable",
|
||||||
|
@ -7823,7 +7876,7 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": ["RefreshDatasource"]
|
"actions": ["RefreshDatasource", "AddDataProviderFilterExtension"]
|
||||||
},
|
},
|
||||||
"bbreferencefield": {
|
"bbreferencefield": {
|
||||||
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
|
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
environmentStore,
|
environmentStore,
|
||||||
sidePanelStore,
|
sidePanelStore,
|
||||||
modalStore,
|
modalStore,
|
||||||
|
dataSourceStore,
|
||||||
} from "@/stores"
|
} from "@/stores"
|
||||||
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
|
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
|
||||||
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
|
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
|
||||||
|
@ -47,11 +48,18 @@
|
||||||
import SnippetsProvider from "./context/SnippetsProvider.svelte"
|
import SnippetsProvider from "./context/SnippetsProvider.svelte"
|
||||||
import EmbedProvider from "./context/EmbedProvider.svelte"
|
import EmbedProvider from "./context/EmbedProvider.svelte"
|
||||||
import DNDSelectionIndicators from "./preview/DNDSelectionIndicators.svelte"
|
import DNDSelectionIndicators from "./preview/DNDSelectionIndicators.svelte"
|
||||||
|
import { ActionTypes } from "@/constants"
|
||||||
|
|
||||||
// Provide contexts
|
// Provide contexts
|
||||||
|
const context = createContextStore()
|
||||||
setContext("sdk", SDK)
|
setContext("sdk", SDK)
|
||||||
setContext("component", writable({ id: null, ancestors: [] }))
|
setContext("component", writable({ id: null, ancestors: [] }))
|
||||||
setContext("context", createContextStore())
|
setContext("context", context)
|
||||||
|
|
||||||
|
// Seed context with an action to refresh all datasources
|
||||||
|
context.actions.provideAction("all", ActionTypes.RefreshDatasource, () => {
|
||||||
|
dataSourceStore.actions.refreshAll()
|
||||||
|
})
|
||||||
|
|
||||||
let dataLoaded = false
|
let dataLoaded = false
|
||||||
let permissionError = false
|
let permissionError = false
|
||||||
|
|
|
@ -100,6 +100,7 @@
|
||||||
},
|
},
|
||||||
limit,
|
limit,
|
||||||
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
|
primaryDisplay: ($fetch.definition as any)?.primaryDisplay,
|
||||||
|
loaded: $fetch.loaded,
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFetch = (datasource: ProviderDatasource) => {
|
const createFetch = (datasource: ProviderDatasource) => {
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { featuresStore } from "@/stores"
|
import { featuresStore } from "@/stores"
|
||||||
import { Grid } from "@budibase/frontend-core"
|
import { Grid } from "@budibase/frontend-core"
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
|
import { UILogicalOperator, EmptyFilterOption } from "@budibase/types"
|
||||||
|
|
||||||
// table is actually any datasource, but called table for legacy compatibility
|
// table is actually any datasource, but called table for legacy compatibility
|
||||||
export let table
|
export let table
|
||||||
|
@ -43,6 +44,8 @@
|
||||||
let gridContext
|
let gridContext
|
||||||
let minHeight = 0
|
let minHeight = 0
|
||||||
|
|
||||||
|
let filterExtensions = {}
|
||||||
|
|
||||||
$: id = $component.id
|
$: id = $component.id
|
||||||
$: currentTheme = $context?.device?.theme
|
$: currentTheme = $context?.device?.theme
|
||||||
$: darkMode = !currentTheme?.includes("light")
|
$: darkMode = !currentTheme?.includes("light")
|
||||||
|
@ -51,15 +54,73 @@
|
||||||
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
|
$: schemaOverrides = getSchemaOverrides(parsedColumns, $context)
|
||||||
$: selectedRows = deriveSelectedRows(gridContext)
|
$: selectedRows = deriveSelectedRows(gridContext)
|
||||||
$: styles = patchStyles($component.styles, minHeight)
|
$: styles = patchStyles($component.styles, minHeight)
|
||||||
$: data = { selectedRows: $selectedRows }
|
$: rowMap = gridContext?.rowLookupMap
|
||||||
|
|
||||||
|
$: data = {
|
||||||
|
selectedRows: $selectedRows,
|
||||||
|
embeddedData: {
|
||||||
|
dataSource: table,
|
||||||
|
componentId: $component.id,
|
||||||
|
loaded: !!$rowMap,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
$: actions = [
|
$: actions = [
|
||||||
{
|
{
|
||||||
type: ActionTypes.RefreshDatasource,
|
type: ActionTypes.RefreshDatasource,
|
||||||
callback: () => gridContext?.rows.actions.refreshData(),
|
callback: () => gridContext?.rows.actions.refreshData(),
|
||||||
metadata: { dataSource: table },
|
metadata: { dataSource: table },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: ActionTypes.AddDataProviderFilterExtension,
|
||||||
|
callback: addFilterExtension,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ActionTypes.RemoveDataProviderFilterExtension,
|
||||||
|
callback: removeFilterExtension,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
$: extendedFilter = extendFilter(initialFilter, filterExtensions)
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param componentId Originating Component id
|
||||||
|
* @param extension Filter extension
|
||||||
|
*/
|
||||||
|
const addFilterExtension = (componentId, extension) => {
|
||||||
|
if (!componentId || !extension) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filterExtensions = { ...filterExtensions, [componentId]: extension }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param componentId Originating Component id
|
||||||
|
* @param extension Filter extension
|
||||||
|
*/
|
||||||
|
const removeFilterExtension = componentId => {
|
||||||
|
if (!componentId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { [componentId]: removed, ...rest } = filterExtensions
|
||||||
|
filterExtensions = { ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendFilter = (initialFilter, extensions) => {
|
||||||
|
if (!Object.keys(extensions || {}).length) {
|
||||||
|
return initialFilter
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groups: (initialFilter ? [initialFilter] : []).concat(
|
||||||
|
Object.values(extensions)
|
||||||
|
),
|
||||||
|
logicalOperator: UILogicalOperator.ALL,
|
||||||
|
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Provide additional data context for live binding eval
|
// Provide additional data context for live binding eval
|
||||||
export const getAdditionalDataContext = () => {
|
export const getAdditionalDataContext = () => {
|
||||||
const gridContext = grid?.getContext()
|
const gridContext = grid?.getContext()
|
||||||
|
@ -197,7 +258,7 @@
|
||||||
{stripeRows}
|
{stripeRows}
|
||||||
{quiet}
|
{quiet}
|
||||||
{darkMode}
|
{darkMode}
|
||||||
{initialFilter}
|
initialFilter={extendedFilter}
|
||||||
{initialSortColumn}
|
{initialSortColumn}
|
||||||
{initialSortOrder}
|
{initialSortOrder}
|
||||||
{fixedRowHeight}
|
{fixedRowHeight}
|
||||||
|
|
|
@ -42,13 +42,15 @@
|
||||||
const goToPortal = () => {
|
const goToPortal = () => {
|
||||||
window.location.href = isBuilder ? "/builder/portal/apps" : "/builder/apps"
|
window.location.href = isBuilder ? "/builder/portal/apps" : "/builder/apps"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: user = $authStore as User
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $authStore}
|
{#if $authStore}
|
||||||
<ActionMenu align={compact ? "right" : "left"}>
|
<ActionMenu align={compact ? "right" : "left"}>
|
||||||
<svelte:fragment slot="control">
|
<svelte:fragment slot="control">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<UserAvatar user={$authStore} size="M" showTooltip={false} />
|
<UserAvatar {user} size="M" showTooltip={false} />
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<div class="name">
|
<div class="name">
|
||||||
|
|
|
@ -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">
|
<script lang="ts">
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
import { FieldType } from "@budibase/types"
|
import { FieldType, type Row } from "@budibase/types"
|
||||||
import RelationshipField from "./RelationshipField.svelte"
|
import RelationshipField from "./RelationshipField.svelte"
|
||||||
|
|
||||||
export let defaultValue: string
|
export let defaultValue: string
|
||||||
export let type = FieldType.BB_REFERENCE
|
export let type = FieldType.BB_REFERENCE
|
||||||
|
export let multi: boolean | undefined = undefined
|
||||||
|
export let defaultRows: Row[] | undefined = []
|
||||||
|
|
||||||
function updateUserIDs(value: string | string[]) {
|
function updateUserIDs(value: string | string[]) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
|
@ -33,4 +35,7 @@
|
||||||
datasourceType={"user"}
|
datasourceType={"user"}
|
||||||
primaryDisplay={"email"}
|
primaryDisplay={"email"}
|
||||||
defaultValue={updatedDefaultValue}
|
defaultValue={updatedDefaultValue}
|
||||||
|
{defaultRows}
|
||||||
|
{multi}
|
||||||
|
on:rows
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -14,13 +14,15 @@
|
||||||
type LegacyFilter,
|
type LegacyFilter,
|
||||||
type SearchFilterGroup,
|
type SearchFilterGroup,
|
||||||
type UISearchFilter,
|
type UISearchFilter,
|
||||||
|
type RelationshipFieldMetadata,
|
||||||
|
type Row,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import Field from "./Field.svelte"
|
import Field from "./Field.svelte"
|
||||||
import type { RelationshipFieldMetadata, Row } from "@budibase/types"
|
|
||||||
import type { FieldApi, FieldState, FieldValidation } from "@/types"
|
import type { FieldApi, FieldState, FieldValidation } from "@/types"
|
||||||
import { utils } from "@budibase/shared-core"
|
import { utils } from "@budibase/shared-core"
|
||||||
|
import { createEventDispatcher } from "svelte"
|
||||||
|
|
||||||
export let field: string | undefined = undefined
|
export let field: string | undefined = undefined
|
||||||
export let label: string | undefined = undefined
|
export let label: string | undefined = undefined
|
||||||
|
@ -30,7 +32,7 @@
|
||||||
export let validation: FieldValidation | undefined = undefined
|
export let validation: FieldValidation | undefined = undefined
|
||||||
export let autocomplete: boolean = true
|
export let autocomplete: boolean = true
|
||||||
export let defaultValue: ValueType | undefined = undefined
|
export let defaultValue: ValueType | undefined = undefined
|
||||||
export let onChange: (_props: { value: ValueType }) => void
|
export let onChange: (_props: { value: ValueType; label?: string }) => void
|
||||||
export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
|
export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
|
||||||
export let datasourceType: "table" | "user" = "table"
|
export let datasourceType: "table" | "user" = "table"
|
||||||
export let primaryDisplay: string | undefined = undefined
|
export let primaryDisplay: string | undefined = undefined
|
||||||
|
@ -41,8 +43,14 @@
|
||||||
| FieldType.BB_REFERENCE
|
| FieldType.BB_REFERENCE
|
||||||
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
||||||
|
|
||||||
|
export let multi: boolean | undefined = undefined
|
||||||
|
export let tableId: string | undefined = undefined
|
||||||
|
export let defaultRows: Row[] | undefined = []
|
||||||
|
|
||||||
const { API } = getContext("sdk")
|
const { API } = getContext("sdk")
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
// Field state
|
// Field state
|
||||||
let fieldState: FieldState<string | string[]> | undefined
|
let fieldState: FieldState<string | string[]> | undefined
|
||||||
let fieldApi: FieldApi
|
let fieldApi: FieldApi
|
||||||
|
@ -59,11 +67,11 @@
|
||||||
|
|
||||||
// Reset the available options when our base filter changes
|
// Reset the available options when our base filter changes
|
||||||
$: filter, (optionsMap = {})
|
$: filter, (optionsMap = {})
|
||||||
|
|
||||||
// Determine if we can select multiple rows or not
|
// Determine if we can select multiple rows or not
|
||||||
$: multiselect =
|
$: multiselect =
|
||||||
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
multi ??
|
||||||
fieldSchema?.relationshipType !== "one-to-many"
|
([FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
||||||
|
fieldSchema?.relationshipType !== "one-to-many")
|
||||||
|
|
||||||
// Get the proper string representation of the value
|
// Get the proper string representation of the value
|
||||||
$: realValue = fieldState?.value as ValueType
|
$: realValue = fieldState?.value as ValueType
|
||||||
|
@ -71,7 +79,7 @@
|
||||||
$: selectedIDs = getSelectedIDs(selectedValue)
|
$: selectedIDs = getSelectedIDs(selectedValue)
|
||||||
|
|
||||||
// If writable, we use a fetch to load options
|
// If writable, we use a fetch to load options
|
||||||
$: linkedTableId = fieldSchema?.tableId
|
$: linkedTableId = tableId ?? fieldSchema?.tableId
|
||||||
$: writable = !disabled && !readonly
|
$: writable = !disabled && !readonly
|
||||||
$: migratedFilter = migrateFilter(filter)
|
$: migratedFilter = migrateFilter(filter)
|
||||||
$: fetch = createFetch(
|
$: fetch = createFetch(
|
||||||
|
@ -87,7 +95,13 @@
|
||||||
|
|
||||||
// Build our options map
|
// Build our options map
|
||||||
$: rows = $fetch?.rows || []
|
$: rows = $fetch?.rows || []
|
||||||
$: processOptions(realValue, rows, primaryDisplayField)
|
$: rows && dispatch("rows", rows)
|
||||||
|
|
||||||
|
$: processOptions(
|
||||||
|
realValue,
|
||||||
|
[...rows, ...(defaultRows || [])],
|
||||||
|
primaryDisplayField
|
||||||
|
)
|
||||||
|
|
||||||
// If we ever have a value selected for which we don't have an option, we must
|
// If we ever have a value selected for which we don't have an option, we must
|
||||||
// fetch those rows to ensure we can render them as options
|
// fetch those rows to ensure we can render them as options
|
||||||
|
|
|
@ -35,6 +35,7 @@ export { default as sidepanel } from "./SidePanel.svelte"
|
||||||
export { default as modal } from "./Modal.svelte"
|
export { default as modal } from "./Modal.svelte"
|
||||||
export { default as gridblock } from "./GridBlock.svelte"
|
export { default as gridblock } from "./GridBlock.svelte"
|
||||||
export { default as textv2 } from "./Text.svelte"
|
export { default as textv2 } from "./Text.svelte"
|
||||||
|
export { default as filter } from "./filter/Filter.svelte"
|
||||||
export { default as accordion } from "./Accordion.svelte"
|
export { default as accordion } from "./Accordion.svelte"
|
||||||
export { default as singlerowprovider } from "./SingleRowProvider.svelte"
|
export { default as singlerowprovider } from "./SingleRowProvider.svelte"
|
||||||
export * from "./charts"
|
export * from "./charts"
|
||||||
|
|
|
@ -6,6 +6,8 @@ export const ActionTypes = {
|
||||||
RefreshDatasource: "RefreshDatasource",
|
RefreshDatasource: "RefreshDatasource",
|
||||||
AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
|
AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
|
||||||
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
|
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
|
||||||
|
AddDataProviderFilterExtension: "AddDataProviderFilterExtension",
|
||||||
|
RemoveDataProviderFilterExtension: "RemoveDataProviderFilterExtension",
|
||||||
SetDataProviderSorting: "SetDataProviderSorting",
|
SetDataProviderSorting: "SetDataProviderSorting",
|
||||||
ClearForm: "ClearForm",
|
ClearForm: "ClearForm",
|
||||||
ChangeFormStep: "ChangeFormStep",
|
ChangeFormStep: "ChangeFormStep",
|
||||||
|
|
|
@ -96,7 +96,10 @@ export interface SDK {
|
||||||
ActionTypes: typeof ActionTypes
|
ActionTypes: typeof ActionTypes
|
||||||
fetchDatasourceSchema: any
|
fetchDatasourceSchema: any
|
||||||
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
|
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
|
||||||
|
getRelationshipSchemaAdditions: (schema: Record<string, any>) => Promise<any>
|
||||||
|
enrichButtonActions: any
|
||||||
generateGoldenSample: any
|
generateGoldenSample: any
|
||||||
|
createContextStore: any
|
||||||
builderStore: typeof builderStore
|
builderStore: typeof builderStore
|
||||||
authStore: typeof authStore
|
authStore: typeof authStore
|
||||||
notificationStore: typeof notificationStore
|
notificationStore: typeof notificationStore
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { ActionTypes } from "./constants"
|
||||||
import {
|
import {
|
||||||
fetchDatasourceSchema,
|
fetchDatasourceSchema,
|
||||||
fetchDatasourceDefinition,
|
fetchDatasourceDefinition,
|
||||||
|
getRelationshipSchemaAdditions,
|
||||||
} from "./utils/schema"
|
} from "./utils/schema"
|
||||||
import { getAPIKey } from "./utils/api.js"
|
import { getAPIKey } from "./utils/api.js"
|
||||||
import { enrichButtonActions } from "./utils/buttonActions.js"
|
import { enrichButtonActions } from "./utils/buttonActions.js"
|
||||||
|
@ -71,6 +72,7 @@ export default {
|
||||||
getAction,
|
getAction,
|
||||||
fetchDatasourceSchema,
|
fetchDatasourceSchema,
|
||||||
fetchDatasourceDefinition,
|
fetchDatasourceDefinition,
|
||||||
|
getRelationshipSchemaAdditions,
|
||||||
fetchData,
|
fetchData,
|
||||||
QueryUtils,
|
QueryUtils,
|
||||||
ContextScopes: Constants.ContextScopes,
|
ContextScopes: Constants.ContextScopes,
|
||||||
|
|
|
@ -115,9 +115,18 @@ export const createDataSourceStore = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
get(store).forEach(instance => instance.refresh())
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: store.subscribe,
|
subscribe: store.subscribe,
|
||||||
actions: { registerDataSource, unregisterInstance, invalidateDataSource },
|
actions: {
|
||||||
|
registerDataSource,
|
||||||
|
unregisterInstance,
|
||||||
|
invalidateDataSource,
|
||||||
|
refreshAll,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export { dataSourceStore } from "./dataSource"
|
||||||
export { confirmationStore } from "./confirmation"
|
export { confirmationStore } from "./confirmation"
|
||||||
export { peekStore } from "./peek"
|
export { peekStore } from "./peek"
|
||||||
export { stateStore } from "./state"
|
export { stateStore } from "./state"
|
||||||
|
export { uiStateStore } from "./uiState"
|
||||||
export { themeStore } from "./theme"
|
export { themeStore } from "./theme"
|
||||||
export { devToolsStore } from "./devTools"
|
export { devToolsStore } from "./devTools"
|
||||||
export { componentStore } from "./components"
|
export { componentStore } from "./components"
|
||||||
|
|
|
@ -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"
|
import { Readable } from "svelte/store"
|
||||||
|
|
||||||
export * from "./components"
|
export * from "./components"
|
||||||
|
@ -5,3 +6,10 @@ export * from "./fields"
|
||||||
export * from "./forms"
|
export * from "./forms"
|
||||||
|
|
||||||
export type Context = Readable<Record<string, any>>
|
export type Context = Readable<Record<string, any>>
|
||||||
|
|
||||||
|
export type Operator =
|
||||||
|
| BasicOperator
|
||||||
|
| RangeOperator
|
||||||
|
| ArrayOperator
|
||||||
|
| "rangeLow"
|
||||||
|
| "rangeHigh"
|
||||||
|
|
|
@ -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}`] = {
|
relationshipAdditions[`${fieldKey}.${linkKey}`] = {
|
||||||
type: linkSchema[linkKey].type,
|
type: linkSchema[linkKey].type,
|
||||||
externalType: linkSchema[linkKey].externalType,
|
externalType: linkSchema[linkKey].externalType,
|
||||||
|
constraints: linkSchema[linkKey].constraints,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Avatar, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
import { Avatar, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
import type { User } from "@budibase/types"
|
||||||
|
|
||||||
export let user
|
export let user: User
|
||||||
export let size = "S"
|
export let size: "XS" | "S" | "M" = "S"
|
||||||
export let tooltipPosition = TooltipPosition.Top
|
export let tooltipPosition: TooltipPosition = TooltipPosition.Top
|
||||||
export let showTooltip = true
|
export let showTooltip: boolean = true
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<AbsTooltip
|
<AbsTooltip
|
||||||
text={showTooltip ? helpers.getUserLabel(user) : null}
|
text={showTooltip ? helpers.getUserLabel(user) : ""}
|
||||||
position={tooltipPosition}
|
position={tooltipPosition}
|
||||||
color={helpers.getUserColor(user)}
|
color={helpers.getUserColor(user)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,27 +1,31 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { UserAvatar } from "@budibase/frontend-core"
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
import { TooltipPosition, Avatar } from "@budibase/bbui"
|
import { TooltipPosition, Avatar } from "@budibase/bbui"
|
||||||
|
import type { UIUser } from "@budibase/types"
|
||||||
|
|
||||||
export let users = []
|
type OrderType = "ltr" | "rtl"
|
||||||
export let order = "ltr"
|
|
||||||
export let size = "S"
|
|
||||||
export let tooltipPosition = TooltipPosition.Top
|
|
||||||
|
|
||||||
$: uniqueUsers = unique(users, order)
|
export let users: UIUser[] = []
|
||||||
|
export let order: OrderType = "ltr"
|
||||||
|
export let size: "XS" | "S" = "S"
|
||||||
|
export let tooltipPosition: TooltipPosition = TooltipPosition.Top
|
||||||
|
|
||||||
|
$: uniqueUsers = unique(users)
|
||||||
$: avatars = getAvatars(uniqueUsers, order)
|
$: avatars = getAvatars(uniqueUsers, order)
|
||||||
|
|
||||||
const unique = users => {
|
const unique = (users: UIUser[]) => {
|
||||||
let uniqueUsers = {}
|
const uniqueUsers: Record<string, UIUser> = {}
|
||||||
users?.forEach(user => {
|
users.forEach(user => {
|
||||||
uniqueUsers[user.email] = user
|
uniqueUsers[user.email] = user
|
||||||
})
|
})
|
||||||
return Object.values(uniqueUsers)
|
return Object.values(uniqueUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvatars = (users, order) => {
|
type Overflow = { _id: "overflow"; label: string }
|
||||||
const avatars = users.slice(0, 3)
|
const getAvatars = (users: UIUser[], order: OrderType) => {
|
||||||
|
const avatars: (Overflow | UIUser)[] = users.slice(0, 3)
|
||||||
if (users.length > 3) {
|
if (users.length > 3) {
|
||||||
const overflow = {
|
const overflow: Overflow = {
|
||||||
_id: "overflow",
|
_id: "overflow",
|
||||||
label: `+${users.length - 3}`,
|
label: `+${users.length - 3}`,
|
||||||
}
|
}
|
||||||
|
@ -31,17 +35,22 @@
|
||||||
avatars.unshift(overflow)
|
avatars.unshift(overflow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return avatars.map((user, idx) => ({
|
return avatars.map((user, idx) => ({
|
||||||
...user,
|
...user,
|
||||||
zIndex: order === "ltr" ? idx : uniqueUsers.length - idx,
|
zIndex: order === "ltr" ? idx : uniqueUsers.length - idx,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUser(value: Overflow | UIUser): value is UIUser {
|
||||||
|
return value._id !== "overflow"
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="avatars">
|
<div class="avatars">
|
||||||
{#each avatars as user}
|
{#each avatars as user}
|
||||||
<span style="z-index:{user.zIndex};">
|
<span style="z-index:{user.zIndex};">
|
||||||
{#if user._id === "overflow"}
|
{#if !isUser(user)}
|
||||||
<Avatar
|
<Avatar
|
||||||
{size}
|
{size}
|
||||||
initials={user.label}
|
initials={user.label}
|
||||||
|
|
|
@ -18,7 +18,6 @@ import {
|
||||||
generateDevAppID,
|
generateDevAppID,
|
||||||
generateScreenID,
|
generateScreenID,
|
||||||
getLayoutParams,
|
getLayoutParams,
|
||||||
getScreenParams,
|
|
||||||
} from "../../db/utils"
|
} from "../../db/utils"
|
||||||
import {
|
import {
|
||||||
cache,
|
cache,
|
||||||
|
@ -90,17 +89,6 @@ async function getLayouts() {
|
||||||
).rows.map(row => row.doc!)
|
).rows.map(row => row.doc!)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScreens() {
|
|
||||||
const db = context.getAppDB()
|
|
||||||
return (
|
|
||||||
await db.allDocs<Screen>(
|
|
||||||
getScreenParams(null, {
|
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).rows.map(row => row.doc!)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserRoleId(ctx: UserCtx) {
|
function getUserRoleId(ctx: UserCtx) {
|
||||||
return !ctx.user?.role || !ctx.user.role._id
|
return !ctx.user?.role || !ctx.user.role._id
|
||||||
? roles.BUILTIN_ROLE_IDS.PUBLIC
|
? roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||||
|
@ -241,7 +229,7 @@ export async function fetchAppDefinition(
|
||||||
const userRoleId = getUserRoleId(ctx)
|
const userRoleId = getUserRoleId(ctx)
|
||||||
const accessController = new roles.AccessController()
|
const accessController = new roles.AccessController()
|
||||||
const screens = await accessController.checkScreensAccess(
|
const screens = await accessController.checkScreensAccess(
|
||||||
await getScreens(),
|
await sdk.screens.fetch(),
|
||||||
userRoleId
|
userRoleId
|
||||||
)
|
)
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -257,7 +245,7 @@ export async function fetchAppPackage(
|
||||||
const appId = context.getAppId()
|
const appId = context.getAppId()
|
||||||
const application = await sdk.applications.metadata.get()
|
const application = await sdk.applications.metadata.get()
|
||||||
const layouts = await getLayouts()
|
const layouts = await getLayouts()
|
||||||
let screens = await getScreens()
|
let screens = await sdk.screens.fetch()
|
||||||
const license = await licensing.cache.getCachedLicense()
|
const license = await licensing.cache.getCachedLicense()
|
||||||
|
|
||||||
// Enrich plugin URLs
|
// Enrich plugin URLs
|
||||||
|
@ -915,7 +903,7 @@ async function migrateAppNavigation() {
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
const existing = await sdk.applications.metadata.get()
|
const existing = await sdk.applications.metadata.get()
|
||||||
const layouts: Layout[] = await getLayouts()
|
const layouts: Layout[] = await getLayouts()
|
||||||
const screens: Screen[] = await getScreens()
|
const screens: Screen[] = await sdk.screens.fetch()
|
||||||
|
|
||||||
// Migrate all screens, removing custom layouts
|
// Migrate all screens, removing custom layouts
|
||||||
for (let screen of screens) {
|
for (let screen of screens) {
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { EMPTY_LAYOUT } from "../../constants/layouts"
|
import { EMPTY_LAYOUT } from "../../constants/layouts"
|
||||||
import { generateLayoutID, getScreenParams } from "../../db/utils"
|
import { generateLayoutID } from "../../db/utils"
|
||||||
import { events, context } from "@budibase/backend-core"
|
import { events, context } from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
DeleteLayoutResponse,
|
DeleteLayoutResponse,
|
||||||
Layout,
|
|
||||||
SaveLayoutRequest,
|
SaveLayoutRequest,
|
||||||
SaveLayoutResponse,
|
SaveLayoutResponse,
|
||||||
UserCtx,
|
UserCtx,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
import sdk from "../../sdk"
|
||||||
|
|
||||||
export async function save(
|
export async function save(
|
||||||
ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse>
|
ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse>
|
||||||
|
@ -36,13 +36,9 @@ export async function destroy(ctx: UserCtx<void, DeleteLayoutResponse>) {
|
||||||
const layoutId = ctx.params.layoutId,
|
const layoutId = ctx.params.layoutId,
|
||||||
layoutRev = ctx.params.layoutRev
|
layoutRev = ctx.params.layoutRev
|
||||||
|
|
||||||
const layoutsUsedByScreens = (
|
const layoutsUsedByScreens = (await sdk.screens.fetch()).map(
|
||||||
await db.allDocs<Layout>(
|
element => element.layoutId
|
||||||
getScreenParams(null, {
|
)
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).rows.map(element => element.doc!.layoutId)
|
|
||||||
if (layoutsUsedByScreens.includes(layoutId)) {
|
if (layoutsUsedByScreens.includes(layoutId)) {
|
||||||
ctx.throw(400, "Cannot delete a layout that's being used by a screen")
|
ctx.throw(400, "Cannot delete a layout that's being used by a screen")
|
||||||
}
|
}
|
||||||
|
|
|
@ -918,7 +918,9 @@ if (descriptions.length) {
|
||||||
|
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
it("reads an existing row successfully", async () => {
|
it("reads an existing row successfully", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
const res = await config.api.row.get(table._id!, existing._id!)
|
const res = await config.api.row.get(table._id!, existing._id!)
|
||||||
|
|
||||||
|
@ -930,7 +932,7 @@ if (descriptions.length) {
|
||||||
|
|
||||||
it("returns 404 when row does not exist", async () => {
|
it("returns 404 when row does not exist", async () => {
|
||||||
const table = await config.api.table.save(defaultTable())
|
const table = await config.api.table.save(defaultTable())
|
||||||
await config.api.row.save(table._id!, {})
|
await config.api.row.save(table._id!, { name: "foo" })
|
||||||
await config.api.row.get(table._id!, "1234567", {
|
await config.api.row.get(table._id!, "1234567", {
|
||||||
status: 404,
|
status: 404,
|
||||||
})
|
})
|
||||||
|
@ -958,8 +960,8 @@ if (descriptions.length) {
|
||||||
it("fetches all rows for given tableId", async () => {
|
it("fetches all rows for given tableId", async () => {
|
||||||
const table = await config.api.table.save(defaultTable())
|
const table = await config.api.table.save(defaultTable())
|
||||||
const rows = await Promise.all([
|
const rows = await Promise.all([
|
||||||
config.api.row.save(table._id!, {}),
|
config.api.row.save(table._id!, { name: "foo" }),
|
||||||
config.api.row.save(table._id!, {}),
|
config.api.row.save(table._id!, { name: "bar" }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const res = await config.api.row.fetch(table._id!)
|
const res = await config.api.row.fetch(table._id!)
|
||||||
|
@ -975,7 +977,9 @@ if (descriptions.length) {
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
it("updates an existing row successfully", async () => {
|
it("updates an existing row successfully", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
await expectRowUsage(0, async () => {
|
await expectRowUsage(0, async () => {
|
||||||
const res = await config.api.row.save(table._id!, {
|
const res = await config.api.row.save(table._id!, {
|
||||||
|
@ -1166,7 +1170,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should update only the fields that are supplied", async () => {
|
it("should update only the fields that are supplied", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
await expectRowUsage(0, async () => {
|
await expectRowUsage(0, async () => {
|
||||||
const row = await config.api.row.patch(table._id!, {
|
const row = await config.api.row.patch(table._id!, {
|
||||||
|
@ -1186,6 +1192,22 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not require the primary display", async () => {
|
||||||
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
description: "bar",
|
||||||
|
})
|
||||||
|
await expectRowUsage(0, async () => {
|
||||||
|
const row = await config.api.row.patch(table._id!, {
|
||||||
|
_id: existing._id!,
|
||||||
|
_rev: existing._rev!,
|
||||||
|
tableId: table._id!,
|
||||||
|
description: "baz",
|
||||||
|
})
|
||||||
|
expect(row.description).toEqual("baz")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("should update only the fields that are supplied and emit the correct oldRow", async () => {
|
it("should update only the fields that are supplied and emit the correct oldRow", async () => {
|
||||||
let beforeRow = await config.api.row.save(table._id!, {
|
let beforeRow = await config.api.row.save(table._id!, {
|
||||||
name: "test",
|
name: "test",
|
||||||
|
@ -1213,7 +1235,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should throw an error when given improper types", async () => {
|
it("should throw an error when given improper types", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
await expectRowUsage(0, async () => {
|
await expectRowUsage(0, async () => {
|
||||||
await config.api.row.patch(
|
await config.api.row.patch(
|
||||||
|
@ -1289,6 +1313,7 @@ if (descriptions.length) {
|
||||||
description: "test",
|
description: "test",
|
||||||
})
|
})
|
||||||
const { _id } = await config.api.row.save(table._id!, {
|
const { _id } = await config.api.row.save(table._id!, {
|
||||||
|
name: "test",
|
||||||
relationship: [{ _id: row._id }, { _id: row2._id }],
|
relationship: [{ _id: row._id }, { _id: row2._id }],
|
||||||
})
|
})
|
||||||
const relatedRow = await config.api.row.get(table._id!, _id!, {
|
const relatedRow = await config.api.row.get(table._id!, _id!, {
|
||||||
|
@ -1440,7 +1465,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to delete a row", async () => {
|
it("should be able to delete a row", async () => {
|
||||||
const createdRow = await config.api.row.save(table._id!, {})
|
const createdRow = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||||
const res = await config.api.row.bulkDelete(table._id!, {
|
const res = await config.api.row.bulkDelete(table._id!, {
|
||||||
|
@ -1451,7 +1478,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to delete a row with ID only", async () => {
|
it("should be able to delete a row with ID only", async () => {
|
||||||
const createdRow = await config.api.row.save(table._id!, {})
|
const createdRow = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
|
|
||||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||||
const res = await config.api.row.bulkDelete(table._id!, {
|
const res = await config.api.row.bulkDelete(table._id!, {
|
||||||
|
@ -1463,8 +1492,12 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to bulk delete rows, including a row that doesn't exist", async () => {
|
it("should be able to bulk delete rows, including a row that doesn't exist", async () => {
|
||||||
const createdRow = await config.api.row.save(table._id!, {})
|
const createdRow = await config.api.row.save(table._id!, {
|
||||||
const createdRow2 = await config.api.row.save(table._id!, {})
|
name: "foo",
|
||||||
|
})
|
||||||
|
const createdRow2 = await config.api.row.save(table._id!, {
|
||||||
|
name: "bar",
|
||||||
|
})
|
||||||
|
|
||||||
const res = await config.api.row.bulkDelete(table._id!, {
|
const res = await config.api.row.bulkDelete(table._id!, {
|
||||||
rows: [createdRow, createdRow2, { _id: "9999999" }],
|
rows: [createdRow, createdRow2, { _id: "9999999" }],
|
||||||
|
@ -1581,8 +1614,8 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to delete a bulk set of rows", async () => {
|
it("should be able to delete a bulk set of rows", async () => {
|
||||||
const row1 = await config.api.row.save(table._id!, {})
|
const row1 = await config.api.row.save(table._id!, { name: "foo" })
|
||||||
const row2 = await config.api.row.save(table._id!, {})
|
const row2 = await config.api.row.save(table._id!, { name: "bar" })
|
||||||
|
|
||||||
await expectRowUsage(isInternal ? -2 : 0, async () => {
|
await expectRowUsage(isInternal ? -2 : 0, async () => {
|
||||||
const res = await config.api.row.bulkDelete(table._id!, {
|
const res = await config.api.row.bulkDelete(table._id!, {
|
||||||
|
@ -1596,9 +1629,9 @@ if (descriptions.length) {
|
||||||
|
|
||||||
it("should be able to delete a variety of row set types", async () => {
|
it("should be able to delete a variety of row set types", async () => {
|
||||||
const [row1, row2, row3] = await Promise.all([
|
const [row1, row2, row3] = await Promise.all([
|
||||||
config.api.row.save(table._id!, {}),
|
config.api.row.save(table._id!, { name: "foo" }),
|
||||||
config.api.row.save(table._id!, {}),
|
config.api.row.save(table._id!, { name: "bar" }),
|
||||||
config.api.row.save(table._id!, {}),
|
config.api.row.save(table._id!, { name: "baz" }),
|
||||||
])
|
])
|
||||||
|
|
||||||
await expectRowUsage(isInternal ? -3 : 0, async () => {
|
await expectRowUsage(isInternal ? -3 : 0, async () => {
|
||||||
|
@ -1612,7 +1645,7 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should accept a valid row object and delete the row", async () => {
|
it("should accept a valid row object and delete the row", async () => {
|
||||||
const row1 = await config.api.row.save(table._id!, {})
|
const row1 = await config.api.row.save(table._id!, { name: "foo" })
|
||||||
|
|
||||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||||
const res = await config.api.row.delete(
|
const res = await config.api.row.delete(
|
||||||
|
@ -2347,7 +2380,9 @@ if (descriptions.length) {
|
||||||
|
|
||||||
!isInternal &&
|
!isInternal &&
|
||||||
it("should allow exporting all columns", async () => {
|
it("should allow exporting all columns", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
const res = await config.api.row.exportRows(table._id!, {
|
const res = await config.api.row.exportRows(table._id!, {
|
||||||
rows: [existing._id!],
|
rows: [existing._id!],
|
||||||
})
|
})
|
||||||
|
@ -2363,7 +2398,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow exporting without filtering", async () => {
|
it("should allow exporting without filtering", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
const res = await config.api.row.exportRows(table._id!)
|
const res = await config.api.row.exportRows(table._id!)
|
||||||
const results = JSON.parse(res)
|
const results = JSON.parse(res)
|
||||||
expect(results.length).toEqual(1)
|
expect(results.length).toEqual(1)
|
||||||
|
@ -2373,7 +2410,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow exporting only certain columns", async () => {
|
it("should allow exporting only certain columns", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
const res = await config.api.row.exportRows(table._id!, {
|
const res = await config.api.row.exportRows(table._id!, {
|
||||||
rows: [existing._id!],
|
rows: [existing._id!],
|
||||||
columns: ["_id"],
|
columns: ["_id"],
|
||||||
|
@ -2388,7 +2427,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should handle single quotes in row filtering", async () => {
|
it("should handle single quotes in row filtering", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
const res = await config.api.row.exportRows(table._id!, {
|
const res = await config.api.row.exportRows(table._id!, {
|
||||||
rows: [`['${existing._id!}']`],
|
rows: [`['${existing._id!}']`],
|
||||||
})
|
})
|
||||||
|
@ -2399,7 +2440,9 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return an error if no table is found", async () => {
|
it("should return an error if no table is found", async () => {
|
||||||
const existing = await config.api.row.save(table._id!, {})
|
const existing = await config.api.row.save(table._id!, {
|
||||||
|
name: "foo",
|
||||||
|
})
|
||||||
await config.api.row.exportRows(
|
await config.api.row.exportRows(
|
||||||
"1234567",
|
"1234567",
|
||||||
{ rows: [existing._id!] },
|
{ rows: [existing._id!] },
|
||||||
|
|
|
@ -1897,7 +1897,7 @@ if (descriptions.length) {
|
||||||
tableOrViewId = await createTableOrView({
|
tableOrViewId = await createTableOrView({
|
||||||
product: { name: "product", type: FieldType.STRING },
|
product: { name: "product", type: FieldType.STRING },
|
||||||
ai: {
|
ai: {
|
||||||
name: "AI",
|
name: "ai",
|
||||||
type: FieldType.AI,
|
type: FieldType.AI,
|
||||||
operation: AIOperationEnum.PROMPT,
|
operation: AIOperationEnum.PROMPT,
|
||||||
prompt: "Translate '{{ product }}' into German",
|
prompt: "Translate '{{ product }}' into German",
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
SourceName,
|
SourceName,
|
||||||
VirtualDocumentType,
|
VirtualDocumentType,
|
||||||
LinkDocument,
|
LinkDocument,
|
||||||
|
AIFieldMetadata,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
export { DocumentType, VirtualDocumentType } from "@budibase/types"
|
export { DocumentType, VirtualDocumentType } from "@budibase/types"
|
||||||
|
@ -338,6 +339,10 @@ export function isRelationshipColumn(
|
||||||
return column.type === FieldType.LINK
|
return column.type === FieldType.LINK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAIColumn(column: FieldSchema): column is AIFieldMetadata {
|
||||||
|
return column.type === FieldType.AI
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new row actions ID.
|
* Generates a new row actions ID.
|
||||||
* @returns The new row actions ID which the row actions doc can be stored under.
|
* @returns The new row actions ID which the row actions doc can be stored under.
|
||||||
|
|
|
@ -4,8 +4,8 @@ import {
|
||||||
getDatasourceParams,
|
getDatasourceParams,
|
||||||
getTableParams,
|
getTableParams,
|
||||||
getAutomationParams,
|
getAutomationParams,
|
||||||
getScreenParams,
|
|
||||||
} from "../../../db/utils"
|
} from "../../../db/utils"
|
||||||
|
import sdk from "../.."
|
||||||
|
|
||||||
async function runInContext(appId: string, cb: any, db?: Database) {
|
async function runInContext(appId: string, cb: any, db?: Database) {
|
||||||
if (db) {
|
if (db) {
|
||||||
|
@ -46,8 +46,8 @@ export async function calculateScreenCount(appId: string, db?: Database) {
|
||||||
return runInContext(
|
return runInContext(
|
||||||
appId,
|
appId,
|
||||||
async (db: Database) => {
|
async (db: Database) => {
|
||||||
const screenList = await db.allDocs(getScreenParams())
|
const screenList = await sdk.screens.fetch(db)
|
||||||
return screenList.rows.length
|
return screenList.length
|
||||||
},
|
},
|
||||||
db
|
db
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)) {
|
for (let fieldName of Object.keys(table.schema)) {
|
||||||
const column = table.schema[fieldName]
|
const column = table.schema[fieldName]
|
||||||
const constraints = cloneDeep(column.constraints)
|
|
||||||
const type = column.type
|
const type = column.type
|
||||||
|
let constraints = cloneDeep(column.constraints)
|
||||||
|
|
||||||
|
// Ensure display column is required
|
||||||
|
if (table.primaryDisplay === fieldName) {
|
||||||
|
constraints = { ...constraints, presence: true }
|
||||||
|
}
|
||||||
|
|
||||||
// foreign keys are likely to be enriched
|
// foreign keys are likely to be enriched
|
||||||
if (isForeignKey(fieldName, table)) {
|
if (isForeignKey(fieldName, table)) {
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -2,9 +2,7 @@ import { getScreenParams } from "../../../db/utils"
|
||||||
import { context } from "@budibase/backend-core"
|
import { context } from "@budibase/backend-core"
|
||||||
import { Screen } from "@budibase/types"
|
import { Screen } from "@budibase/types"
|
||||||
|
|
||||||
export async function fetch(): Promise<Screen[]> {
|
export async function fetch(db = context.getAppDB()): Promise<Screen[]> {
|
||||||
const db = context.getAppDB()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await db.allDocs<Screen>(
|
await db.allDocs<Screen>(
|
||||||
getScreenParams(null, {
|
getScreenParams(null, {
|
||||||
|
|
|
@ -17,177 +17,234 @@ import datasources from "../datasources"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import { ensureQueryUISet } from "../views/utils"
|
import { ensureQueryUISet } from "../views/utils"
|
||||||
import { isV2 } from "../views"
|
import { isV2 } from "../views"
|
||||||
|
import { tracer } from "dd-trace"
|
||||||
|
|
||||||
export async function processTable(table: Table): Promise<Table> {
|
export async function processTable(table: Table): Promise<Table> {
|
||||||
if (!table) {
|
return await tracer.trace("processTable", async span => {
|
||||||
return table
|
if (!table) {
|
||||||
}
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
table = { ...table }
|
span.addTags({ tableId: table._id })
|
||||||
if (table.views) {
|
|
||||||
for (const [key, view] of Object.entries(table.views)) {
|
table = { ...table }
|
||||||
if (!isV2(view)) {
|
if (table.views) {
|
||||||
continue
|
span.addTags({ numViews: Object.keys(table.views).length })
|
||||||
|
for (const [key, view] of Object.entries(table.views)) {
|
||||||
|
if (!isV2(view)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
table.views[key] = ensureQueryUISet(view)
|
||||||
}
|
}
|
||||||
table.views[key] = ensureQueryUISet(view)
|
|
||||||
}
|
}
|
||||||
}
|
if (table._id && isExternalTableID(table._id)) {
|
||||||
if (table._id && isExternalTableID(table._id)) {
|
span.addTags({ isExternal: true })
|
||||||
// Old created external tables via Budibase might have a missing field name breaking some UI such as filters
|
// Old created external tables via Budibase might have a missing field name breaking some UI such as filters
|
||||||
if (table.schema["id"] && !table.schema["id"].name) {
|
if (table.schema["id"] && !table.schema["id"].name) {
|
||||||
table.schema["id"].name = "id"
|
table.schema["id"].name = "id"
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...table,
|
||||||
|
type: "table",
|
||||||
|
sourceType: TableSourceType.EXTERNAL,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span.addTags({ isExternal: false })
|
||||||
|
const processed: Table = {
|
||||||
|
...table,
|
||||||
|
type: "table",
|
||||||
|
primary: ["_id"], // internal tables must always use _id as primary key
|
||||||
|
sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
|
||||||
|
sourceType: TableSourceType.INTERNAL,
|
||||||
|
sql: true,
|
||||||
|
}
|
||||||
|
return processed
|
||||||
}
|
}
|
||||||
return {
|
})
|
||||||
...table,
|
|
||||||
type: "table",
|
|
||||||
sourceType: TableSourceType.EXTERNAL,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const processed: Table = {
|
|
||||||
...table,
|
|
||||||
type: "table",
|
|
||||||
primary: ["_id"], // internal tables must always use _id as primary key
|
|
||||||
sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
|
|
||||||
sourceType: TableSourceType.INTERNAL,
|
|
||||||
sql: true,
|
|
||||||
}
|
|
||||||
return processed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processTables(tables: Table[]): Promise<Table[]> {
|
export async function processTables(tables: Table[]): Promise<Table[]> {
|
||||||
return await Promise.all(tables.map(table => processTable(table)))
|
return await tracer.trace("processTables", async span => {
|
||||||
|
span.addTags({ numTables: tables.length })
|
||||||
|
return await Promise.all(tables.map(table => processTable(table)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processEntities(tables: Record<string, Table>) {
|
async function processEntities(tables: Record<string, Table>) {
|
||||||
for (let key of Object.keys(tables)) {
|
return await tracer.trace("processEntities", async span => {
|
||||||
tables[key] = await processTable(tables[key])
|
span.addTags({ numTables: Object.keys(tables).length })
|
||||||
}
|
for (let key of Object.keys(tables)) {
|
||||||
return tables
|
tables[key] = await processTable(tables[key])
|
||||||
|
}
|
||||||
|
return tables
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllInternalTables(db?: Database): Promise<Table[]> {
|
export async function getAllInternalTables(db?: Database): Promise<Table[]> {
|
||||||
if (!db) {
|
return await tracer.trace("getAllInternalTables", async span => {
|
||||||
db = context.getAppDB()
|
if (!db) {
|
||||||
}
|
db = context.getAppDB()
|
||||||
const internalTables = await db.allDocs<Table>(
|
}
|
||||||
getTableParams(null, {
|
span.addTags({ db: db.name })
|
||||||
include_docs: true,
|
const internalTables = await db.allDocs<Table>(
|
||||||
})
|
getTableParams(null, {
|
||||||
)
|
include_docs: true,
|
||||||
return await processTables(internalTables.rows.map(row => row.doc!))
|
})
|
||||||
|
)
|
||||||
|
span.addTags({ numTables: internalTables.rows.length })
|
||||||
|
return await processTables(internalTables.rows.map(row => row.doc!))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllExternalTables(): Promise<Table[]> {
|
async function getAllExternalTables(): Promise<Table[]> {
|
||||||
// this is all datasources, we'll need to filter out internal
|
return await tracer.trace("getAllExternalTables", async span => {
|
||||||
const datasources = await sdk.datasources.fetch({ enriched: true })
|
// this is all datasources, we'll need to filter out internal
|
||||||
const allEntities = datasources
|
const datasources = await sdk.datasources.fetch({ enriched: true })
|
||||||
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
|
span.addTags({ numDatasources: datasources.length })
|
||||||
.map(datasource => datasource.entities)
|
|
||||||
let final: Table[] = []
|
const allEntities = datasources
|
||||||
for (let entities of allEntities) {
|
.filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID)
|
||||||
if (entities) {
|
.map(datasource => datasource.entities)
|
||||||
final = final.concat(Object.values(entities))
|
span.addTags({ numEntities: allEntities.length })
|
||||||
|
|
||||||
|
let final: Table[] = []
|
||||||
|
for (let entities of allEntities) {
|
||||||
|
if (entities) {
|
||||||
|
final = final.concat(Object.values(entities))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
span.addTags({ numTables: final.length })
|
||||||
return await processTables(final)
|
return await processTables(final)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExternalTable(
|
export async function getExternalTable(
|
||||||
datasourceId: string,
|
datasourceId: string,
|
||||||
tableName: string
|
tableName: string
|
||||||
): Promise<Table> {
|
): Promise<Table> {
|
||||||
const entities = await getExternalTablesInDatasource(datasourceId)
|
return await tracer.trace("getExternalTable", async span => {
|
||||||
if (!entities[tableName]) {
|
span.addTags({ datasourceId, tableName })
|
||||||
throw new Error(`Unable to find table named "${tableName}"`)
|
const entities = await getExternalTablesInDatasource(datasourceId)
|
||||||
}
|
if (!entities[tableName]) {
|
||||||
const table = await processTable(entities[tableName])
|
throw new Error(`Unable to find table named "${tableName}"`)
|
||||||
if (!table.sourceId) {
|
}
|
||||||
table.sourceId = datasourceId
|
const table = await processTable(entities[tableName])
|
||||||
}
|
if (!table.sourceId) {
|
||||||
return table
|
table.sourceId = datasourceId
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTable(tableId: string): Promise<Table> {
|
export async function getTable(tableId: string): Promise<Table> {
|
||||||
const db = context.getAppDB()
|
return await tracer.trace("getTable", async span => {
|
||||||
let output: Table
|
const db = context.getAppDB()
|
||||||
if (tableId && isExternalTableID(tableId)) {
|
span.addTags({ tableId, db: db.name })
|
||||||
let { datasourceId, tableName } = breakExternalTableId(tableId)
|
let output: Table
|
||||||
const datasource = await datasources.get(datasourceId)
|
if (tableId && isExternalTableID(tableId)) {
|
||||||
const table = await getExternalTable(datasourceId, tableName)
|
let { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||||
output = { ...table, sql: isSQL(datasource) }
|
span.addTags({ isExternal: true, datasourceId, tableName })
|
||||||
} else {
|
const datasource = await datasources.get(datasourceId)
|
||||||
output = await db.get<Table>(tableId)
|
const table = await getExternalTable(datasourceId, tableName)
|
||||||
}
|
output = { ...table, sql: isSQL(datasource) }
|
||||||
return await processTable(output)
|
span.addTags({ isSQL: isSQL(datasource) })
|
||||||
|
} else {
|
||||||
|
output = await db.get<Table>(tableId)
|
||||||
|
}
|
||||||
|
return await processTable(output)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doesTableExist(tableId: string): Promise<boolean> {
|
export async function doesTableExist(tableId: string): Promise<boolean> {
|
||||||
try {
|
return await tracer.trace("doesTableExist", async span => {
|
||||||
const table = await getTable(tableId)
|
span.addTags({ tableId })
|
||||||
return !!table
|
try {
|
||||||
} catch (err) {
|
const table = await getTable(tableId)
|
||||||
return false
|
span.addTags({ tableExists: !!table })
|
||||||
}
|
return !!table
|
||||||
|
} catch (err) {
|
||||||
|
span.addTags({ tableExists: false })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTables() {
|
export async function getAllTables() {
|
||||||
const [internal, external] = await Promise.all([
|
return await tracer.trace("getAllTables", async span => {
|
||||||
getAllInternalTables(),
|
const [internal, external] = await Promise.all([
|
||||||
getAllExternalTables(),
|
getAllInternalTables(),
|
||||||
])
|
getAllExternalTables(),
|
||||||
return await processTables([...internal, ...external])
|
])
|
||||||
|
span.addTags({
|
||||||
|
numInternalTables: internal.length,
|
||||||
|
numExternalTables: external.length,
|
||||||
|
})
|
||||||
|
return await processTables([...internal, ...external])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExternalTablesInDatasource(
|
export async function getExternalTablesInDatasource(
|
||||||
datasourceId: string
|
datasourceId: string
|
||||||
): Promise<Record<string, Table>> {
|
): Promise<Record<string, Table>> {
|
||||||
const datasource = await datasources.get(datasourceId, { enriched: true })
|
return await tracer.trace("getExternalTablesInDatasource", async span => {
|
||||||
if (!datasource || !datasource.entities) {
|
const datasource = await datasources.get(datasourceId, { enriched: true })
|
||||||
throw new Error("Datasource is not configured fully.")
|
if (!datasource || !datasource.entities) {
|
||||||
}
|
throw new Error("Datasource is not configured fully.")
|
||||||
return await processEntities(datasource.entities)
|
}
|
||||||
|
span.addTags({
|
||||||
|
datasourceId,
|
||||||
|
numEntities: Object.keys(datasource.entities).length,
|
||||||
|
})
|
||||||
|
return await processEntities(datasource.entities)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTables(tableIds: string[]): Promise<Table[]> {
|
export async function getTables(tableIds: string[]): Promise<Table[]> {
|
||||||
const externalTableIds = tableIds.filter(tableId =>
|
return tracer.trace("getTables", async span => {
|
||||||
isExternalTableID(tableId)
|
span.addTags({ numTableIds: tableIds.length })
|
||||||
),
|
const externalTableIds = tableIds.filter(tableId =>
|
||||||
internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
|
isExternalTableID(tableId)
|
||||||
let tables: Table[] = []
|
),
|
||||||
if (externalTableIds.length) {
|
internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
|
||||||
const externalTables = await getAllExternalTables()
|
let tables: Table[] = []
|
||||||
tables = tables.concat(
|
if (externalTableIds.length) {
|
||||||
externalTables.filter(
|
const externalTables = await getAllExternalTables()
|
||||||
table => externalTableIds.indexOf(table._id!) !== -1
|
tables = tables.concat(
|
||||||
|
externalTables.filter(
|
||||||
|
table => externalTableIds.indexOf(table._id!) !== -1
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
if (internalTableIds.length) {
|
||||||
if (internalTableIds.length) {
|
const db = context.getAppDB()
|
||||||
const db = context.getAppDB()
|
const internalTables = await db.getMultiple<Table>(internalTableIds, {
|
||||||
const internalTables = await db.getMultiple<Table>(internalTableIds, {
|
allowMissing: true,
|
||||||
allowMissing: true,
|
})
|
||||||
})
|
tables = tables.concat(internalTables)
|
||||||
tables = tables.concat(internalTables)
|
}
|
||||||
}
|
span.addTags({ numTables: tables.length })
|
||||||
return await processTables(tables)
|
return await processTables(tables)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichViewSchemas(
|
export async function enrichViewSchemas(
|
||||||
table: Table
|
table: Table
|
||||||
): Promise<FindTableResponse> {
|
): Promise<FindTableResponse> {
|
||||||
const views = []
|
return await tracer.trace("enrichViewSchemas", async span => {
|
||||||
for (const view of Object.values(table.views ?? [])) {
|
span.addTags({ tableId: table._id })
|
||||||
if (sdk.views.isV2(view)) {
|
const views = []
|
||||||
views.push(await sdk.views.enrichSchema(view, table.schema))
|
for (const view of Object.values(table.views ?? [])) {
|
||||||
} else views.push(view)
|
if (sdk.views.isV2(view)) {
|
||||||
}
|
views.push(await sdk.views.enrichSchema(view, table.schema))
|
||||||
|
} else views.push(view)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...table,
|
...table,
|
||||||
views: views.reduce((p, v) => {
|
views: views.reduce((p, v) => {
|
||||||
p[v.name!] = v
|
p[v.name!] = v
|
||||||
return p
|
return p
|
||||||
}, {} as TableViewsResponse),
|
}, {} as TableViewsResponse),
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
import { AutoFieldDefaultNames } from "../../constants"
|
import { context } from "@budibase/backend-core"
|
||||||
|
import { ai } from "@budibase/pro"
|
||||||
|
import { OperationFields } from "@budibase/shared-core"
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import {
|
import {
|
||||||
AutoColumnFieldMetadata,
|
AutoColumnFieldMetadata,
|
||||||
|
AutoFieldSubType,
|
||||||
FieldSchema,
|
FieldSchema,
|
||||||
|
FieldType,
|
||||||
|
FormulaType,
|
||||||
|
OperationFieldTypeEnum,
|
||||||
Row,
|
Row,
|
||||||
Table,
|
Table,
|
||||||
FormulaType,
|
|
||||||
AutoFieldSubType,
|
|
||||||
FieldType,
|
|
||||||
OperationFieldTypeEnum,
|
|
||||||
AIOperationEnum,
|
|
||||||
AIFieldMetadata,
|
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { OperationFields } from "@budibase/shared-core"
|
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
import { context } from "@budibase/backend-core"
|
import { AutoFieldDefaultNames } from "../../constants"
|
||||||
import { ai } from "@budibase/pro"
|
import { isAIColumn } from "../../db/utils"
|
||||||
import { coerce } from "./index"
|
import { coerce } from "./index"
|
||||||
|
|
||||||
interface FormulaOpts {
|
interface FormulaOpts {
|
||||||
|
@ -122,52 +121,56 @@ export async function processAIColumns<T extends Row | Row[]>(
|
||||||
inputRows: T,
|
inputRows: T,
|
||||||
{ contextRows }: FormulaOpts
|
{ contextRows }: FormulaOpts
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const aiColumns = Object.values(table.schema).filter(isAIColumn)
|
||||||
|
if (!aiColumns.length) {
|
||||||
|
return inputRows
|
||||||
|
}
|
||||||
|
|
||||||
return tracer.trace("processAIColumns", {}, async span => {
|
return tracer.trace("processAIColumns", {}, async span => {
|
||||||
const numRows = Array.isArray(inputRows) ? inputRows.length : 1
|
const numRows = Array.isArray(inputRows) ? inputRows.length : 1
|
||||||
span?.addTags({ table_id: table._id, numRows })
|
span?.addTags({ table_id: table._id, numRows })
|
||||||
const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
|
const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
|
||||||
|
|
||||||
const llm = await ai.getLLM()
|
const llm = await ai.getLLM()
|
||||||
if (rows && llm) {
|
if (rows && llm) {
|
||||||
// Ensure we have snippet context
|
// Ensure we have snippet context
|
||||||
await context.ensureSnippetContext()
|
await context.ensureSnippetContext()
|
||||||
|
|
||||||
for (let [column, schema] of Object.entries(table.schema)) {
|
const aiColumns = Object.values(table.schema).filter(isAIColumn)
|
||||||
if (schema.type !== FieldType.AI) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = schema.operation
|
const rowUpdates = rows.flatMap((row, i) => {
|
||||||
const aiSchema: AIFieldMetadata = schema
|
const contextRow = contextRows ? contextRows[i] : row
|
||||||
const rowUpdates = rows.map((row, i) => {
|
|
||||||
const contextRow = contextRows ? contextRows[i] : row
|
return aiColumns.map(aiColumn => {
|
||||||
|
const column = aiColumn.name
|
||||||
|
|
||||||
// Check if the type is bindable and pass through HBS if so
|
// Check if the type is bindable and pass through HBS if so
|
||||||
const operationField = OperationFields[operation as AIOperationEnum]
|
const operationField = OperationFields[aiColumn.operation]
|
||||||
for (const key in schema) {
|
for (const key in aiColumn) {
|
||||||
const fieldType = operationField[key as keyof typeof operationField]
|
const fieldType = operationField[key as keyof typeof operationField]
|
||||||
if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) {
|
if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) {
|
||||||
// @ts-ignore
|
// @ts-expect-error: keys are not casted
|
||||||
schema[key] = processStringSync(schema[key], contextRow)
|
aiColumn[key] = processStringSync(aiColumn[key], contextRow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracer.trace("processAIColumn", {}, async span => {
|
return tracer.trace("processAIColumn", {}, async span => {
|
||||||
span?.addTags({ table_id: table._id, column })
|
span?.addTags({ table_id: table._id, column })
|
||||||
const llmResponse = await llm.operation(aiSchema, row)
|
const llmResponse = await llm.operation(aiColumn, row)
|
||||||
return {
|
return {
|
||||||
...row,
|
rowIndex: i,
|
||||||
[column]: llmResponse.message,
|
columnName: column,
|
||||||
|
value: llmResponse.message,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const processedRows = await Promise.all(rowUpdates)
|
const processedAIColumns = await Promise.all(rowUpdates)
|
||||||
|
|
||||||
// Promise.all is deterministic so can rely on the indexing here
|
processedAIColumns.forEach(aiColumn => {
|
||||||
processedRows.forEach(
|
rows[aiColumn.rowIndex][aiColumn.columnName] = aiColumn.value
|
||||||
(processedRow, index) => (rows[index] = processedRow)
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Array.isArray(inputRows) ? rows : rows[0]
|
return Array.isArray(inputRows) ? rows : rows[0]
|
||||||
})
|
})
|
||||||
|
|
|
@ -323,7 +323,13 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value = new Date(value).toISOString()
|
if (typeof value === "string") {
|
||||||
|
value = new Date(value).toISOString()
|
||||||
|
} else if (isRangeSearchOperator(operator)) {
|
||||||
|
query[operator] ??= {}
|
||||||
|
query[operator][field] = value
|
||||||
|
return query
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case FieldType.NUMBER:
|
case FieldType.NUMBER:
|
||||||
|
@ -349,7 +355,6 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined {
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRangeSearchOperator(operator)) {
|
if (isRangeSearchOperator(operator)) {
|
||||||
const key = externalType as keyof typeof SqlNumberTypeRangeMap
|
const key = externalType as keyof typeof SqlNumberTypeRangeMap
|
||||||
const limits = SqlNumberTypeRangeMap[key] || {
|
const limits = SqlNumberTypeRangeMap[key] || {
|
||||||
|
@ -637,7 +642,6 @@ export function runQuery<T extends Record<string, any>>(
|
||||||
if (docValue == null || docValue === "") {
|
if (docValue == null || docValue === "") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlainObject(testValue.low) && isEmpty(testValue.low)) {
|
if (isPlainObject(testValue.low) && isEmpty(testValue.low)) {
|
||||||
testValue.low = undefined
|
testValue.low = undefined
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { FieldType } from "@budibase/types"
|
||||||
|
|
||||||
export * from "./codeEditor"
|
export * from "./codeEditor"
|
||||||
export * from "./errors"
|
export * from "./errors"
|
||||||
|
|
||||||
|
@ -83,3 +85,11 @@ export const enum ComponentContextScopes {
|
||||||
Local = "local",
|
Local = "local",
|
||||||
Global = "global",
|
Global = "global",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FilterConfig = {
|
||||||
|
active: boolean
|
||||||
|
field: string
|
||||||
|
label?: string
|
||||||
|
_id?: string
|
||||||
|
columnType?: FieldType
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue