Merge branch 'master' of github.com:Budibase/budibase into revert-15323-revert-15215-chore/aws-v2-to-v3

This commit is contained in:
mike12345567 2025-02-07 16:01:03 +00:00
commit 7f3a1dca09
241 changed files with 6118 additions and 4237 deletions

View File

@ -9,4 +9,5 @@ packages/backend-core/coverage
packages/builder/.routify
packages/sdk/sdk
packages/pro/coverage
**/*.ivm.bundle.js
**/*.ivm.bundle.js
!**/bson-polyfills.ivm.bundle.js

View File

@ -54,17 +54,21 @@
</h3>
<br /><br />
## ✨ Features
### Build and ship real software
### Build and ship real software
Unlike other platforms, with Budibase you build and ship single page applications. Budibase applications have performance baked in and can be designed responsively, providing users with a great experience.
<br /><br />
### Open source and extensible
Budibase is open-source - licensed as GPL v3. This should fill you with confidence that Budibase will always be around. You can also code against Budibase or fork it and make changes as you please, providing a developer-friendly experience.
<br /><br />
### Load data or start from scratch
Budibase pulls data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MariaDB, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
@ -82,10 +86,12 @@ Budibase comes out of the box with beautifully designed, powerful components whi
<br /><br />
### Automate processes, integrate with other tools and connect to webhooks
Save time by automating manual processes and workflows. From connecting to webhooks to automating emails, simply tell Budibase what to do and let it work for you. You can easily [create new automations for Budibase here](https://github.com/Budibase/automations) or [Request new automation](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<br /><br />
### Integrate with your favorite tools
Budibase integrates with a number of popular tools allowing you to build apps that perfectly fit your stack.
<p align="center">
@ -94,6 +100,7 @@ Budibase integrates with a number of popular tools allowing you to build apps th
<br /><br />
### Deploy with confidence and security
Budibase is made to scale. With Budibase, you can self-host on your own infrastructure and globally manage users, onboarding, SMTP, apps, groups, theming and more. You can also provide users/groups with an app portal and disseminate user management to the group manager.
- Checkout the promo video: https://youtu.be/xoljVpty_Kw
@ -104,15 +111,15 @@ Budibase is made to scale. With Budibase, you can self-host on your own infrastr
<br />
## Budibase Public API
As with anything that we build in Budibase, our new public API is simple to use, flexible, and introduces new extensibility. To summarize, the Budibase API enables:
- Budibase as a backend
- Interoperability
#### Docs
You can learn more about the Budibase API at the following places:
- [General documentation](https://docs.budibase.com/docs/public-api): Learn how to get your API key, how to use spec, and how to use Postman
@ -132,10 +139,8 @@ Deploy Budibase using Docker, Kubernetes, and Digital Ocean on your existing inf
- [Digital Ocean](https://docs.budibase.com/docs/digitalocean)
- [Portainer](https://docs.budibase.com/docs/portainer)
### [Get started with Budibase Cloud](https://budibase.com)
<br /><br />
## 🎓 Learning Budibase
@ -143,7 +148,6 @@ Deploy Budibase using Docker, Kubernetes, and Digital Ocean on your existing inf
The Budibase documentation [lives here](https://docs.budibase.com/docs).
<br />
<br /><br />
## 💬 Community
@ -152,25 +156,24 @@ If you have a question or would like to talk with other Budibase users and join
<br /><br /><br />
## ❗ Code of conduct
Budibase is dedicated to providing everyone a welcoming, diverse, and harassment-free experience. We expect everyone in the Budibase community to abide by our [**Code of Conduct**](https://github.com/Budibase/budibase/blob/HEAD/docs/CODE_OF_CONDUCT.md). Please read it.
<br />
<br /><br />
## 🙌 Contributing to Budibase
From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API, please create an issue first. This way, we can ensure your work is not in vain.
Environment setup instructions are available [here](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md).
### Not Sure Where to Start?
A good place to start contributing is the [First time issues project](https://github.com/Budibase/budibase/projects/22).
A good place to start contributing is by looking for the [good first issue](https://github.com/Budibase/budibase/labels/good%20first%20issue) tag.
### How the repository is organized
Budibase is a monorepo managed by lerna. Lerna manages the building and publishing of the budibase packages. At a high level, here are the packages that make up Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contains code for the budibase builder client-side svelte application.
@ -183,7 +186,6 @@ For more information, see [CONTRIBUTING.md](https://github.com/Budibase/budibase
<br /><br />
## 📝 License
Budibase is open-source, licensed as [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). The client and component libraries are licensed as [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - so the apps you build can be licensed however you like.
@ -202,7 +204,6 @@ If you are having issues between updates of the builder, please use the guide [h
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<a href="https://github.com/Budibase/budibase/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Budibase/budibase" />
</a>

View File

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

View File

@ -83,11 +83,15 @@ export function isViewId(id: string): boolean {
/**
* Check if a given ID is that of a datasource or datasource plus.
*/
export const isDatasourceId = (id: string): boolean => {
export function isDatasourceId(id: string): boolean {
// this covers both datasources and datasource plus
return !!id && id.startsWith(`${DocumentType.DATASOURCE}${SEPARATOR}`)
}
export function isQueryId(id: string): boolean {
return !!id && id.startsWith(`${DocumentType.QUERY}${SEPARATOR}`)
}
/**
* Gets parameters for retrieving workspaces.
*/

View File

@ -1,45 +1,58 @@
import events from "events"
import { newid } from "../utils"
import { Queue, QueueOptions, JobOptions } from "./queue"
import { helpers } from "@budibase/shared-core"
import { Job, JobId, JobInformation } from "bull"
interface JobMessage {
function jobToJobInformation(job: Job): JobInformation {
let cron = ""
let every = -1
let tz: string | undefined = undefined
let endDate: number | undefined = undefined
const repeat = job.opts?.repeat
if (repeat) {
endDate = repeat.endDate ? new Date(repeat.endDate).getTime() : Date.now()
tz = repeat.tz
if ("cron" in repeat) {
cron = repeat.cron
} else {
every = repeat.every
}
}
return {
id: job.id.toString(),
name: "",
key: job.id.toString(),
tz,
endDate,
cron,
every,
next: 0,
}
}
interface JobMessage<T = any> extends Partial<Job<T>> {
id: string
timestamp: number
queue: string
queue: Queue<T>
data: any
opts?: JobOptions
}
/**
* Bull works with a Job wrapper around all messages that contains a lot more information about
* the state of the message, this object constructor implements the same schema of Bull jobs
* for the sake of maintaining API consistency.
* @param queue The name of the queue which the message will be carried on.
* @param message The JSON message which will be passed back to the consumer.
* @returns A new job which can now be put onto the queue, this is mostly an
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
*/
function newJob(queue: string, message: any, opts?: JobOptions): JobMessage {
return {
id: newid(),
timestamp: Date.now(),
queue: queue,
data: message,
opts,
}
}
/**
* This is designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock.
* It is relatively simple, using an event emitter internally to register when messages are available
* to the consumers - in can support many inputs and many consumers.
* This is designed to replicate Bull (https://github.com/OptimalBits/bull) in
* memory as a sort of mock. It is relatively simple, using an event emitter
* internally to register when messages are available to the consumers - in can
* support many inputs and many consumers.
*/
class InMemoryQueue implements Partial<Queue> {
_name: string
_opts?: QueueOptions
_messages: JobMessage[]
_queuedJobIds: Set<string>
_emitter: NodeJS.EventEmitter
_emitter: NodeJS.EventEmitter<{ message: [JobMessage]; completed: [Job] }>
_runCount: number
_addCount: number
@ -69,34 +82,29 @@ class InMemoryQueue implements Partial<Queue> {
*/
async process(concurrencyOrFunc: number | any, func?: any) {
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
this._emitter.on("message", async () => {
if (this._messages.length <= 0) {
return
}
let msg = this._messages.shift()
let resp = func(msg)
this._emitter.on("message", async message => {
let resp = func(message)
async function retryFunc(fnc: any) {
try {
await fnc
} catch (e: any) {
await new Promise<void>(r => setTimeout(() => r(), 50))
await retryFunc(func(msg))
await helpers.wait(50)
await retryFunc(func(message))
}
}
if (resp.then != null) {
try {
await retryFunc(resp)
this._emitter.emit("completed", message as Job)
} catch (e: any) {
console.error(e)
}
}
this._runCount++
const jobId = msg?.opts?.jobId?.toString()
if (jobId && msg?.opts?.removeOnComplete) {
const jobId = message.opts?.jobId?.toString()
if (jobId && message.opts?.removeOnComplete) {
this._queuedJobIds.delete(jobId)
}
})
@ -130,9 +138,16 @@ class InMemoryQueue implements Partial<Queue> {
}
const pushMessage = () => {
this._messages.push(newJob(this._name, data, opts))
const message: JobMessage = {
id: newid(),
timestamp: Date.now(),
queue: this as unknown as Queue,
data,
opts,
}
this._messages.push(message)
this._addCount++
this._emitter.emit("message")
this._emitter.emit("message", message)
}
const delay = opts?.delay
@ -158,13 +173,6 @@ class InMemoryQueue implements Partial<Queue> {
console.log(cronJobId)
}
/**
* Implemented for tests
*/
async getRepeatableJobs() {
return []
}
async removeJobs(_pattern: string) {
// no-op
}
@ -176,13 +184,31 @@ class InMemoryQueue implements Partial<Queue> {
return []
}
async getJob() {
async getJob(id: JobId) {
for (const message of this._messages) {
if (message.id === id) {
return message as Job
}
}
return null
}
on() {
// do nothing
return this as any
on(event: string, callback: (...args: any[]) => void): Queue {
// @ts-expect-error - this callback can be one of many types
this._emitter.on(event, callback)
return this as unknown as Queue
}
async count() {
return this._messages.length
}
async getCompletedCount() {
return this._runCount
}
async getRepeatableJobs() {
return this._messages.map(job => jobToJobInformation(job as Job))
}
}

View File

@ -388,7 +388,7 @@ class InternalBuilder {
}
}
if (typeof input === "string") {
if (typeof input === "string" && schema.type === FieldType.DATETIME) {
if (isInvalidISODateString(input)) {
return null
}

View File

@ -1,5 +1,12 @@
import { Feature, License, Quotas } from "@budibase/types"
import {
Feature,
License,
MonthlyQuotaName,
QuotaType,
QuotaUsageType,
} from "@budibase/types"
import cloneDeep from "lodash/cloneDeep"
import merge from "lodash/merge"
let CLOUD_FREE_LICENSE: License
let UNLIMITED_LICENSE: License
@ -27,18 +34,19 @@ export function initInternal(opts: {
export interface UseLicenseOpts {
features?: Feature[]
quotas?: Quotas
monthlyQuotas?: [MonthlyQuotaName, number][]
}
// LICENSES
export const useLicense = (license: License, opts?: UseLicenseOpts) => {
if (opts) {
if (opts.features) {
license.features.push(...opts.features)
}
if (opts.quotas) {
license.quotas = opts.quotas
if (opts?.features) {
license.features.push(...opts.features)
}
if (opts?.monthlyQuotas) {
for (const [name, value] of opts.monthlyQuotas) {
license.quotas[QuotaType.USAGE][QuotaUsageType.MONTHLY][name].value =
value
}
}
@ -57,12 +65,9 @@ export const useCloudFree = () => {
// FEATURES
const useFeature = (feature: Feature) => {
const useFeature = (feature: Feature, extra?: Partial<UseLicenseOpts>) => {
const license = cloneDeep(getCachedLicense() || UNLIMITED_LICENSE)
const opts: UseLicenseOpts = {
features: [feature],
}
const opts: UseLicenseOpts = merge({ features: [feature] }, extra)
return useLicense(license, opts)
}
@ -102,8 +107,12 @@ export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS)
}
export const useBudibaseAI = () => {
return useFeature(Feature.BUDIBASE_AI)
export const useBudibaseAI = (opts?: { monthlyQuota?: number }) => {
return useFeature(Feature.BUDIBASE_AI, {
monthlyQuotas: [
[MonthlyQuotaName.BUDIBASE_AI_CREDITS, opts?.monthlyQuota || 1000],
],
})
}
export const useAICustomConfigs = () => {

View File

@ -80,7 +80,7 @@
"dayjs": "^1.10.8",
"easymde": "^2.16.1",
"svelte-dnd-action": "^0.9.8",
"svelte-portal": "^1.0.0"
"svelte-portal": "^2.2.1"
},
"resolutions": {
"loader-utils": "1.4.1"

View File

@ -1,3 +1,17 @@
type ClickOutsideCallback = (event: MouseEvent) => void | undefined
interface ClickOutsideOpts {
callback?: ClickOutsideCallback
anchor?: HTMLElement
}
interface Handler {
id: number
element: HTMLElement
anchor: HTMLElement
callback?: ClickOutsideCallback
}
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [
".download-js-link",
@ -14,18 +28,20 @@ const conditionallyIgnoredClasses = [
".drawer-wrapper",
".spectrum-Popover",
]
let clickHandlers = []
let candidateTarget
let clickHandlers: Handler[] = []
let candidateTarget: HTMLElement | undefined
// Processes a "click outside" event and invokes callbacks if our source element
// is valid
const handleClick = event => {
const handleClick = (e: MouseEvent) => {
const target = (e.target || e.relatedTarget) as HTMLElement
// Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) {
if (target.closest('[data-ignore-click-outside="true"]')) {
return
}
for (let className of ignoredClasses) {
if (event.target.closest(className)) {
if (target.closest(className)) {
return
}
}
@ -33,41 +49,41 @@ const handleClick = event => {
// Process handlers
clickHandlers.forEach(handler => {
// Check that the click isn't inside the target
if (handler.element.contains(event.target)) {
if (handler.element.contains(target)) {
return
}
// Ignore clicks for certain classes unless we're nested inside them
for (let className of conditionallyIgnoredClasses) {
const sourceInside = handler.anchor.closest(className) != null
const clickInside = event.target.closest(className) != null
const clickInside = target.closest(className) != null
if (clickInside && !sourceInside) {
return
}
}
handler.callback?.(event)
handler.callback?.(e)
})
}
// On mouse up we only trigger a "click outside" callback if we targetted the
// same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text.
const handleMouseUp = e => {
const handleMouseUp = (e: MouseEvent) => {
if (candidateTarget === e.target) {
handleClick(e)
}
candidateTarget = null
candidateTarget = undefined
}
// On mouse down we store which element was targetted for comparison later
const handleMouseDown = e => {
const handleMouseDown = (e: MouseEvent) => {
// Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler.
if (e.button !== 0) {
return
}
candidateTarget = e.target
candidateTarget = e.target as HTMLElement
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
@ -75,14 +91,29 @@ const handleMouseDown = e => {
document.addEventListener("click", handleMouseUp, true)
}
// Handle iframe clicks by detecting a loss of focus on the main window
const handleBlur = () => {
if (document.activeElement?.tagName === "IFRAME") {
handleClick(
new MouseEvent("click", { relatedTarget: document.activeElement })
)
}
}
// Global singleton listeners for our events
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("contextmenu", handleClick)
window.addEventListener("blur", handleBlur)
/**
* Adds or updates a click handler
*/
const updateHandler = (id, element, anchor, callback) => {
const updateHandler = (
id: number,
element: HTMLElement,
anchor: HTMLElement,
callback: ClickOutsideCallback | undefined
) => {
let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback })
@ -94,27 +125,52 @@ const updateHandler = (id, element, anchor, callback) => {
/**
* Removes a click handler
*/
const removeHandler = id => {
const removeHandler = (id: number) => {
clickHandlers = clickHandlers.filter(x => x.id !== id)
}
/**
* Svelte action to apply a click outside handler for a certain element
* Svelte action to apply a click outside handler for a certain element.
* opts.anchor is an optional param specifying the real root source of the
* component being observed. This is required for things like popovers, where
* the element using the clickoutside action is the popover, but the popover is
* rendered at the root of the DOM somewhere, whereas the popover anchor is the
* element we actually want to consider when determining the source component.
*/
export default (element, opts) => {
export default (
element: HTMLElement,
opts?: ClickOutsideOpts | ClickOutsideCallback
) => {
const id = Math.random()
const update = newOpts => {
const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element
const isCallback = (
opts?: ClickOutsideOpts | ClickOutsideCallback
): opts is ClickOutsideCallback => {
return typeof opts === "function"
}
const isOpts = (
opts?: ClickOutsideOpts | ClickOutsideCallback
): opts is ClickOutsideOpts => {
return opts != null && typeof opts === "object"
}
const update = (newOpts?: ClickOutsideOpts | ClickOutsideCallback) => {
let callback: ClickOutsideCallback | undefined
let anchor = element
if (isCallback(newOpts)) {
callback = newOpts
} else if (isOpts(newOpts)) {
callback = newOpts.callback
if (newOpts.anchor) {
anchor = newOpts.anchor
}
}
updateHandler(id, element, anchor, callback)
}
update(opts)
return {
update,
destroy: () => removeHandler(id),

View File

@ -1,34 +1,53 @@
/**
* Valid alignment options are
* - left
* - right
* - left-outside
* - right-outside
**/
// Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment.
const Strategies = {
StartToStart: "StartToStart", // e.g. left alignment
EndToEnd: "EndToEnd", // e.g. right alignment
StartToEnd: "StartToEnd", // e.g. right-outside alignment
EndToStart: "EndToStart", // e.g. left-outside alignment
MidPoint: "MidPoint", // centers relative to midpoints
ScreenEdge: "ScreenEdge", // locks to screen edge
import { PopoverAlignment } from "../constants"
type Strategy =
| "StartToStart"
| "EndToEnd"
| "StartToEnd"
| "EndToStart"
| "MidPoint"
| "ScreenEdge"
export interface Styles {
maxHeight?: number
minWidth?: number
maxWidth?: number
offset?: number
left: number
top: number
}
export default function positionDropdown(element, opts) {
let resizeObserver
export type UpdateHandler = (
anchorBounds: DOMRect,
elementBounds: DOMRect,
styles: Styles
) => Styles
interface Opts {
anchor?: HTMLElement
align: PopoverAlignment
maxHeight?: number
maxWidth?: number
minWidth?: number
useAnchorWidth: boolean
offset: number
customUpdate?: UpdateHandler
resizable: boolean
wrap: boolean
}
export default function positionDropdown(element: HTMLElement, opts: Opts) {
let resizeObserver: ResizeObserver
let latestOpts = opts
// We need a static reference to this function so that we can properly
// clean up the scroll listener.
const scrollUpdate = () => {
updatePosition(latestOpts)
}
const scrollUpdate = () => updatePosition(latestOpts)
// Updates the position of the dropdown
const updatePosition = opts => {
const updatePosition = (opts: Opts) => {
const {
anchor,
align,
@ -51,12 +70,12 @@ export default function positionDropdown(element, opts) {
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const screenOffset = 8
let styles = {
let styles: Styles = {
maxHeight,
minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null,
top: null,
left: 0,
top: 0,
}
// Ignore all our logic for custom logic
@ -81,67 +100,67 @@ export default function positionDropdown(element, opts) {
}
// Applies a dynamic max height constraint if appropriate
const applyMaxHeight = height => {
const applyMaxHeight = (height: number) => {
if (!styles.maxHeight && resizable) {
styles.maxHeight = height
}
}
// Applies the X strategy to our styles
const applyXStrategy = strategy => {
const applyXStrategy = (strategy: Strategy) => {
switch (strategy) {
case Strategies.StartToStart:
case "StartToStart":
default:
styles.left = anchorBounds.left
break
case Strategies.EndToEnd:
case "EndToEnd":
styles.left = anchorBounds.right - elementBounds.width
break
case Strategies.StartToEnd:
case "StartToEnd":
styles.left = anchorBounds.right + offset
break
case Strategies.EndToStart:
case "EndToStart":
styles.left = anchorBounds.left - elementBounds.width - offset
break
case Strategies.MidPoint:
case "MidPoint":
styles.left =
anchorBounds.left +
anchorBounds.width / 2 -
elementBounds.width / 2
break
case Strategies.ScreenEdge:
case "ScreenEdge":
styles.left = winWidth - elementBounds.width - screenOffset
break
}
}
// Applies the Y strategy to our styles
const applyYStrategy = strategy => {
const applyYStrategy = (strategy: Strategy) => {
switch (strategy) {
case Strategies.StartToStart:
case "StartToStart":
styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break
case Strategies.EndToEnd:
case "EndToEnd":
styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset)
break
case Strategies.StartToEnd:
case "StartToEnd":
default:
styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break
case Strategies.EndToStart:
case "EndToStart":
styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset)
break
case Strategies.MidPoint:
case "MidPoint":
styles.top =
anchorBounds.top +
anchorBounds.height / 2 -
elementBounds.height / 2
break
case Strategies.ScreenEdge:
case "ScreenEdge":
styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset)
break
@ -149,82 +168,96 @@ export default function positionDropdown(element, opts) {
}
// Determine X strategy
if (align === "right") {
applyXStrategy(Strategies.EndToEnd)
} else if (align === "right-outside" || align === "right-context-menu") {
applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside" || align === "left-context-menu") {
applyXStrategy(Strategies.EndToStart)
} else if (align === "center") {
applyXStrategy(Strategies.MidPoint)
if (align === PopoverAlignment.Right) {
applyXStrategy("EndToEnd")
} else if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.RightContextMenu
) {
applyXStrategy("StartToEnd")
} else if (
align === PopoverAlignment.LeftOutside ||
align === PopoverAlignment.LeftContextMenu
) {
applyXStrategy("EndToStart")
} else if (align === PopoverAlignment.Center) {
applyXStrategy("MidPoint")
} else {
applyXStrategy(Strategies.StartToStart)
applyXStrategy("StartToStart")
}
// Determine Y strategy
if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint)
} else if (
align === "right-context-menu" ||
align === "left-context-menu"
if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.LeftOutside
) {
applyYStrategy(Strategies.StartToStart)
styles.top -= 5 // Manual adjustment for action menu padding
applyYStrategy("MidPoint")
} else if (
align === PopoverAlignment.RightContextMenu ||
align === PopoverAlignment.LeftContextMenu
) {
applyYStrategy("StartToStart")
if (styles.top) {
styles.top -= 5 // Manual adjustment for action menu padding
}
} else {
applyYStrategy(Strategies.StartToEnd)
applyYStrategy("StartToEnd")
}
// Handle screen overflow
if (doesXOverflow()) {
// Swap left to right
if (align === "left") {
applyXStrategy(Strategies.EndToEnd)
if (align === PopoverAlignment.Left) {
applyXStrategy("EndToEnd")
}
// Swap right-outside to left-outside
else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart)
else if (align === PopoverAlignment.RightOutside) {
applyXStrategy("EndToStart")
}
}
if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor
if (wrap) {
applyYStrategy(Strategies.MidPoint)
applyYStrategy("MidPoint")
if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge)
applyYStrategy("ScreenEdge")
}
applyXStrategy(Strategies.StartToEnd)
applyXStrategy("StartToEnd")
if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart)
applyXStrategy("EndToStart")
}
}
// Othewise invert as normal
// Otherwise invert as normal
else {
// If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge)
if (
align === PopoverAlignment.LeftOutside ||
align === PopoverAlignment.RightOutside
) {
applyYStrategy("ScreenEdge")
}
// Otherwise flip above
else {
applyYStrategy(Strategies.EndToStart)
applyYStrategy("EndToStart")
}
}
}
}
// Apply styles
Object.entries(styles).forEach(([style, value]) => {
for (const [key, value] of Object.entries(styles)) {
const name = key as keyof Styles
if (value != null) {
element.style[style] = `${value.toFixed(0)}px`
element.style[name] = `${value}px`
} else {
element.style[style] = null
element.style[name] = ""
}
})
}
}
// The actual svelte action callback which creates observers on the relevant
// DOM elements
const update = newOpts => {
const update = (newOpts: Opts) => {
latestOpts = newOpts
// Cleanup old state

View File

@ -1,22 +1,23 @@
<script>
<script lang="ts">
import "@spectrum-css/checkbox/dist/index-vars.css"
import "@spectrum-css/fieldgroup/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import type { ChangeEventHandler } from "svelte/elements"
export let value = false
export let id = null
export let text = null
export let id: string | undefined = undefined
export let text: string | undefined = undefined
export let disabled = false
export let readonly = false
export let size
export let size: "S" | "M" | "L" | "XL" = "M"
export let indeterminate = false
const dispatch = createEventDispatcher()
const onChange = event => {
dispatch("change", event.target.checked)
const onChange: ChangeEventHandler<HTMLInputElement> = event => {
dispatch("change", event.currentTarget.checked)
}
$: sizeClass = `spectrum-Checkbox--size${size || "M"}`
$: sizeClass = `spectrum-Checkbox--size${size}`
</script>
<label

View File

@ -1,19 +1,24 @@
<script>
<script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts" generics="O, V">
import "@spectrum-css/fieldgroup/dist/index-vars.css"
import "@spectrum-css/radio/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
export let direction = "vertical"
export let value = []
export let options = []
export let direction: "horizontal" | "vertical" = "vertical"
export let value: V[] = []
export let options: O[] = []
export let disabled = false
export let readonly = false
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = (option: O) => option as unknown as V
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{ change: V[] }>()
const onChange = optionValue => {
const onChange = (optionValue: V) => {
if (!value.includes(optionValue)) {
dispatch("change", [...value, optionValue])
} else {

View File

@ -1,38 +1,50 @@
<script>
<script lang="ts" context="module">
type O = any
</script>
<script lang="ts" generics="O">
import type { ChangeEventHandler } from "svelte/elements"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte"
import { PopoverAlignment } from "../../constants"
export let value = null
export let id = null
export let value: string | undefined = undefined
export let id: string | undefined = undefined
export let placeholder = "Choose an option or type"
export let disabled = false
export let readonly = false
export let options = []
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let options: O[] = []
export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = (option: O) => `${option}`
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{
change: string
blur: void
type: string
pick: string
}>()
let open = false
let focus = false
let anchor
const selectOption = value => {
const selectOption = (value: string) => {
dispatch("change", value)
open = false
}
const onType = e => {
const value = e.target.value
const onType: ChangeEventHandler<HTMLInputElement> = e => {
const value = e.currentTarget.value
dispatch("type", value)
selectOption(value)
}
const onPick = value => {
const onPick = (value: string) => {
dispatch("pick", value)
selectOption(value)
}
@ -86,11 +98,16 @@
<Popover
{anchor}
{open}
align="left"
align={PopoverAlignment.Left}
on:close={() => (open = false)}
useAnchorWidth
>
<div class="popover-content" use:clickOutside={() => (open = false)}>
<div
class="popover-content"
use:clickOutside={() => {
open = false
}}
>
<ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)}
{#each options as option}

View File

@ -1,4 +1,9 @@
<script>
<script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import "@spectrum-css/picker/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
@ -11,43 +16,55 @@
import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte"
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
import { PopoverAlignment } from "../../constants"
export let id = null
export let disabled = false
export let fieldText = ""
export let fieldIcon = ""
export let fieldColour = ""
export let isPlaceholder = false
export let placeholderOption = null
export let options = []
export let isOptionSelected = () => false
export let isOptionEnabled = () => true
export let onSelectOption = () => {}
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let id: string | undefined = undefined
export let disabled: boolean = false
export let fieldText: string = ""
export let fieldIcon: string = ""
export let fieldColour: string = ""
export let isPlaceholder: boolean = false
export let placeholderOption: string | undefined | boolean = undefined
export let options: O[] = []
export let isOptionSelected = (option: O) => option as unknown as boolean
export let isOptionEnabled = (option: O, _index?: number) =>
option as unknown as boolean
export let onSelectOption: (_value: V) => void = () => {}
export let getOptionLabel = (option: O, _index?: number) => `${option}`
export let getOptionValue = (option: O, _index?: number) =>
option as unknown as V
export let getOptionIcon = (option: O, _index?: number) =>
option?.icon ?? undefined
export let getOptionColour = (option: O, _index?: number) =>
option?.colour ?? undefined
export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle ?? undefined
export let useOptionIconImage = false
export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let open = false
export let readonly = false
export let quiet = false
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let searchTerm = null
export let customPopoverHeight
export let align = "left"
export let footer = null
export let customAnchor = null
export let loading
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
export let open: boolean = false
export let readonly: boolean = false
export let quiet: boolean = false
export let autoWidth: boolean | undefined = false
export let autocomplete: boolean = false
export let sort: boolean = false
export let searchTerm: string | null = null
export let customPopoverHeight: string | undefined = undefined
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let footer: string | undefined = undefined
export let customAnchor: HTMLElement | undefined = undefined
export let loading: boolean = false
export let onOptionMouseenter: (
_e: MouseEvent,
_option: any
) => void = () => {}
export let onOptionMouseleave: (
_e: MouseEvent,
_option: any
) => void = () => {}
const dispatch = createEventDispatcher()
let button
let component
let button: any
let component: any
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
$: filteredOptions = getFilteredOptions(
@ -56,7 +73,7 @@
getOptionLabel
)
const onClick = e => {
const onClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
dispatch("click")
@ -67,7 +84,11 @@
open = !open
}
const getSortedOptions = (options, getLabel, sort) => {
const getSortedOptions = (
options: any[],
getLabel: (_option: any) => string,
sort: boolean
) => {
if (!options?.length || !Array.isArray(options)) {
return []
}
@ -81,17 +102,21 @@
})
}
const getFilteredOptions = (options, term, getLabel) => {
const getFilteredOptions = (
options: any[],
term: string | null,
getLabel: (_option: any) => string
) => {
if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase()
return options.filter(option => {
return options.filter((option: any) => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
})
}
return options
}
const onScroll = e => {
const onScroll = (e: any) => {
const scrollPxThreshold = 100
const scrollPositionFromBottom =
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
@ -151,18 +176,20 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover
anchor={customAnchor ? customAnchor : button}
align={align || "left"}
align={align || PopoverAlignment.Left}
{open}
on:close={() => (open = false)}
useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null}
maxWidth={autoWidth ? 400 : undefined}
customHeight={customPopoverHeight}
maxHeight={360}
>
<div
class="popover-content"
class:auto-width={autoWidth}
use:clickOutside={() => (open = false)}
use:clickOutside={() => {
open = false
}}
>
{#if autocomplete}
<Search

View File

@ -1,19 +1,19 @@
<script>
<script lang="ts">
import "@spectrum-css/search/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
export let value = null
export let placeholder = null
export let value: any = null
export let placeholder: string | undefined = undefined
export let disabled = false
export let id = null
export let updateOnChange = true
export let quiet = false
export let inputRef
export let inputRef: HTMLElement | undefined = undefined
const dispatch = createEventDispatcher()
let focus = false
const updateValue = value => {
const updateValue = (value: any) => {
dispatch("change", value)
}
@ -21,19 +21,19 @@
focus = true
}
const onBlur = event => {
const onBlur = (event: any) => {
focus = false
updateValue(event.target.value)
}
const onInput = event => {
const onInput = (event: any) => {
if (!updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => {
const updateValueOnEnter = (event: any) => {
if (event.key === "Enter") {
updateValue(event.target.value)
}

View File

@ -1,33 +1,44 @@
<script>
<script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte"
import Picker from "./Picker.svelte"
import { PopoverAlignment } from "../../constants"
export let value = null
export let id = null
export let placeholder = "Choose an option"
export let disabled = false
export let options = []
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let compare = null
export let value: V | null = null
export let id: string | undefined = undefined
export let placeholder: string | boolean = "Choose an option"
export let disabled: boolean = false
export let options: O[] = []
export let getOptionLabel = (option: O, _index?: number) => `${option}`
export let getOptionValue = (option: O, _index?: number) =>
option as unknown as V
export let getOptionIcon = (option: O, _index?: number) =>
option?.icon ?? undefined
export let getOptionColour = (option: O, _index?: number) =>
option?.colour ?? undefined
export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle ?? undefined
export let compare = (option: O, value: V) => option === value
export let useOptionIconImage = false
export let isOptionEnabled
export let readonly = false
export let quiet = false
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let align
export let footer = null
export let open = false
export let tag = null
export let searchTerm = null
export let loading
export let isOptionEnabled = (option: O, _index?: number) =>
option as unknown as boolean
export let readonly: boolean = false
export let quiet: boolean = false
export let autoWidth: boolean = false
export let autocomplete: boolean = false
export let sort: boolean = false
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let footer: string | undefined = undefined
export let open: boolean = false
export let searchTerm: string | undefined = undefined
export let loading: boolean | undefined = undefined
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
export let customPopoverHeight: string | undefined = undefined
const dispatch = createEventDispatcher()
@ -35,24 +46,28 @@
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
$: fieldColour = getFieldAttribute(getOptionColour, value, options)
function compareOptionAndValue(option, value) {
function compareOptionAndValue(option: O, value: V) {
return typeof compare === "function"
? compare(option, value)
: option === value
}
const getFieldAttribute = (getAttribute, value, options) => {
const getFieldAttribute = (getAttribute: any, value: V[], options: O[]) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex((option, idx) =>
const index = options.findIndex((option: any, idx: number) =>
compareOptionAndValue(getOptionValue(option, idx), value)
)
return index !== -1 ? getAttribute(options[index], index) : null
}
const getFieldText = (value, options, placeholder) => {
const getFieldText = (
value: any,
options: any,
placeholder: boolean | string
) => {
if (value == null || value === "") {
// Explicit false means use no placeholder and allow an empty fields
if (placeholder === false) {
@ -67,7 +82,7 @@
)
}
const selectOption = value => {
const selectOption = (value: V) => {
dispatch("change", value)
open = false
}
@ -98,14 +113,14 @@
{isOptionEnabled}
{autocomplete}
{sort}
{tag}
{onOptionMouseenter}
{onOptionMouseleave}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false
? null
? undefined
: placeholder || "Choose an option"}
isOptionSelected={option => compareOptionAndValue(option, value)}
onSelectOption={selectOption}
{loading}
{customPopoverHeight}
/>

View File

@ -1,30 +1,31 @@
<script>
<script lang="ts">
import "@spectrum-css/textfield/dist/index-vars.css"
import { createEventDispatcher, onMount, tick } from "svelte"
import type { UIEvent } from "@budibase/types"
export let value = null
export let placeholder = null
export let value: string | null = null
export let placeholder: string | undefined = undefined
export let type = "text"
export let disabled = false
export let id = null
export let readonly = false
export let updateOnChange = true
export let quiet = false
export let align
export let autofocus = false
export let autocomplete = null
export let align: "left" | "right" | "center" | undefined = undefined
export let autofocus: boolean | null = false
export let autocomplete: boolean | undefined
const dispatch = createEventDispatcher()
let field
let field: any
let focus = false
const updateValue = newValue => {
const updateValue = (newValue: any) => {
if (readonly || disabled) {
return
}
if (type === "number") {
const float = parseFloat(newValue)
const float = parseFloat(newValue as string)
newValue = isNaN(float) ? null : float
}
dispatch("change", newValue)
@ -37,40 +38,47 @@
focus = true
}
const onBlur = event => {
const onBlur = (event: UIEvent) => {
if (readonly || disabled) {
return
}
focus = false
updateValue(event.target.value)
updateValue(event?.target?.value)
}
const onInput = event => {
const onInput = (event: UIEvent) => {
if (readonly || !updateOnChange || disabled) {
return
}
updateValue(event.target.value)
updateValue(event.target?.value)
}
const updateValueOnEnter = event => {
const updateValueOnEnter = (event: UIEvent) => {
if (readonly || disabled) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
updateValue(event.target?.value)
}
}
const getInputMode = type => {
const getInputMode = (type: string) => {
if (type === "bigint") {
return "numeric"
}
return type === "number" ? "decimal" : "text"
}
$: autocompleteValue =
typeof autocomplete === "boolean"
? autocomplete
? "on"
: "off"
: undefined
onMount(async () => {
if (disabled) return
focus = autofocus
focus = autofocus || false
if (focus) {
await tick()
field.focus()
@ -104,7 +112,7 @@
class="spectrum-Textfield-input"
style={align ? `text-align: ${align};` : ""}
inputmode={getInputMode(type)}
{autocomplete}
autocomplete={autocompleteValue}
/>
</div>

View File

@ -1,14 +1,14 @@
<script>
<script lang="ts">
import "@spectrum-css/fieldlabel/dist/index-vars.css"
import FieldLabel from "./FieldLabel.svelte"
import Icon from "../Icon/Icon.svelte"
export let id = null
export let label = null
export let labelPosition = "above"
export let error = null
export let helpText = null
export let tooltip = ""
export let id: string | undefined = undefined
export let label: string | undefined = undefined
export let labelPosition: string = "above"
export let error: string | undefined = undefined
export let helpText: string | undefined = undefined
export let tooltip: string | undefined = undefined
</script>
<div class="spectrum-Form-item" class:above={labelPosition === "above"}>

View File

@ -1,24 +1,24 @@
<script>
<script lang="ts">
import Field from "./Field.svelte"
import TextField from "./Core/TextField.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let value: any = undefined
export let label: string | undefined = undefined
export let labelPosition = "above"
export let placeholder = null
export let placeholder: string | undefined = undefined
export let type = "text"
export let disabled = false
export let readonly = false
export let error = null
export let error: string | undefined = undefined
export let updateOnChange = true
export let quiet = false
export let autofocus
export let autocomplete
export let helpText = null
export let autofocus: boolean | undefined = undefined
export let autocomplete: boolean | undefined = undefined
export let helpText: string | undefined = undefined
const dispatch = createEventDispatcher()
const onChange = e => {
const onChange = (e: any) => {
value = e.detail
dispatch("change", e.detail)
}
@ -27,7 +27,6 @@
<Field {helpText} {label} {labelPosition} {error}>
<TextField
{updateOnChange}
{error}
{disabled}
{readonly}
{value}

View File

@ -1,44 +1,54 @@
<script>
<script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import Field from "./Field.svelte"
import Select from "./Core/Select.svelte"
import { createEventDispatcher } from "svelte"
import { PopoverAlignment } from "../constants"
export let value = null
export let label = undefined
export let disabled = false
export let readonly = false
export let labelPosition = "above"
export let error = null
export let placeholder = "Choose an option"
export let options = []
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => option?.subtitle
export let getOptionIcon = option => option?.icon
export let getOptionColour = option => option?.colour
export let value: V | undefined = undefined
export let label: string | undefined = undefined
export let disabled: boolean = false
export let readonly: boolean = false
export let labelPosition: string = "above"
export let error: string | undefined = undefined
export let placeholder: string | boolean = "Choose an option"
export let options: O[] = []
export let getOptionLabel = (option: O, _index?: number) =>
extractProperty(option, "label")
export let getOptionValue = (option: O, _index?: number) =>
extractProperty(option, "value")
export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle
export let getOptionIcon = (option: O, _index?: number) => option?.icon
export let getOptionColour = (option: O, _index?: number) => option?.colour
export let useOptionIconImage = false
export let isOptionEnabled = undefined
export let quiet = false
export let autoWidth = false
export let sort = false
export let tooltip = ""
export let autocomplete = false
export let customPopoverHeight = undefined
export let align = undefined
export let footer = null
export let tag = null
export let helpText = null
export let compare = undefined
export let isOptionEnabled:
| ((_option: O, _index?: number) => boolean)
| undefined = undefined
export let quiet: boolean = false
export let autoWidth: boolean = false
export let sort: boolean = false
export let tooltip: string | undefined = undefined
export let autocomplete: boolean = false
export let customPopoverHeight: string | undefined = undefined
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let footer: string | undefined = undefined
export let helpText: string | undefined = undefined
export let compare: any = undefined
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
const onChange = e => {
const onChange = (e: CustomEvent<any>) => {
value = e.detail
dispatch("change", e.detail)
}
const extractProperty = (value, property) => {
const extractProperty = (value: any, property: any) => {
if (value && typeof value === "object") {
return value[property]
}
@ -49,7 +59,6 @@
<Field {helpText} {label} {labelPosition} {error} {tooltip}>
<Select
{quiet}
{error}
{disabled}
{readonly}
{value}
@ -68,7 +77,6 @@
{isOptionEnabled}
{autocomplete}
{customPopoverHeight}
{tag}
{compare}
{onOptionMouseenter}
{onOptionMouseleave}

View File

@ -1,13 +1,10 @@
<script lang="ts">
import {
default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "../Tooltip/AbsTooltip.svelte"
import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
import { TooltipPosition, TooltipType } from "../constants"
export let name: string = "Add"
export let size: "XS" | "S" | "M" | "L" | "XL" = "M"
export let hidden: boolean = false
export let size = "M"
export let hoverable: boolean = false
export let disabled: boolean = false
export let color: string | undefined = undefined
@ -81,17 +78,6 @@
color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important;
}
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
text-align: center;
z-index: 1;
}
.spectrum-Icon--sizeXS {
width: var(--spectrum-global-dimension-size-150);
height: var(--spectrum-global-dimension-size-150);

View File

@ -9,8 +9,8 @@
export let primary = false
export let secondary = false
export let overBackground = false
export let target
export let download
export let target = undefined
export let download = undefined
export let disabled = false
export let tooltip = null

View File

@ -1,39 +1,53 @@
<script>
<script context="module" lang="ts">
export interface PopoverAPI {
show: () => void
hide: () => void
}
</script>
<script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css"
import Portal from "svelte-portal"
import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown from "../Actions/position_dropdown"
import positionDropdown, {
type UpdateHandler,
} from "../Actions/position_dropdown"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Context from "../context"
import type { KeyboardEventHandler } from "svelte/elements"
import { PopoverAlignment } from "../constants"
const dispatch = createEventDispatcher()
export let anchor
export let align = "right"
export let portalTarget
export let minWidth
export let maxWidth
export let maxHeight
export let anchor: HTMLElement
export let align: PopoverAlignment = PopoverAlignment.Right
export let portalTarget: string | undefined = undefined
export let minWidth: number | undefined = undefined
export let maxWidth: number | undefined = undefined
export let maxHeight: number | undefined = undefined
export let open = false
export let useAnchorWidth = false
export let dismissible = true
export let offset = 4
export let customHeight
export let customHeight: string | undefined = undefined
export let animate = true
export let customZindex
export let handlePostionUpdate
export let customZIndex: number | undefined = undefined
export let handlePositionUpdate: UpdateHandler | undefined = undefined
export let showPopover = true
export let clickOutsideOverride = false
export let resizable = true
export let wrap = false
const dispatch = createEventDispatcher<{ open: void; close: void }>()
const animationDuration = 260
let timeout
let timeout: ReturnType<typeof setTimeout>
let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
// Portal library lacks types, so we have to type this as any even though it's
// actually a string
$: target = (portalTarget ||
getContext(Context.PopoverRoot) ||
".spectrum") as any
$: {
// Disable pointer events for the initial part of the animation, because we
// fly from top to bottom and initially can be positioned under the cursor,
@ -65,13 +79,13 @@
}
}
const handleOutsideClick = e => {
const handleOutsideClick = (e: MouseEvent) => {
if (clickOutsideOverride) {
return
}
if (open) {
// Stop propagation if the source is the anchor
let node = e.target
let node = e.target as Node | null
let fromAnchor = false
while (!fromAnchor && node && node.parentNode) {
fromAnchor = node === anchor
@ -86,7 +100,7 @@
}
}
function handleEscape(e) {
const handleEscape: KeyboardEventHandler<HTMLDivElement> = e => {
if (!clickOutsideOverride) {
return
}
@ -113,7 +127,7 @@
minWidth,
useAnchorWidth,
offset,
customUpdate: handlePostionUpdate,
customUpdate: handlePositionUpdate,
resizable,
wrap,
}}
@ -123,11 +137,11 @@
}}
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class:customZindex
class:customZIndex
class:hidden={!showPopover}
class:blockPointerEvents
role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};"
style="height: {customHeight}; --customZIndex: {customZIndex};"
transition:fly|local={{
y: -20,
duration: animate ? animationDuration : 0,
@ -157,7 +171,7 @@
opacity: 0;
pointer-events: none;
}
.customZindex {
z-index: var(--customZindex) !important;
.customZIndex {
z-index: var(--customZIndex) !important;
}
</style>

View File

@ -1,120 +0,0 @@
<script>
import { View } from "svench";
import Popover from "./Popover.svelte";
import Button from "../Button/Button.svelte";
import TextButton from "../Button/TextButton.svelte";
import Icon from "../Icons/Icon.svelte";
import Input from "../Form/Input.svelte";
import Select from "../Form/Select.svelte";
let anchorRight;
let anchorLeft;
let dropdownRight;
let dropdownLeft;
const options = ["Column 1", "Column 2", "Super cool column"];
const option1s = ["Is", "Is not", "Contains" , "Does not contain"];
</script>
<style>
.button-group {
margin-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
h6 {
font-size: var(--font-size-m);
margin: 0 0 var(--spacing-l) 0;
font-weight: 600;
}
.input-group-column {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.input-group-row {
display: grid;
grid-template-columns: [boolean-start] 60px [boolean-end property-start] 120px [property-end opererator-start] 110px [operator-end value-start] auto [value-end menu-start] 32px [menu-end];
gap: var(--spacing-s);
margin-bottom: var(--spacing-l);
align-items: center;
}
p {
margin:0;
font-size: var(--font-size-xs);
}
</style>
<View name="Simple popover">
<div bind:this={anchorLeft}>
<Button text on:click={dropdownLeft.show}>
<Icon name="view" />
Add View
</Button>
</div>
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
<h6>Add New View</h6>
<Input thin placeholder="Enter your name" />
<div class="button-group">
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
<Button primary on:click={() => alert('Clicked!')}>Add New View</Button>
</div>
</Popover>
</View>
<View name="Stacked columns">
<div bind:this={anchorRight}>
<Button text on:click={dropdownRight.show}>
<Icon name="addrow" />
Add Row
</Button>
</div>
<Popover bind:this={dropdownRight} anchor={anchorRight}>
<h6>Add New Row</h6>
<div class="input-group-column">
<Input thin placeholder="Enter your string" />
<Input thin placeholder="Enter your string" />
<Input thin placeholder="Enter your string" />
</div>
<div class="button-group">
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
<Button primary on:click={() => alert('Clicked!')}>Add New Row</Button>
</div>
</Popover>
</View>
<View name="Multiple inputs in a row">
<div bind:this={anchorLeft}>
<Button text on:click={dropdownLeft.show}>
<Icon name="filter" />
Add Filter
</Button>
</div>
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
<h6>Add New Filter</h6>
<div class="input-group-row">
<p>Where</p>
<Select secondary thin name="Test">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</Select>
<Select secondary thin name="Test">
{#each option1s as option1}
<option value={option1}>{option1}</option>
{/each}
</Select>
<Input thin placeholder="Enter your name" />
<Button text on:click={() => alert('Clicked!')}>
<Icon name="close" />
</Button>
</div>
<Button text on:click={() => alert('Clicked!')}>Add Filter</Button>
</Popover>
</View>

View File

@ -1,25 +1,25 @@
<script>
<script lang="ts">
import "@spectrum-css/statuslight"
export let size = "M"
export let celery = false
export let yellow = false
export let fuchsia = false
export let indigo = false
export let seafoam = false
export let chartreuse = false
export let magenta = false
export let purple = false
export let neutral = false
export let info = false
export let positive = false
export let notice = false
export let negative = false
export let disabled = false
export let active = false
export let color = null
export let square = false
export let hoverable = false
export let size: string = "M"
export let celery: boolean = false
export let yellow: boolean = false
export let fuchsia: boolean = false
export let indigo: boolean = false
export let seafoam: boolean = false
export let chartreuse: boolean = false
export let magenta: boolean = false
export let purple: boolean = false
export let neutral: boolean = false
export let info: boolean = false
export let positive: boolean = false
export let notice: boolean = false
export let negative: boolean = false
export let disabled: boolean = false
export let active: boolean = false
export let color: string | undefined = undefined
export let square: boolean = false
export let hoverable: boolean = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@ -1,38 +1,24 @@
<script context="module">
export const TooltipPosition = {
Top: "top",
Right: "right",
Bottom: "bottom",
Left: "left",
}
export const TooltipType = {
Default: "default",
Info: "info",
Positive: "positive",
Negative: "negative",
}
</script>
<script>
<script lang="ts">
import Portal from "svelte-portal"
import { fade } from "svelte/transition"
import "@spectrum-css/tooltip/dist/index-vars.css"
import { onDestroy } from "svelte"
import { TooltipPosition, TooltipType } from "../constants"
export let position = TooltipPosition.Top
export let type = TooltipType.Default
export let text = ""
export let fixed = false
export let color = ""
export let noWrap = false
export let position: TooltipPosition = TooltipPosition.Top
export let type: TooltipType = TooltipType.Default
export let text: string = ""
export let fixed: boolean = false
export let color: string | undefined = undefined
export let noWrap: boolean = false
let wrapper
let wrapper: HTMLElement | undefined
let hovered = false
let left
let top
let left: number | undefined
let top: number | undefined
let visible = false
let timeout
let interval
let timeout: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
$: {
if (hovered || fixed) {
@ -49,8 +35,8 @@
const updateTooltipPosition = () => {
const node = wrapper?.children?.[0]
if (!node) {
left = null
top = null
left = undefined
top = undefined
return
}
const bounds = node.getBoundingClientRect()

View File

@ -0,0 +1,23 @@
export enum PopoverAlignment {
Left = "left",
Right = "right",
LeftOutside = "left-outside",
RightOutside = "right-outside",
Center = "center",
RightContextMenu = "right-context-menu",
LeftContextMenu = "left-context-menu",
}
export enum TooltipPosition {
Top = "top",
Right = "right",
Bottom = "bottom",
Left = "left",
}
export enum TooltipType {
Default = "default",
Info = "info",
Positive = "positive",
Negative = "negative",
}

View File

@ -1,8 +1,9 @@
import "./bbui.css"
// Spectrum icons
import "@spectrum-css/icon/dist/index-vars.css"
// Constants
export * from "./constants"
// Form components
export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte"
@ -45,7 +46,7 @@ export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte"
export { default as Popover, type PopoverAPI } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
export { default as Label } from "./Label/Label.svelte"
@ -92,7 +93,6 @@ export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
// Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"

View File

@ -16,4 +16,4 @@
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
}
}

View File

@ -81,7 +81,7 @@
"shortid": "2.2.15",
"svelte-dnd-action": "^0.9.8",
"svelte-loading-spinners": "^0.1.1",
"svelte-portal": "1.0.0",
"svelte-portal": "^2.2.1",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@ -293,7 +293,7 @@
type: RowSelector,
props: {
row: inputData["oldRow"] || {
tableId: inputData["row"].tableId,
tableId: inputData["row"]?.tableId,
},
meta: {
fields: inputData["meta"]?.oldFields || {},

View File

@ -7,7 +7,7 @@
import IntegrationIcon from "@/components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { Icon } from "@budibase/bbui"
import UpdateDatasourceModal from "@/components/backend/DatasourceNavigator/modals/UpdateDatasourceModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
import DeleteDataConfirmModal from "@/components/backend/modals/DeleteDataConfirmationModal.svelte"
export let datasource
@ -71,7 +71,10 @@
{/if}
</NavItem>
<UpdateDatasourceModal {datasource} bind:this={editModal} />
<DeleteConfirmationModal {datasource} bind:this={deleteConfirmationModal} />
<DeleteDataConfirmModal
source={datasource}
bind:this={deleteConfirmationModal}
/>
<style>
.datasource-icon {

View File

@ -1,37 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import { datasources } from "@/stores/builder"
import { notifications } from "@budibase/bbui"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
export let datasource
let confirmDeleteDialog
export const show = () => {
confirmDeleteDialog.show()
}
async function deleteDatasource() {
try {
const isSelected = datasource.selected || datasource.containsSelected
await datasources.delete(datasource)
notifications.success("Datasource deleted")
if (isSelected) {
$goto("./datasource")
}
} catch (error) {
notifications.error("Error deleting datasource")
}
}
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Datasource"
onOk={deleteDatasource}
title="Confirm Deletion"
>
Are you sure you wish to delete the datasource
<i>{datasource.name}</i>? This action cannot be undone.
</ConfirmDialog>

View File

@ -6,19 +6,18 @@
} from "@/helpers/data/utils"
import { goto as gotoStore, isActive } from "@roxi/routify"
import {
datasources,
queries,
userSelectedResourceMap,
contextMenuStore,
} from "@/stores/builder"
import NavItem from "@/components/common/NavItem.svelte"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import DeleteDataConfirmModal from "@/components/backend/modals/DeleteDataConfirmationModal.svelte"
import { notifications, Icon } from "@budibase/bbui"
export let datasource
export let query
let confirmDeleteDialog
let confirmDeleteModal
// goto won't work in the context menu callback if the store is called directly
$: goto = $gotoStore
@ -31,7 +30,7 @@
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
callback: confirmDeleteModal.show,
},
{
icon: "Duplicate",
@ -51,20 +50,6 @@
]
}
async function deleteQuery() {
try {
// Go back to the datasource if we are deleting the active query
if ($queries.selectedQueryId === query._id) {
goto(`./datasource/${query.datasourceId}`)
}
await queries.delete(query)
await datasources.fetch()
notifications.success("Query deleted")
} catch (error) {
notifications.error("Error deleting query")
}
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
@ -90,14 +75,7 @@
<Icon size="S" hoverable name="MoreSmallList" on:click={openContextMenu} />
</NavItem>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Query"
onOk={deleteQuery}
title="Confirm Deletion"
>
Are you sure you wish to delete this query? This action cannot be undone.
</ConfirmDialog>
<DeleteDataConfirmModal source={query} bind:this={confirmDeleteModal} />
<style>
</style>

View File

@ -1,175 +0,0 @@
<script>
import { goto, params } from "@roxi/routify"
import { appStore, tables, datasources, screenStore } from "@/stores/builder"
import { InlineAlert, Link, Input, notifications } from "@budibase/bbui"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { DB_TYPE_EXTERNAL } from "@/constants/backend"
export let table
let confirmDeleteDialog
let screensPossiblyAffected = []
let viewsMessage = ""
let deleteTableName
const getViewsMessage = () => {
const views = Object.values(table?.views ?? [])
if (views.length < 1) {
return ""
}
if (views.length === 1) {
return ", including 1 view"
}
return `, including ${views.length} views`
}
export const show = () => {
viewsMessage = getViewsMessage()
screensPossiblyAffected = $screenStore.screens
.filter(
screen => screen.autoTableId === table._id && screen.routing?.route
)
.map(screen => ({
text: screen.routing.route,
url: `/builder/app/${$appStore.appId}/design/${screen._id}`,
}))
confirmDeleteDialog.show()
}
async function deleteTable() {
const isSelected = $params.tableId === table._id
try {
await tables.delete(table)
if (table.sourceType === DB_TYPE_EXTERNAL) {
await datasources.fetch()
}
notifications.success("Table deleted")
if (isSelected) {
$goto(`./datasource/${table.datasourceId}`)
}
} catch (error) {
notifications.error(`Error deleting table - ${error.message}`)
}
}
function hideDeleteDialog() {
deleteTableName = ""
}
const autofillTableName = () => {
deleteTableName = table.name
}
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Table"
onOk={deleteTable}
onCancel={hideDeleteDialog}
title="Confirm Deletion"
disabled={deleteTableName !== table.name}
>
<div class="content">
<p class="firstWarning">
Are you sure you wish to delete the table
<span class="tableNameLine">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<b on:click={autofillTableName} class="tableName">{table.name}</b>
<span>?</span>
</span>
</p>
<p class="secondWarning">All table data will be deleted{viewsMessage}.</p>
<p class="thirdWarning">This action <b>cannot be undone</b>.</p>
{#if screensPossiblyAffected.length > 0}
<div class="affectedScreens">
<InlineAlert
header="The following screens were originally generated from this table and may no longer function as expected"
>
<ul class="affectedScreensList">
{#each screensPossiblyAffected as item}
<li>
<Link quiet overBackground target="_blank" href={item.url}
>{item.text}</Link
>
</li>
{/each}
</ul>
</InlineAlert>
</div>
{/if}
<p class="fourthWarning">Please enter the table name below to confirm.</p>
<Input bind:value={deleteTableName} placeholder={table.name} />
</div>
</ConfirmDialog>
<style>
.content {
margin-top: 0;
max-width: 320px;
}
.firstWarning {
margin: 0 0 12px;
max-width: 100%;
}
.tableNameLine {
display: inline-flex;
max-width: 100%;
vertical-align: bottom;
}
.tableName {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.secondWarning {
margin: 0;
max-width: 100%;
}
.thirdWarning {
margin: 0 0 12px;
max-width: 100%;
}
.affectedScreens {
margin: 18px 0;
max-width: 100%;
margin-bottom: 24px;
}
.affectedScreens :global(.spectrum-InLineAlert) {
max-width: 100%;
}
.affectedScreensList {
padding: 0;
margin-bottom: 0;
}
.affectedScreensList li {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
}
.fourthWarning {
margin: 12px 0 6px;
max-width: 100%;
}
</style>

View File

@ -8,7 +8,7 @@
import NavItem from "@/components/common/NavItem.svelte"
import { isActive } from "@roxi/routify"
import EditModal from "./EditModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
import DeleteConfirmationModal from "../../modals/DeleteDataConfirmationModal.svelte"
import { Icon } from "@budibase/bbui"
import { DB_TYPE_EXTERNAL } from "@/constants/backend"
@ -65,4 +65,4 @@
{/if}
</NavItem>
<EditModal {table} bind:this={editModal} />
<DeleteConfirmationModal {table} bind:this={deleteConfirmationModal} />
<DeleteConfirmationModal source={table} bind:this={deleteConfirmationModal} />

View File

@ -0,0 +1,226 @@
<script lang="ts">
import { Link, notifications } from "@budibase/bbui"
import {
appStore,
datasources,
queries,
screenStore,
tables,
views,
viewsV2,
} from "@/stores/builder"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { helpers, utils } from "@budibase/shared-core"
import { SourceType } from "@budibase/types"
import { goto, params } from "@roxi/routify"
import { DB_TYPE_EXTERNAL } from "@/constants/backend"
import { get } from "svelte/store"
import type { Table, ViewV2, View, Datasource, Query } from "@budibase/types"
export let source: Table | ViewV2 | Datasource | Query | undefined
let confirmDeleteDialog: any
let affectedScreens: { text: string; url: string }[] = []
let sourceType: SourceType | undefined = undefined
const getDatasourceQueries = () => {
if (sourceType !== SourceType.DATASOURCE) {
return ""
}
const sourceId = getSourceID()
const queryList = get(queries).list.filter(
query => query.datasourceId === sourceId
)
return queryList
}
function getSourceID(): string {
if (!source) {
throw new Error("No data source provided.")
}
if ("id" in source) {
return source.id
}
return source._id!
}
export const show = async () => {
const usage = await screenStore.usageInScreens(getSourceID())
affectedScreens = processScreens(usage.screens)
sourceType = usage.sourceType
confirmDeleteDialog.show()
}
function processScreens(
screens: { url: string; _id: string }[]
): { text: string; url: string }[] {
return screens.map(({ url, _id }) => ({
text: url,
url: `/builder/app/${$appStore.appId}/design/${_id}`,
}))
}
function hideDeleteDialog() {
sourceType = undefined
}
async function deleteTable(table: Table & { datasourceId?: string }) {
const isSelected = $params.tableId === table._id
try {
await tables.delete({
_id: table._id!,
_rev: table._rev!,
})
if (table.sourceType === DB_TYPE_EXTERNAL) {
await datasources.fetch()
}
notifications.success("Table deleted")
if (isSelected) {
$goto(`./datasource/${table.datasourceId}`)
}
} catch (error: any) {
notifications.error(`Error deleting table - ${error.message}`)
}
}
async function deleteView(view: ViewV2 | View) {
try {
if (helpers.views.isV2(view)) {
await viewsV2.delete(view as ViewV2)
} else {
await views.delete(view as View)
}
notifications.success("View deleted")
} catch (error) {
notifications.error("Error deleting view")
}
}
async function deleteDatasource(datasource: Datasource) {
try {
await datasources.delete(datasource)
notifications.success("Datasource deleted")
const isSelected =
get(datasources).selectedDatasourceId === datasource._id
if (isSelected) {
$goto("./datasource")
}
} catch (error) {
notifications.error("Error deleting datasource")
}
}
async function deleteQuery(query: Query) {
try {
// Go back to the datasource if we are deleting the active query
if ($queries.selectedQueryId === query._id) {
$goto(`./datasource/${query.datasourceId}`)
}
await queries.delete(query)
await datasources.fetch()
notifications.success("Query deleted")
} catch (error) {
notifications.error("Error deleting query")
}
}
async function deleteSource() {
if (!source || !sourceType) {
throw new Error("Unable to delete - no data source found.")
}
switch (sourceType) {
case SourceType.TABLE:
return await deleteTable(source as Table)
case SourceType.VIEW:
return await deleteView(source as ViewV2)
case SourceType.QUERY:
return await deleteQuery(source as Query)
case SourceType.DATASOURCE:
return await deleteDatasource(source as Datasource)
default:
utils.unreachable(sourceType)
}
}
function buildMessage(sourceType: string) {
if (!source) {
return ""
}
const screenCount = affectedScreens.length
let message = `Removing ${source?.name} `
let initialLength = message.length
if (sourceType === SourceType.TABLE) {
const views = "views" in source ? Object.values(source?.views ?? []) : []
message += `will delete its data${
views.length
? `${screenCount ? "," : " and"} views (${views.length})`
: ""
}`
} else if (sourceType === SourceType.DATASOURCE) {
const queryList = getDatasourceQueries()
if (queryList.length) {
message += `will delete its queries (${queryList.length})`
}
}
if (screenCount) {
message +=
initialLength !== message.length
? ", and break connected screens:"
: "will break connected screens:"
} else {
message += "."
}
return message.length !== initialLength ? message : ""
}
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete"
onOk={deleteSource}
onCancel={hideDeleteDialog}
title={`Are you sure you want to delete this ${sourceType}?`}
>
<div class="content">
{#if sourceType}
<p class="warning">
{buildMessage(sourceType)}
{#if affectedScreens.length > 0}
<span class="screens">
{#each affectedScreens as item, idx}
<Link overBackground target="_blank" href={item.url}
>{item.text}{idx !== affectedScreens.length - 1
? ","
: ""}</Link
>
{/each}
</span>
{/if}
</p>
{/if}
<p class="warning">
<b>This action cannot be undone.</b>
</p>
</div>
</ConfirmDialog>
<style>
.content {
margin-top: 0;
max-width: 320px;
}
.warning {
margin: 0;
max-width: 100%;
}
.screens {
display: flex;
flex-direction: row;
padding-bottom: var(--spacing-l);
gap: var(--spacing-xs);
}
</style>

View File

@ -8,7 +8,7 @@
export let onOk = undefined
export let onCancel = undefined
export let warning = true
export let disabled
export let disabled = false
let modal

View File

@ -1,14 +1,20 @@
<script>
import { Popover, Icon } from "@budibase/bbui"
<script lang="ts">
import {
Popover,
Icon,
PopoverAlignment,
type PopoverAPI,
} from "@budibase/bbui"
export let title
export let align = "left"
export let showPopover
export let width
export let title: string = ""
export let subtitle: string | undefined = undefined
export let align: PopoverAlignment = PopoverAlignment.Left
export let showPopover: boolean = true
export let width: number | undefined = undefined
let popover
let anchor
let open
let popover: PopoverAPI | undefined
let anchor: HTMLElement | undefined
let open: boolean = false
export const show = () => popover?.show()
export const hide = () => popover?.hide()
@ -30,20 +36,24 @@
{showPopover}
on:open
on:close
customZindex={100}
customZIndex={100}
>
<div class="detail-popover">
<div class="detail-popover__header">
<div class="detail-popover__title">
{title}
<Icon
name="Close"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={hide}
size="S"
/>
</div>
<Icon
name="Close"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectum-global-color-gray-900)"
on:click={hide}
/>
{#if subtitle}
<div class="detail-popover__subtitle">{subtitle}</div>
{/if}
</div>
<div class="detail-popover__body">
<slot />
@ -56,14 +66,18 @@
background-color: var(--spectrum-alias-background-color-primary);
}
.detail-popover__header {
display: flex;
flex-direction: column;
align-items: stretch;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
padding: var(--spacing-l) var(--spacing-xl);
gap: var(--spacing-s);
}
.detail-popover__title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
padding: var(--spacing-l) var(--spacing-xl);
}
.detail-popover__title {
font-size: 16px;
font-weight: 600;
}

View File

@ -0,0 +1,281 @@
<script context="module" lang="ts">
interface JSONViewerClickContext {
label: string | undefined
value: any
path: (string | number)[]
}
export interface JSONViewerClickEvent {
detail: JSONViewerClickContext
}
</script>
<script lang="ts">
import { Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
export let label: string | undefined = undefined
export let value: any = undefined
export let root: boolean = true
export let path: (string | number)[] = []
export let showCopyIcon: boolean = false
const dispatch = createEventDispatcher()
const Colors = {
Array: "var(--spectrum-global-color-gray-600)",
Object: "var(--spectrum-global-color-gray-600)",
Other: "var(--spectrum-global-color-blue-700)",
Undefined: "var(--spectrum-global-color-gray-600)",
Null: "var(--spectrum-global-color-yellow-700)",
String: "var(--spectrum-global-color-orange-700)",
Number: "var(--spectrum-global-color-purple-700)",
True: "var(--spectrum-global-color-celery-700)",
False: "var(--spectrum-global-color-red-700)",
Date: "var(--spectrum-global-color-green-700)",
}
let expanded = false
let valueExpanded = false
let clickContext: JSONViewerClickContext
$: isArray = Array.isArray(value)
$: isObject = value?.toString?.() === "[object Object]"
$: primitive = !(isArray || isObject)
$: keys = getKeys(isArray, isObject, value)
$: expandable = keys.length > 0
$: displayValue = getDisplayValue(isArray, isObject, keys, value)
$: style = getStyle(isArray, isObject, value)
$: clickContext = { value, label, path }
const getKeys = (isArray: boolean, isObject: boolean, value: any) => {
if (isArray) {
return [...value.keys()]
}
if (isObject) {
return Object.keys(value).sort()
}
return []
}
const pluralise = (text: string, number: number) => {
return number === 1 ? text : text + "s"
}
const getDisplayValue = (
isArray: boolean,
isObject: boolean,
keys: any[],
value: any
) => {
if (isArray) {
return `[] ${keys.length} ${pluralise("item", keys.length)}`
}
if (isObject) {
return `{} ${keys.length} ${pluralise("key", keys.length)}`
}
if (typeof value === "object" && typeof value?.toString === "function") {
return value.toString()
} else {
return JSON.stringify(value, null, 2)
}
}
const getStyle = (isArray: boolean, isObject: boolean, value: any) => {
return `color:${getColor(isArray, isObject, value)};`
}
const getColor = (isArray: boolean, isObject: boolean, value: any) => {
if (isArray) {
return Colors.Array
}
if (isObject) {
return Colors.Object
}
if (value instanceof Date) {
return Colors.Date
}
switch (value) {
case undefined:
return Colors.Undefined
case null:
return Colors.Null
case true:
return Colors.True
case false:
return Colors.False
}
switch (typeof value) {
case "string":
return Colors.String
case "number":
return Colors.Number
}
return Colors.Other
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="binding-node">
{#if label != null}
<div class="binding-text">
<div class="binding-arrow" class:expanded>
{#if expandable}
<Icon
name="Play"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={() => (expanded = !expanded)}
/>
{/if}
</div>
<div
class="binding-label"
class:primitive
class:expandable
on:click={() => (expanded = !expanded)}
on:click={() => dispatch("click-label", clickContext)}
>
{label}
</div>
<div
class="binding-value"
class:primitive
class:expanded={valueExpanded}
{style}
on:click={() => (valueExpanded = !valueExpanded)}
on:click={() => dispatch("click-value", clickContext)}
>
{displayValue}
</div>
{#if showCopyIcon}
<div class="copy-value-icon">
<Icon
name="Copy"
size="XS"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={() => dispatch("click-copy", clickContext)}
/>
</div>
{/if}
</div>
{/if}
{#if expandable && (expanded || label == null)}
<div class="binding-children" class:root>
{#each keys as key}
<svelte:self
label={key}
value={value[key]}
root={false}
path={[...path, key]}
{showCopyIcon}
on:click-label
on:click-value
on:click-copy
/>
{/each}
</div>
{/if}
</div>
<style>
.binding-node {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
/* Expand arrow */
.binding-arrow {
margin: -3px 6px -2px 4px;
flex: 0 0 9px;
transition: transform 130ms ease-out;
}
.binding-arrow :global(svg) {
width: 9px;
}
.binding-arrow.expanded {
transform: rotate(90deg);
}
/* Main text wrapper */
.binding-text {
display: flex;
flex-direction: row;
font-family: monospace;
font-size: 12px;
align-items: flex-start;
width: 100%;
}
/* Size label and value according to type */
.binding-label {
flex: 0 1 auto;
margin-right: 8px;
transition: color 130ms ease-out;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.binding-label.expandable:hover {
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
.binding-value {
flex: 0 0 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: filter 130ms ease-out;
}
.binding-value.primitive:hover {
filter: brightness(1.25);
cursor: pointer;
}
.binding-value.expanded {
word-break: break-all;
white-space: wrap;
}
.binding-label.primitive {
flex: 0 0 auto;
max-width: 75%;
}
.binding-value.primitive {
flex: 0 1 auto;
}
/* Trim spans in the highlighted HTML */
.binding-value :global(span) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
/* Copy icon for value */
.copy-value-icon {
display: none;
margin-left: 8px;
}
.binding-text:hover .copy-value-icon {
display: block;
}
/* Children wrapper */
.binding-children {
display: flex;
flex-direction: column;
gap: 8px;
border-left: 1px solid var(--spectrum-global-color-gray-400);
margin-left: 20px;
padding-left: 3px;
}
.binding-children.root {
border-left: none;
margin-left: 0;
padding-left: 0;
}
</style>

View File

@ -25,7 +25,7 @@
</div>
<Popover
customZindex={998}
customZIndex={998}
bind:this={formPopover}
align="center"
anchor={formPopoverAnchor}

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { Icon, Input, Drawer, Button } from "@budibase/bbui"
import {
readableToRuntimeBinding,
@ -10,25 +10,25 @@
import { builderStore } from "@/stores/builder"
export let panel = ClientBindingPanel
export let value = ""
export let bindings = []
export let title
export let placeholder
export let label
export let disabled = false
export let allowHBS = true
export let allowJS = true
export let allowHelpers = true
export let updateOnChange = true
export let key
export let disableBindings = false
export let forceModal = false
export let value: any = ""
export let bindings: any[] = []
export let title: string | undefined = undefined
export let placeholder: string | undefined = undefined
export let label: string | undefined = undefined
export let disabled: boolean = false
export let allowHBS: boolean = true
export let allowJS: boolean = true
export let allowHelpers: boolean = true
export let updateOnChange: boolean = true
export let key: string | null = null
export let disableBindings: boolean = false
export let forceModal: boolean = false
export let context = null
export let autocomplete
export let autocomplete: boolean | undefined = undefined
const dispatch = createEventDispatcher()
let bindingDrawer
let bindingDrawer: any
let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value)
@ -38,7 +38,7 @@
const saveBinding = () => {
onChange(tempValue)
onBlur()
builderStore.propertyFocus()
builderStore.propertyFocus(null)
bindingDrawer.hide()
}
@ -46,7 +46,7 @@
save: saveBinding,
})
const onChange = value => {
const onChange = (value: any) => {
currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal)
}
@ -55,8 +55,8 @@
dispatch("blur", currentVal)
}
const onDrawerHide = e => {
builderStore.propertyFocus()
const onDrawerHide = (e: any) => {
builderStore.propertyFocus(null)
dispatch("drawerHide", e.detail)
}
</script>

View File

@ -19,7 +19,7 @@
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
$: highlightedLogs = expressionLogs.map(l => ({
log: highlight(l.log.join(", ")),
log: l.log.map(part => highlight(part)).join(", "),
line: l.line,
type: l.type,
}))
@ -95,7 +95,9 @@
{#if empty}
Your expression will be evaluated here
{:else if error}
{formatError(expressionError)}
<div class="error-msg">
{formatError(expressionError)}
</div>
{:else}
<div class="output-lines">
{#each highlightedLogs as logLine}
@ -118,13 +120,17 @@
<span>{@html logLine.log}</span>
</div>
{#if logLine.line}
<span style="color: var(--blue)">:{logLine.line}</span>
<span style="color: var(--blue); overflow-wrap: normal;"
>:{logLine.line}</span
>
{/if}
</div>
{/each}
<div class="line">
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
<div>
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
</div>
</div>
</div>
{/if}
@ -169,29 +175,33 @@
.header.error::before {
background: var(--error-bg);
}
.error-msg {
padding-top: var(--spacing-m);
}
.body {
flex: 1 1 auto;
padding: var(--spacing-m) var(--spacing-l);
font-family: var(--font-mono);
margin: 0 var(--spacing-m);
font-size: 12px;
overflow-y: auto;
overflow-x: hidden;
white-space: pre-line;
word-wrap: break-word;
word-wrap: anywhere;
height: 0;
}
.output-lines {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.line {
border-bottom: var(--border-light);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: end;
padding: var(--spacing-s);
padding: var(--spacing-m) 0;
word-wrap: anywhere;
}
.line:not(:first-of-type) {
border-top: var(--border-light);
}
.icon-log {
display: flex;

View File

@ -7,8 +7,21 @@
export let dataSet
export let value
export let onSelect
export let identifiers = ["resourceId"]
$: displayDatasourceName = $datasources.list.length > 1
function isSelected(entry) {
if (!identifiers.length) {
return false
}
for (const identifier of identifiers) {
if (entry[identifier] !== value?.[identifier]) {
return false
}
}
return true
}
</script>
{#if dividerState}
@ -24,8 +37,7 @@
{#each dataSet as data}
<li
class="spectrum-Menu-item"
class:is-selected={value?.resourceId === data.resourceId &&
value?.type === data.type}
class:is-selected={isSelected(data) && value?.type === data.type}
role="option"
aria-selected="true"
tabindex="0"

View File

@ -31,6 +31,11 @@
import IntegrationQueryEditor from "@/components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "@/helpers/components"
import {
extractFields,
extractJSONArrayFields,
extractRelationships,
} from "@/helpers/bindings"
import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "@/api"
@ -81,67 +86,9 @@
value: `{{ literal ${safe(provider._id)} }}`,
type: "provider",
}))
$: links = bindings
// Get only link bindings
.filter(x => x.fieldSchema?.type === "link")
// Filter out bindings provided by forms
.filter(x => !x.component?.endsWith("/form"))
.map(binding => {
const { providerId, readableBinding, fieldSchema } = binding || {}
const { name, tableId } = fieldSchema || {}
const safeProviderId = safe(providerId)
return {
providerId,
label: readableBinding,
fieldName: name,
tableId,
type: "link",
// These properties will be enriched by the client library and provide
// details of the parent row of the relationship field, from context
rowId: `{{ ${safeProviderId}.${safe("_id")} }}`,
rowTableId: `{{ ${safeProviderId}.${safe("tableId")} }}`,
}
})
$: fields = bindings
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
type: "field",
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: jsonArrays = bindings
.filter(
x =>
x.fieldSchema?.type === "jsonarray" ||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys, subtype } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: type === "jsonarray" ? "jsonarray" : "queryarray",
subtype,
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: links = extractRelationships(bindings)
$: fields = extractFields(bindings)
$: jsonArrays = extractJSONArrayFields(bindings)
$: custom = {
type: "custom",
label: "JSON / CSV",
@ -291,6 +238,7 @@
dataSet={views}
{value}
onSelect={handleSelected}
identifiers={["tableId", "name"]}
/>
{/if}
{#if queries?.length}
@ -300,6 +248,7 @@
dataSet={queries}
{value}
onSelect={handleSelected}
identifiers={["_id"]}
/>
{/if}
{#if links?.length}
@ -309,6 +258,7 @@
dataSet={links}
{value}
onSelect={handleSelected}
identifiers={["tableId", "fieldName"]}
/>
{/if}
{#if fields?.length}
@ -318,6 +268,7 @@
dataSet={fields}
{value}
onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/>
{/if}
{#if jsonArrays?.length}
@ -327,6 +278,7 @@
dataSet={jsonArrays}
{value}
onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/>
{/if}
{#if showDataProviders && dataProviders?.length}
@ -336,6 +288,7 @@
dataSet={dataProviders}
{value}
onSelect={handleSelected}
identifiers={["providerId"]}
/>
{/if}
<DataSourceCategory

View File

@ -61,6 +61,7 @@
anchor={primaryDisplayColumnAnchor}
item={columns.primary}
on:change={e => columns.update(e.detail)}
{bindings}
/>
</div>
</div>

View File

@ -8,6 +8,7 @@
export let item
export let anchor
export let bindings
let draggableStore = writable({
selected: null,
@ -48,6 +49,7 @@
componentInstance={item}
{parseSettings}
on:change
{bindings}
>
<div slot="header" class="type-icon">
<Icon name={icon} />

View File

@ -69,6 +69,7 @@ const toGridFormat = draggableListColumns => {
active: entry.active,
width: entry.width,
conditions: entry.conditions,
format: entry.format,
}))
}
@ -85,6 +86,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
columnType: column.columnType || schema[column.field].type,
width: column.width,
conditions: column.conditions,
format: column.format,
},
{}
)

View File

@ -5,7 +5,6 @@
runtimeToReadableBinding,
} from "@/dataBinding"
import { builderStore } from "@/stores/builder"
import { onDestroy } from "svelte"
export let label = ""
export let labelHidden = false
@ -32,7 +31,7 @@
$: safeValue = getSafeValue(value, defaultValue, allBindings)
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
$: if (!Array.isArray(value)) {
$: if (value) {
highlightType =
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
}
@ -75,12 +74,6 @@
? defaultValue
: enriched
}
onDestroy(() => {
if (highlightedProp) {
builderStore.highlightSetting(null)
}
})
</script>
<div
@ -150,10 +143,10 @@
.property-control.highlighted {
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600);
margin-top: -3.5px;
margin-bottom: -3.5px;
padding-bottom: 3.5px;
padding-top: 3.5px;
margin-top: -4px;
margin-bottom: -4px;
padding-bottom: 4px;
padding-top: 4px;
}
.property-control.property-focus :global(input) {
@ -172,7 +165,7 @@
}
.text {
font-size: var(--spectrum-global-dimension-font-size-75);
color: var(--grey-6);
color: var(--spectrum-global-color-gray-700);
grid-column: 2 / 2;
}

View File

@ -20,7 +20,7 @@
const processModals = () => {
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
temporalStore.setExpiring(key, {}, oneDayInSeconds)
}
const dismissableModals = [
@ -50,7 +50,7 @@
},
]
return dismissableModals.filter(modal => {
return !temporalStore.actions.getExpiring(modal.key) && modal.criteria()
return !temporalStore.getExpiring(modal.key) && modal.criteria()
})
}

View File

@ -6,7 +6,7 @@ import { BANNER_TYPES } from "@budibase/bbui"
const oneDayInSeconds = 86400
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
temporalStore.setExpiring(key, {}, oneDayInSeconds)
}
const upgradeAction = key => {
@ -148,7 +148,7 @@ export const getBanners = () => {
buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER),
].filter(licensingBanner => {
return (
!temporalStore.actions.getExpiring(licensingBanner.key) &&
!temporalStore.getExpiring(licensingBanner.key) &&
licensingBanner.criteria()
)
})

View File

@ -96,8 +96,8 @@
maxWidth={300}
dismissible={false}
offset={12}
handlePostionUpdate={tourStep?.positionHandler}
customZindex={3}
handlePositionUpdate={tourStep?.positionHandler}
customZIndex={3}
>
<div class="tour-content">
<Layout noPadding gap="M">

View File

@ -1159,10 +1159,17 @@ export const buildFormSchema = (component, asset) => {
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
// Find all button action settings in all components
export const getAllStateVariables = screen => {
let assets = []
if (screen) {
// only include state variables from a specific screen
assets.push(screen)
} else {
// otherwise include state variables from all screens
assets = getAllAssets()
}
let eventSettings = []
getAllAssets().forEach(asset => {
assets.forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component)
const nestedTypes = [
@ -1214,11 +1221,17 @@ export const getAllStateVariables = () => {
})
// Add on load settings from screens
get(screenStore).screens.forEach(screen => {
if (screen) {
if (screen.onLoad) {
eventSettings.push(screen.onLoad)
}
})
} else {
get(screenStore).screens.forEach(screen => {
if (screen.onLoad) {
eventSettings.push(screen.onLoad)
}
})
}
// Extract all state keys from any "update state" actions in each setting
let bindingSet = new Set()

View File

@ -0,0 +1,74 @@
import { makePropSafe } from "@budibase/string-templates"
import { UIBinding } from "@budibase/types"
export function extractRelationships(bindings: UIBinding[]) {
return (
bindings
// Get only link bindings
.filter(x => x.fieldSchema?.type === "link")
// Filter out bindings provided by forms
.filter(x => !x.component?.endsWith("/form"))
.map(binding => {
const { providerId, readableBinding, fieldSchema } = binding || {}
const { name, tableId } = fieldSchema || {}
const safeProviderId = makePropSafe(providerId)
return {
providerId,
label: readableBinding,
fieldName: name,
tableId,
type: "link",
// These properties will be enriched by the client library and provide
// details of the parent row of the relationship field, from context
rowId: `{{ ${safeProviderId}.${makePropSafe("_id")} }}`,
rowTableId: `{{ ${safeProviderId}.${makePropSafe("tableId")} }}`,
}
})
)
}
export function extractFields(bindings: UIBinding[]) {
return bindings
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema!
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
type: "field",
value: `{{ literal ${runtimeBinding} }}`,
}
})
}
export function extractJSONArrayFields(bindings: UIBinding[]) {
return bindings
.filter(
x =>
x.fieldSchema?.type === "jsonarray" ||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys, subtype } = binding.fieldSchema!
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: type === "jsonarray" ? "jsonarray" : "queryarray",
subtype,
value: `{{ literal ${runtimeBinding} }}`,
}
})
}

View File

@ -11,7 +11,7 @@ export const datasourceSelect = {
},
viewV2: (view, datasources) => {
const datasource = datasources
.filter(f => f.entities)
?.filter(f => f.entities)
.flatMap(d => d.entities)
.find(ds => ds._id === view.tableId)
return {

View File

@ -76,13 +76,15 @@ export const getSequentialName = <T extends any>(
{
getName,
numberFirstItem,
separator = "",
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
separator?: string
} = {}
) => {
if (!prefix?.length) {
return null
return ""
}
const trimmedPrefix = prefix.trim()
const firstName = numberFirstItem ? `${prefix}1` : trimmedPrefix
@ -107,5 +109,5 @@ export const getSequentialName = <T extends any>(
max = num
}
})
return max === 0 ? firstName : `${prefix}${max + 1}`
return max === 0 ? firstName : `${prefix}${separator}${max + 1}`
}

View File

@ -9,3 +9,5 @@ export {
lowercase,
isBuilderInputFocused,
} from "./helpers"
export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings"

View File

@ -49,7 +49,7 @@ describe("getSequentialName", () => {
it("handles nullish prefix", async () => {
const name = getSequentialName([], null)
expect(name).toBe(null)
expect(name).toBe("")
})
it("handles just the prefix", async () => {

View File

@ -18,7 +18,7 @@
$: useAccountPortal = cloud && !$admin.disableAccountPortal
navigation.actions.init($redirect)
navigation.init($redirect)
const validateTenantId = async () => {
const host = window.location.host

View File

@ -1,35 +0,0 @@
<script>
import { views, viewsV2 } from "@/stores/builder"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { notifications } from "@budibase/bbui"
export let view
let confirmDeleteDialog
export const show = () => {
confirmDeleteDialog.show()
}
async function deleteView() {
try {
if (view.version === 2) {
await viewsV2.delete(view)
} else {
await views.delete(view)
}
notifications.success("View deleted")
} catch (error) {
console.error(error)
notifications.error("Error deleting view")
}
}
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
body={`Are you sure you wish to delete the view '${view.name}'? Your data will be deleted and this action cannot be undone.`}
okText="Delete View"
onOk={deleteView}
title="Confirm Deletion"
/>

View File

@ -10,9 +10,8 @@
import { Icon, ActionButton, ActionMenu, MenuItem } from "@budibase/bbui"
import { params, url } from "@roxi/routify"
import EditViewModal from "./EditViewModal.svelte"
import DeleteViewModal from "./DeleteViewModal.svelte"
import EditTableModal from "@/components/backend/TableNavigator/TableNavItem/EditModal.svelte"
import DeleteTableModal from "@/components/backend/TableNavigator/TableNavItem/DeleteConfirmationModal.svelte"
import DeleteConfirmationModal from "@/components/backend/modals/DeleteDataConfirmationModal.svelte"
import { UserAvatars } from "@budibase/frontend-core"
import { DB_TYPE_EXTERNAL } from "@/constants/backend"
import { TableNames } from "@/constants"
@ -314,12 +313,12 @@
{#if table && tableEditable}
<EditTableModal {table} bind:this={editTableModal} />
<DeleteTableModal {table} bind:this={deleteTableModal} />
<DeleteConfirmationModal source={table} bind:this={deleteTableModal} />
{/if}
{#if editableView}
<EditViewModal view={editableView} bind:this={editViewModal} />
<DeleteViewModal view={editableView} bind:this={deleteViewModal} />
<DeleteConfirmationModal source={editableView} bind:this={deleteViewModal} />
{/if}
<style>

View File

@ -16,6 +16,7 @@
} from "@/dataBinding"
import { ActionButton, notifications } from "@budibase/bbui"
import { capitalise } from "@/helpers"
import { builderStore } from "@/stores/builder"
import TourWrap from "@/components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "@/components/portal/onboarding/tours.js"
@ -55,6 +56,17 @@
$: id = $selectedComponent?._id
$: id, (section = tabs[0])
$: componentName = getComponentName(componentInstance)
$: highlightedSetting = $builderStore.highlightedSetting
$: if (highlightedSetting) {
if (highlightedSetting.key === "_conditions") {
section = "conditions"
} else if (highlightedSetting.key === "_styles") {
section = "styles"
} else {
section = "settings"
}
}
</script>
{#if $selectedComponent}
@ -98,7 +110,7 @@
{/each}
</div>
</span>
{#if section == "settings"}
{#if section === "settings"}
<TourWrap
stepKeys={[
BUILDER_FORM_CREATE_STEPS,
@ -115,7 +127,7 @@
/>
</TourWrap>
{/if}
{#if section == "styles"}
{#if section === "styles"}
<DesignSection
{componentInstance}
{componentBindings}
@ -130,7 +142,7 @@
componentTitle={title}
/>
{/if}
{#if section == "conditions"}
{#if section === "conditions"}
<ConditionalUISection
{componentInstance}
{componentDefinition}

View File

@ -190,7 +190,7 @@
<Icon name="DragHandle" size="XL" />
</div>
<Select
placeholder={null}
placeholder={false}
options={actionOptions}
bind:value={condition.action}
/>
@ -227,7 +227,7 @@
on:change={e => (condition.newValue = e.detail)}
/>
<Select
placeholder={null}
placeholder={false}
options={getOperatorOptions(condition)}
bind:value={condition.operator}
on:change={e => onOperatorChange(condition, e.detail)}
@ -236,7 +236,7 @@
disabled={condition.noValue || condition.operator === "oneOf"}
options={valueTypeOptions}
bind:value={condition.valueType}
placeholder={null}
placeholder={false}
on:change={e => onValueTypeChange(condition, e.detail)}
/>
{#if ["string", "number"].includes(condition.valueType)}

View File

@ -9,6 +9,7 @@
import { componentStore } from "@/stores/builder"
import ConditionalUIDrawer from "./ConditionalUIDrawer.svelte"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import { builderStore } from "@/stores/builder"
export let componentInstance
export let componentDefinition
@ -18,6 +19,8 @@
let tempValue
let drawer
$: highlighted = $builderStore.highlightedSetting?.key === "_conditions"
const openDrawer = () => {
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? []))
drawer.show()
@ -52,7 +55,9 @@
/>
<DetailSummary name={"Conditions"} collapsible={false}>
<ActionButton on:click={openDrawer}>{conditionText}</ActionButton>
<div class:highlighted>
<ActionButton fullWidth on:click={openDrawer}>{conditionText}</ActionButton>
</div>
</DetailSummary>
<Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description">
@ -61,3 +66,13 @@
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer>
<style>
.highlighted {
background: var(--spectrum-global-color-gray-300);
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
transition: background 130ms ease-out, border-color 130ms ease-out;
margin: -4px calc(-1 * var(--spacing-xl));
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
}
</style>

View File

@ -16,6 +16,7 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "@/dataBinding"
import { builderStore } from "@/stores/builder"
export let componentInstance
export let componentDefinition
@ -32,6 +33,8 @@
$: icon = componentDefinition?.icon
$: highlighted = $builderStore.highlightedSetting?.key === "_styles"
const openDrawer = () => {
tempValue = runtimeToReadableBinding(
bindings,
@ -55,7 +58,7 @@
name={`Custom CSS${componentInstance?._styles?.custom ? " *" : ""}`}
collapsible={false}
>
<div>
<div class:highlighted>
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
</div>
</DetailSummary>
@ -97,4 +100,12 @@
align-items: center;
gap: var(--spacing-m);
}
.highlighted {
background: var(--spectrum-global-color-gray-300);
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
transition: background 130ms ease-out, border-color 130ms ease-out;
margin: -4px calc(-1 * var(--spacing-xl));
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
}
</style>

View File

@ -33,7 +33,7 @@
{/each}
</div>
</div>
<Layout gap="XS" paddingX="L" paddingY="XL">
<Layout gap="XS" paddingX="XL" paddingY="XL">
{#if activeTab === "theme"}
<ThemePanel />
{:else}

View File

@ -30,7 +30,7 @@
if (id === `${$screenStore.selectedScreenId}-screen`) return true
if (id === `${$screenStore.selectedScreenId}-navigation`) return true
return !!findComponent($selectedScreen.props, id)
return !!findComponent($selectedScreen?.props, id)
}
// Keep URL and state in sync for selected component ID

View File

@ -50,6 +50,9 @@
margin-bottom: 9px;
}
.header-left {
display: flex;
}
.header-left :global(div) {
border-right: none;
}

View File

@ -11,6 +11,7 @@
selectedScreen,
hoverStore,
componentTreeNodesStore,
screenComponentErrors,
snippets,
} from "@/stores/builder"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
@ -68,6 +69,7 @@
port: window.location.port,
},
snippets: $snippets,
componentErrors: $screenComponentErrors,
}
// Refresh the preview when required

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { onMount } from "svelte"
import { Helpers, notifications } from "@budibase/bbui"
import { processObjectSync } from "@budibase/string-templates"
import {
previewStore,
selectedScreen,
componentStore,
snippets,
} from "@/stores/builder"
import { getBindableProperties } from "@/dataBinding"
import JSONViewer, {
type JSONViewerClickEvent,
} from "@/components/common/JSONViewer.svelte"
// Minimal typing for the real data binding structure, as none exists
type DataBinding = {
category: string
runtimeBinding: string
readableBinding: string
}
$: previewContext = $previewStore.selectedComponentContext || {}
$: selectedComponentId = $componentStore.selectedComponentId
$: context = makeContext(previewContext, bindings)
$: bindings = getBindableProperties($selectedScreen, selectedComponentId)
const makeContext = (
previewContext: Record<string, any>,
bindings: DataBinding[]
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
// Account for nasty hardcoded HBS bindings for roles, for legacy
// compatibility
return `{{ ${binding.runtimeBinding} }}`
} else {
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvaluations = processObjectSync(bindingStrings, {
...previewContext,
snippets: $snippets,
}) as any[]
// Deeply set values for all readable bindings
const enrichedBindings: any[] = bindings.map((binding, idx) => {
return {
...binding,
value: bindingEvaluations[idx],
}
})
let context = {}
for (let binding of enrichedBindings) {
Helpers.deepSet(context, binding.readableBinding, binding.value)
}
return context
}
const copyBinding = (e: JSONViewerClickEvent) => {
const readableBinding = `{{ ${e.detail.path.join(".")} }}`
Helpers.copyToClipboard(readableBinding)
notifications.success("Binding copied to clipboard")
}
onMount(previewStore.requestComponentContext)
</script>
<div class="bindings-panel">
<JSONViewer value={context} showCopyIcon on:click-copy={copyBinding} />
</div>
<style>
.bindings-panel {
flex: 1 1 auto;
height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l)
var(--spacing-l);
}
</style>

View File

@ -1,6 +1,5 @@
<script>
import { notifications, Icon, Body } from "@budibase/bbui"
import { isActive, goto } from "@roxi/routify"
import { notifications, Icon } from "@budibase/bbui"
import {
selectedScreen,
screenStore,
@ -13,23 +12,12 @@
import ComponentTree from "./ComponentTree.svelte"
import { dndStore, DropPosition } from "./dndStore.js"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
import getScreenContextMenuItems from "./getScreenContextMenuItems"
let scrolling = false
$: screenComponentId = `${$screenStore.selectedScreenId}-screen`
$: navComponentId = `${$screenStore.selectedScreenId}-navigation`
const toNewComponentRoute = () => {
if ($isActive(`./:componentId/new`)) {
$goto(`./:componentId`)
} else {
$goto(`./:componentId/new`)
}
}
const onDrop = async () => {
try {
await dndStore.actions.drop()
@ -39,10 +27,6 @@
}
}
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
const hover = hoverStore.hover
// showCopy is used to hide the copy button when the user right-clicks the empty
@ -72,17 +56,9 @@
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="components">
<div class="header" class:scrolling>
<Body size="S">Components</Body>
<div on:click={toNewComponentRoute} class="addButton">
<Icon name="Add" />
</div>
</div>
<div class="list-panel">
<ComponentScrollWrapper on:scroll={handleScroll}>
<ComponentScrollWrapper>
<ul
class="componentTree"
on:contextmenu={e => openScreenContextMenu(e, false)}
@ -159,7 +135,6 @@
</ul>
</ComponentScrollWrapper>
</div>
<ComponentKeyHandler />
</div>
<style>
@ -168,35 +143,13 @@
display: flex;
flex-direction: column;
flex: 1;
}
.header {
height: 50px;
box-sizing: border-box;
padding: var(--spacing-l);
display: flex;
align-items: center;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
padding-top: var(--spacing-l);
}
.components :global(.nav-item) {
padding-right: 8px !important;
}
.addButton {
margin-left: auto;
color: var(--grey-7);
cursor: pointer;
}
.addButton:hover {
color: var(--ink);
}
.list-panel {
display: flex;
flex-direction: column;

View File

@ -1,25 +1,60 @@
<script>
<script lang="ts">
import ScreenList from "./ScreenList/index.svelte"
import ComponentList from "./ComponentList/index.svelte"
import { getHorizontalResizeActions } from "@/components/common/resizable"
import { ActionButton } from "@budibase/bbui"
import StatePanel from "./StatePanel.svelte"
import BindingsPanel from "./BindingsPanel.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
const [resizable, resizableHandle] = getHorizontalResizeActions()
const Tabs = {
Components: "Components",
Bindings: "Bindings",
State: "State",
}
let activeTab = Tabs.Components
</script>
<div class="panel" use:resizable>
<div class="content">
<ScreenList />
<ComponentList />
<div class="tabs">
{#each Object.values(Tabs) as tab}
<ActionButton
quiet
selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
<div class="tab-label">
{tab}
{#if tab !== Tabs.Components}
<div class="new">NEW</div>
{/if}
</div>
</ActionButton>
{/each}
</div>
{#if activeTab === Tabs.Components}
<ComponentList />
{:else if activeTab === Tabs.Bindings}
<BindingsPanel />
{:else if activeTab === Tabs.State}
<div class="tab-content"><StatePanel /></div>
{/if}
</div>
<div class="divider">
<div class="dividerClickExtender" role="separator" use:resizableHandle />
</div>
</div>
<ComponentKeyHandler />
<style>
.panel {
display: flex;
min-width: 270px;
min-width: 310px;
width: 310px;
height: 100%;
}
@ -34,6 +69,34 @@
position: relative;
}
.tabs {
display: flex;
flex-direction: row;
gap: 8px;
padding: var(--spacing-m) var(--spacing-l);
border-bottom: var(--border-light);
}
.tab-content {
flex: 1 1 auto;
height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-l);
}
.tab-label {
display: flex;
align-items: center;
gap: 4px;
}
.new {
font-size: 8px;
background: var(--bb-indigo);
border-radius: 2px;
padding: 1px 3px;
color: white;
font-weight: bold;
}
.divider {
position: relative;
height: 100%;
@ -45,7 +108,6 @@
background: var(--spectrum-global-color-gray-300);
cursor: row-resize;
}
.dividerClickExtender {
position: absolute;
cursor: col-resize;

View File

@ -0,0 +1,336 @@
<script lang="ts">
import { onMount } from "svelte"
import { Select } from "@budibase/bbui"
import type {
Component,
ComponentCondition,
ComponentSetting,
EventHandler,
Screen,
} from "@budibase/types"
import { getAllStateVariables, getBindableProperties } from "@/dataBinding"
import {
componentStore,
selectedScreen,
builderStore,
previewStore,
} from "@/stores/builder"
import {
decodeJSBinding,
findHBSBlocks,
isJSBinding,
processStringSync,
} from "@budibase/string-templates"
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
interface ComponentUsingState {
id: string
name: string
setting: string
}
let selectedKey: string | undefined = undefined
let componentsUsingState: ComponentUsingState[] = []
let componentsUpdatingState: ComponentUsingState[] = []
let editorValue: string = ""
$: selectStateKey($selectedScreen, selectedKey)
$: keyOptions = getAllStateVariables($selectedScreen)
$: bindings = getBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
// Auto-select first valid state key
$: {
if (keyOptions.length && !keyOptions.includes(selectedKey)) {
selectedKey = keyOptions[0]
} else if (!keyOptions.length) {
selectedKey = undefined
}
}
const selectStateKey = (
screen: Screen | undefined,
key: string | undefined
) => {
if (screen && key) {
searchComponents(screen, key)
editorValue = $previewStore.selectedComponentContext?.state?.[key] ?? ""
} else {
editorValue = ""
componentsUsingState = []
componentsUpdatingState = []
}
}
const searchComponents = (screen: Screen, stateKey: string) => {
const { props, onLoad, _id } = screen
componentsUsingState = findComponentsUsingState(props, stateKey)
componentsUpdatingState = findComponentsUpdatingState(props, stateKey)
// Check screen load actions which are outside the component hierarchy
if (eventUpdatesState(onLoad, stateKey)) {
componentsUpdatingState.push({
id: _id!,
name: "Screen - On load",
setting: "onLoad",
})
}
}
// Checks if an event setting updates a certain state key
const eventUpdatesState = (
handlers: EventHandler[] | undefined,
stateKey: string
) => {
return handlers?.some(handler => {
return (
handler["##eventHandlerType"] === "Update State" &&
handler.parameters?.key === stateKey
)
})
}
// Checks if a setting for the given component updates a certain state key
const settingUpdatesState = (
component: Record<string, any>,
setting: ComponentSetting,
stateKey: string
) => {
if (setting.type === "event") {
return eventUpdatesState(component[setting.key], stateKey)
} else if (setting.type === "buttonConfiguration") {
const buttons = component[setting.key]
if (Array.isArray(buttons)) {
for (let button of buttons) {
if (eventUpdatesState(button.onClick, stateKey)) {
return true
}
}
}
}
return false
}
// Checks if a condition updates a certain state key
const conditionUpdatesState = (
condition: ComponentCondition,
settings: ComponentSetting[],
stateKey: string
) => {
const setting = settings.find(s => s.key === condition.setting)
if (!setting) {
return false
}
const component = { [setting.key]: condition.settingValue }
return settingUpdatesState(component, setting, stateKey)
}
const findComponentsUpdatingState = (
component: Component,
stateKey: string,
foundComponents: ComponentUsingState[] = []
): ComponentUsingState[] => {
const { _children, _conditions, _component, _instanceName, _id } = component
const settings = componentStore
.getComponentSettings(_component)
.filter(s => s.type === "event" || s.type === "buttonConfiguration")
// Check all settings of this component
settings.forEach(setting => {
if (settingUpdatesState(component, setting, stateKey)) {
const label = setting.label || setting.key
foundComponents.push({
id: _id!,
name: `${_instanceName} - ${label}`,
setting: setting.key,
})
}
})
// Check if conditions update these settings to update this state key
if (_conditions?.some(c => conditionUpdatesState(c, settings, stateKey))) {
foundComponents.push({
id: _id!,
name: `${_instanceName} - Conditions`,
setting: "_conditions",
})
}
// Check children
_children?.forEach(child => {
findComponentsUpdatingState(child, stateKey, foundComponents)
})
return foundComponents
}
const findComponentsUsingState = (
component: Component,
stateKey: string,
componentsUsingState: ComponentUsingState[] = []
): ComponentUsingState[] => {
const settings = componentStore.getComponentSettings(component._component)
// Check all settings of this component
const settingsWithState = getSettingsUsingState(component, stateKey)
settingsWithState.forEach(setting => {
// Get readable label for this setting
let label = settings.find(s => s.key === setting)?.label || setting
if (setting === "_conditions") {
label = "Conditions"
} else if (setting === "_styles") {
label = "Styles"
}
componentsUsingState.push({
id: component._id!,
name: `${component._instanceName} - ${label}`,
setting,
})
})
// Check children
component._children?.forEach(child => {
findComponentsUsingState(child, stateKey, componentsUsingState)
})
return componentsUsingState
}
const getSettingsUsingState = (
component: Component,
stateKey: string
): string[] => {
return Object.entries(component)
.filter(([key]) => key !== "_children")
.filter(([_, value]) => hasStateBinding(JSON.stringify(value), stateKey))
.map(([key]) => key)
}
const hasStateBinding = (value: string, stateKey: string): boolean => {
const bindings = findHBSBlocks(value).map(binding => {
const sanitizedBinding = binding.replace(/\\"/g, '"')
return isJSBinding(sanitizedBinding)
? decodeJSBinding(sanitizedBinding)
: sanitizedBinding
})
return bindings.join(" ").includes(stateKey)
}
const onClickComponentLink = (component: ComponentUsingState) => {
componentStore.select(component.id)
builderStore.highlightSetting(component.setting)
}
const handleStateInspectorChange = (e: CustomEvent) => {
if (!selectedKey || !$previewStore.selectedComponentContext) {
return
}
const stateUpdate = {
[selectedKey]: processStringSync(
e.detail,
$previewStore.selectedComponentContext
),
}
previewStore.updateState(stateUpdate)
editorValue = e.detail
}
onMount(() => {
previewStore.requestComponentContext()
})
</script>
<div class="state-panel">
<Select
label="State variable"
bind:value={selectedKey}
placeholder={keyOptions.length > 0 ? false : "No state variables found"}
options={keyOptions}
/>
{#if selectedKey && keyOptions.length > 0}
<DrawerBindableInput
value={editorValue}
title={`Set value for "${selectedKey}"`}
placeholder="Enter a value"
label="Set temporary value for design preview"
on:change={e => handleStateInspectorChange(e)}
{bindings}
/>
{/if}
{#if componentsUsingState.length > 0}
<div class="section">
<span class="text">Updates</span>
<div class="updates-section">
{#each componentsUsingState as component}
<button
class="component-link updates-colour"
on:click={() => onClickComponentLink(component)}
>
{component.name}
</button>
{/each}
</div>
</div>
{/if}
{#if componentsUpdatingState.length > 0}
<div class="section">
<span class="text">Controlled by</span>
<div class="updates-section">
{#each componentsUpdatingState as component}
<button
class="component-link controlled-by-colour"
on:click={() => onClickComponentLink(component)}
>
{component.name}
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.state-panel {
background-color: var(--spectrum-alias-background-color-primary);
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
margin-top: var(--spacing-s);
}
.text {
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
.updates-colour {
color: var(--bb-indigo-light);
}
.controlled-by-colour {
color: var(--spectrum-global-color-orange-700);
}
.component-link {
display: inline-block;
border: none;
background: none;
text-decoration: underline;
cursor: pointer;
padding: 0;
white-space: nowrap;
font-size: 12px;
transition: filter 130ms ease-out;
}
.component-link:hover {
text-decoration: underline;
filter: brightness(1.2);
}
.updates-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-s);
}
</style>

View File

@ -40,26 +40,33 @@ function setupEnv(hosting, features = {}, flags = {}) {
describe("AISettings", () => {
let instance = null
const setupDOM = () => {
instance = render(AISettings, {})
const modalContainer = document.createElement("div")
modalContainer.classList.add("modal-container")
instance.baseElement.appendChild(modalContainer)
}
afterEach(() => {
vi.restoreAllMocks()
})
it("that the AISettings is rendered", () => {
instance = render(AISettings, {})
setupDOM()
expect(instance).toBeDefined()
})
describe("Licensing", () => {
it("should show the premium label on self host for custom configs", async () => {
setupEnv(Hosting.Self)
instance = render(AISettings, {})
setupDOM()
const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument()
})
it("should show the enterprise label on cloud for custom configs", async () => {
setupEnv(Hosting.Cloud)
instance = render(AISettings, {})
setupDOM()
const enterpriseTag = instance.queryByText("Enterprise")
expect(enterpriseTag).toBeInTheDocument()
})
@ -69,7 +76,7 @@ describe("AISettings", () => {
let configModal
setupEnv(Hosting.Cloud)
instance = render(AISettings)
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
@ -86,7 +93,7 @@ describe("AISettings", () => {
{ customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true }
)
instance = render(AISettings)
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
@ -103,7 +110,7 @@ describe("AISettings", () => {
{ customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true }
)
instance = render(AISettings)
setupDOM()
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)

View File

@ -2,7 +2,7 @@ import { derived, get } from "svelte/store"
import { API } from "@/api"
import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { createHistoryStore } from "@/stores/builder/history"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { licensing } from "@/stores/portal"
import { tables, appStore } from "@/stores/builder"
import { notifications } from "@budibase/bbui"
@ -1428,7 +1428,7 @@ const automationActions = (store: AutomationStore) => ({
})
class AutomationStore extends BudiStore<AutomationState> {
history: any
history: HistoryStore<Automation>
actions: ReturnType<typeof automationActions>
constructor() {
@ -1437,8 +1437,6 @@ class AutomationStore extends BudiStore<AutomationState> {
this.history = createHistoryStore({
getDoc: this.actions.getDefinition.bind(this),
selectDoc: this.actions.select.bind(this),
beforeAction: () => {},
afterAction: () => {},
})
// Then wrap save and delete with history

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store"
import { selectedScreen as selectedScreenStore } from "./screens"
import { findComponentPath } from "@/helpers/components"
import { Screen, Component } from "@budibase/types"
import { Component, Screen } from "@budibase/types"
import { BudiStore, PersistenceType } from "@/stores/BudiStore"
interface OpenNodesState {
@ -49,7 +49,12 @@ export class ComponentTreeNodesStore extends BudiStore<OpenNodesState> {
// Will ensure all parents of a node are expanded so that it is visible in the tree
makeNodeVisible(componentId: string) {
const selectedScreen: Screen = get(selectedScreenStore)
const selectedScreen: Screen | undefined = get(selectedScreenStore)
if (!selectedScreen) {
console.error("Invalid node " + componentId)
return {}
}
const path = findComponentPath(selectedScreen.props, componentId)

View File

@ -20,6 +20,8 @@ import {
previewStore,
tables,
componentTreeNodesStore,
builderStore,
screenComponents,
} from "@/stores/builder"
import { buildFormSchema, getSchemaForDatasource } from "@/dataBinding"
import {
@ -30,35 +32,27 @@ import {
} from "@/constants/backend"
import { BudiStore } from "../BudiStore"
import { Utils } from "@budibase/frontend-core"
import { Component, FieldType, Screen, Table } from "@budibase/types"
import {
ComponentDefinition,
ComponentSetting,
Component as ComponentType,
ComponentCondition,
FieldType,
Screen,
Table,
} from "@budibase/types"
import { utils } from "@budibase/shared-core"
import { getSequentialName } from "@/helpers/duplicate"
interface ComponentDefinition {
component: string
name: string
friendlyName?: string
hasChildren?: boolean
settings?: ComponentSetting[]
features?: Record<string, boolean>
typeSupportPresets?: Record<string, any>
interface Component extends ComponentType {
_id: string
}
interface ComponentSetting {
key: string
type: string
section?: string
name?: string
defaultValue?: any
selectAllFields?: boolean
resetOn?: string | string[]
settings?: ComponentSetting[]
}
interface ComponentState {
export interface ComponentState {
components: Record<string, ComponentDefinition>
customComponents: string[]
selectedComponentId: string | null
componentToPaste?: Component | null
selectedComponentId?: string
componentToPaste?: Component
settingsCache: Record<string, ComponentSetting[]>
selectedScreenId?: string | null
}
@ -66,8 +60,6 @@ interface ComponentState {
export const INITIAL_COMPONENTS_STATE: ComponentState = {
components: {},
customComponents: [],
selectedComponentId: null,
componentToPaste: null,
settingsCache: {},
}
@ -254,7 +246,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} opts
* @returns
*/
enrichEmptySettings(component: Component, opts: any) {
enrichEmptySettings(
component: Component,
opts: { screen?: Screen; parent?: Component; useDefaultValues?: boolean }
) {
if (!component?._component) {
return
}
@ -364,7 +359,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
getSchemaForDatasource(screen, dataSource, {})
// Finds fields by types from the schema of the configured datasource
const findFieldTypes = (fieldTypes: any) => {
const findFieldTypes = (fieldTypes: FieldType | FieldType[]) => {
if (!Array.isArray(fieldTypes)) {
fieldTypes = [fieldTypes]
}
@ -439,14 +434,32 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} parent
* @returns
*/
createInstance(componentName: string, presetProps: any, parent: any) {
const definition = this.getDefinition(componentName)
createInstance(
componentType: string,
presetProps: any,
parent: any
): Component | null {
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const definition = this.getDefinition(componentType)
if (!definition) {
return null
}
const componentName = getSequentialName(
get(screenComponents),
`New ${definition.friendlyName || definition.name}`,
{
getName: c => c._instanceName,
separator: " ",
}
)
// Generate basic component structure
let instance = {
let instance: Component = {
_id: Helpers.uuid(),
_component: definition.component,
_styles: {
@ -454,14 +467,14 @@ export class ComponentStore extends BudiStore<ComponentState> {
hover: {},
active: {},
},
_instanceName: `New ${definition.friendlyName || definition.name}`,
_instanceName: componentName,
...presetProps,
}
// Standard post processing
this.enrichEmptySettings(instance, {
parent,
screen: get(selectedScreen),
screen,
useDefaultValues: true,
})
@ -473,16 +486,16 @@ export class ComponentStore extends BudiStore<ComponentState> {
}
// Custom post processing for creation only
let extras: any = {}
let extras: Partial<Component> = {}
if (definition.hasChildren) {
extras._children = []
}
// Add step name to form steps
if (componentName.endsWith("/formstep")) {
if (componentType.endsWith("/formstep")) {
const parentForm = findClosestMatchingComponent(
get(selectedScreen).props,
get(selectedComponent)._id,
screen.props,
get(selectedComponent)?._id,
(component: Component) => component._component.endsWith("/form")
)
const formSteps = findAllMatchingComponents(
@ -508,14 +521,14 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @returns
*/
async create(
componentName: string,
componentType: string,
presetProps: any,
parent: any,
parent: Component,
index: number
) {
const state = get(this.store)
const componentInstance = this.createInstance(
componentName,
componentType,
presetProps,
parent
)
@ -541,7 +554,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Find the selected component
let selectedComponentId = state.selectedComponentId
if (selectedComponentId?.startsWith(`${screen._id}-`)) {
selectedComponentId = screen.props._id || null
selectedComponentId = screen.props._id
}
const currentComponent = findComponent(
screen.props,
@ -652,7 +665,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Determine the next component to select, and select it before deletion
// to avoid an intermediate state of no component selection
const state = get(this.store)
let nextId: string | null = ""
let nextId = ""
if (state.selectedComponentId === component._id) {
nextId = this.getNext()
if (!nextId) {
@ -711,14 +724,16 @@ export class ComponentStore extends BudiStore<ComponentState> {
}
}
/**
*
* @param {string} componentId
*/
select(componentId: string) {
select(id: string) {
this.update(state => {
state.selectedComponentId = componentId
return state
// Only clear highlights if selecting a different component
if (!id.includes(state.selectedComponentId!)) {
builderStore.highlightSetting()
}
return {
...state,
selectedComponentId: id,
}
})
}
@ -739,7 +754,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!state.componentToPaste) {
return
}
let newComponentId: string | null = ""
let newComponentId = ""
// Remove copied component if cutting, regardless if pasting works
let componentToPaste = cloneDeep(state.componentToPaste)
@ -767,7 +782,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!cut) {
componentToPaste = makeComponentUnique(componentToPaste)
}
newComponentId = componentToPaste._id!
newComponentId = componentToPaste._id
// Strip grid position metadata if pasting into a new screen, but keep
// alignment metadata
@ -841,6 +856,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
const state = get(this.store)
const componentId = state.selectedComponentId
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(
(x: Component) => x._id === componentId
@ -890,6 +908,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
const component = get(selectedComponent)
const componentId = component?._id
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(
(x: Component) => x._id === componentId
@ -904,7 +925,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// If we have children, select first child, and the node is not collapsed
if (
component._children?.length &&
component?._children?.length &&
(state.selectedComponentId === navComponentId ||
componentTreeNodesStore.isNodeExpanded(component._id))
) {
@ -1094,7 +1115,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
})
}
async updateConditions(conditions: Record<string, any>) {
async updateConditions(conditions: ComponentCondition[]) {
await this.patch((component: Component) => {
component._conditions = conditions
})
@ -1156,7 +1177,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
}
async handleEjectBlock(componentId: string, ejectedDefinition: Component) {
let nextSelectedComponentId: string | null = null
let nextSelectedComponentId: string | undefined
await screenStore.patch((screen: Screen) => {
const block = findComponent(screen.props, componentId)
@ -1192,7 +1213,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
(x: Component) => x._id === componentId
)
parent._children[index] = ejectedDefinition
nextSelectedComponentId = ejectedDefinition._id ?? null
nextSelectedComponentId = ejectedDefinition._id
}, null)
// Select new root component
@ -1328,12 +1349,15 @@ export const componentStore = new ComponentStore()
export const selectedComponent = derived(
[componentStore, selectedScreen],
([$store, $selectedScreen]) => {
([$store, $selectedScreen]): Component | null => {
if (
$selectedScreen &&
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
) {
return $selectedScreen?.props
return {
...$selectedScreen.props,
_id: $selectedScreen.props._id!,
}
}
if (!$selectedScreen || !$store.selectedComponentId) {
return null

View File

@ -1,10 +1,25 @@
import * as jsonpatch from "fast-json-patch/index.mjs"
import { writable, derived, get } from "svelte/store"
import { Document } from "@budibase/types"
import * as jsonpatch from "fast-json-patch"
import { writable, derived, get, Readable } from "svelte/store"
export const Operations = {
Add: "Add",
Delete: "Delete",
Change: "Change",
export const enum Operations {
Add = "Add",
Delete = "Delete",
Change = "Change",
}
interface Operator<T extends Document> {
id?: number
type: Operations
doc: T
forwardPatch?: jsonpatch.Operation[]
backwardsPatch?: jsonpatch.Operation[]
}
interface HistoryState<T extends Document> {
history: Operator<T>[]
position: number
loading?: boolean
}
export const initialState = {
@ -13,14 +28,38 @@ export const initialState = {
loading: false,
}
export const createHistoryStore = ({
export interface HistoryStore<T extends Document>
extends Readable<
HistoryState<T> & {
canUndo: boolean
canRedo: boolean
}
> {
wrapSaveDoc: (
fn: (doc: T) => Promise<T>
) => (doc: T, operationId?: number) => Promise<T>
wrapDeleteDoc: (
fn: (doc: T) => Promise<void>
) => (doc: T, operationId?: number) => Promise<void>
reset: () => void
undo: () => Promise<void>
redo: () => Promise<void>
}
export const createHistoryStore = <T extends Document>({
getDoc,
selectDoc,
beforeAction,
afterAction,
}) => {
}: {
getDoc: (id: string) => T | undefined
selectDoc: (id: string) => void
beforeAction?: (operation?: Operator<T>) => void
afterAction?: (operation?: Operator<T>) => void
}): HistoryStore<T> => {
// Use a derived store to check if we are able to undo or redo any operations
const store = writable(initialState)
const store = writable<HistoryState<T>>(initialState)
const derivedStore = derived(store, $store => {
return {
...$store,
@ -31,8 +70,8 @@ export const createHistoryStore = ({
// Wrapped versions of essential functions which we call ourselves when using
// undo and redo
let saveFn
let deleteFn
let saveFn: (doc: T, operationId?: number) => Promise<T>
let deleteFn: (doc: T, operationId?: number) => Promise<void>
/**
* Internal util to set the loading flag
@ -66,7 +105,7 @@ export const createHistoryStore = ({
* For internal use only.
* @param operation the operation to save
*/
const saveOperation = operation => {
const saveOperation = (operation: Operator<T>) => {
store.update(state => {
// Update history
let history = state.history
@ -93,15 +132,15 @@ export const createHistoryStore = ({
* @param fn the save function
* @returns {function} a wrapped version of the save function
*/
const wrapSaveDoc = fn => {
saveFn = async (doc, operationId) => {
const wrapSaveDoc = (fn: (doc: T) => Promise<T>) => {
saveFn = async (doc: T, operationId?: number) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
}
startLoading()
try {
const oldDoc = getDoc(doc._id)
const oldDoc = getDoc(doc._id!)
const newDoc = jsonpatch.deepClone(await fn(doc))
// Store the change
@ -141,8 +180,8 @@ export const createHistoryStore = ({
* @param fn the delete function
* @returns {function} a wrapped version of the delete function
*/
const wrapDeleteDoc = fn => {
deleteFn = async (doc, operationId) => {
const wrapDeleteDoc = (fn: (doc: T) => Promise<void>) => {
deleteFn = async (doc: T, operationId?: number) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
@ -201,7 +240,7 @@ export const createHistoryStore = ({
// Undo ADD
if (operation.type === Operations.Add) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const latestDoc = getDoc(operation.doc._id!)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
@ -219,7 +258,7 @@ export const createHistoryStore = ({
// Undo CHANGE
else {
// Get the current doc and apply the backwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
let doc = jsonpatch.deepClone(getDoc(operation.doc._id!))
if (doc) {
jsonpatch.applyPatch(
doc,
@ -283,7 +322,7 @@ export const createHistoryStore = ({
// Redo DELETE
else if (operation.type === Operations.Delete) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const latestDoc = getDoc(operation.doc._id!)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
@ -291,7 +330,7 @@ export const createHistoryStore = ({
// Redo CHANGE
else {
// Get the current doc and apply the forwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
let doc = jsonpatch.deepClone(getDoc(operation.doc._id!))
if (doc) {
jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch))
await saveFn(doc, operation.id)

View File

@ -3,7 +3,7 @@ import { appStore } from "./app.js"
import { componentStore, selectedComponent } from "./components"
import { navigationStore } from "./navigation.js"
import { themeStore } from "./theme.js"
import { screenStore, selectedScreen, sortedScreens } from "./screens.js"
import { screenStore, selectedScreen, sortedScreens } from "./screens"
import { builderStore } from "./builder.js"
import { hoverStore } from "./hover.js"
import { previewStore } from "./preview.js"
@ -16,6 +16,11 @@ import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets"
import {
screenComponents,
screenComponentErrors,
findComponentsBySettingsType,
} from "./screenComponent"
// Backend
import { tables } from "./tables"
@ -67,6 +72,9 @@ export {
snippets,
rowActions,
appPublished,
screenComponents,
screenComponentErrors,
findComponentsBySettingsType,
}
export const reset = () => {

View File

@ -82,6 +82,10 @@ export class PreviewStore extends BudiStore<PreviewState> {
}))
}
updateState(data: Record<string, any>) {
this.sendEvent("builder-state", data)
}
requestComponentContext() {
this.sendEvent("request-context")
}

View File

@ -0,0 +1,281 @@
import { derived } from "svelte/store"
import { tables } from "./tables"
import { selectedScreen } from "./screens"
import { viewsV2 } from "./viewsV2"
import {
UIDatasourceType,
Screen,
Component,
UIComponentError,
ScreenProps,
ComponentDefinition,
} from "@budibase/types"
import { queries } from "./queries"
import { views } from "./views"
import { findAllComponents } from "@/helpers/components"
import { bindings } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
import { componentStore } from "./components"
import { getSettingsDefinition } from "@budibase/frontend-core"
function reduceBy<TItem extends {}, TKey extends keyof TItem>(
key: TKey,
list: TItem[]
): Record<string, any> {
return list.reduce(
(result, item) => ({
...result,
[item[key] as string]: item,
}),
{}
)
}
const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
viewV2: "view",
}
const validationKeyByType: Record<UIDatasourceType, string | null> = {
table: "tableId",
view: "name",
viewV2: "id",
query: "_id",
custom: null,
link: "rowId",
field: "value",
jsonarray: "value",
}
export const screenComponentErrors = derived(
[selectedScreen, tables, views, viewsV2, queries, componentStore],
([
$selectedScreen,
$tables,
$views,
$viewsV2,
$queries,
$componentStore,
]): Record<string, UIComponentError[]> => {
if (!$selectedScreen) {
return {}
}
const datasources = {
...reduceBy("_id", $tables.list),
...reduceBy("name", $views.list),
...reduceBy("id", $viewsV2.list),
...reduceBy("_id", $queries.list),
}
const { components: definitions } = $componentStore
const errors = {
...getInvalidDatasources($selectedScreen, datasources, definitions),
...getMissingAncestors($selectedScreen, definitions),
...getMissingRequiredSettings($selectedScreen, definitions),
}
return errors
}
)
function getInvalidDatasources(
screen: Screen,
datasources: Record<string, any>,
definitions: Record<string, ComponentDefinition>
) {
const result: Record<string, UIComponentError[]> = {}
for (const { component, setting } of findComponentsBySettingsType(
screen,
["table", "dataSource"],
definitions
)) {
const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const componentBindings = getBindableProperties(screen, component._id)
const componentDatasources = {
...reduceBy("rowId", bindings.extractRelationships(componentBindings)),
...reduceBy("value", bindings.extractFields(componentBindings)),
...reduceBy("value", bindings.extractJSONArrayFields(componentBindings)),
}
const resourceId = componentSettings[validationKey]
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [
{
key: setting.key,
message: `The ${friendlyTypeName} named "${label}" could not be found`,
errorType: "setting",
},
]
}
}
return result
}
function getMissingRequiredSettings(
screen: Screen,
definitions: Record<string, ComponentDefinition>
) {
const allComponents = findAllComponents(screen.props) as Component[]
const result: Record<string, UIComponentError[]> = {}
for (const component of allComponents) {
const definition = definitions[component._component]
const settings = getSettingsDefinition(definition)
const missingRequiredSettings = settings.filter((setting: any) => {
let empty =
component[setting.key] == null || component[setting.key] === ""
let missing = setting.required && empty
// Check if this setting depends on another, as it may not be required
if (setting.dependsOn) {
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
const dependsOnValue = setting.dependsOn.value
const realDependentValue = component[dependsOnKey]
const sectionDependsOnKey =
setting.sectionDependsOn?.setting || setting.sectionDependsOn
const sectionDependsOnValue = setting.sectionDependsOn?.value
const sectionRealDependentValue = component[sectionDependsOnKey]
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
}
return missing
})
if (missingRequiredSettings?.length) {
result[component._id!] = missingRequiredSettings.map((s: any) => ({
key: s.key,
message: `Add the <mark>${s.label}</mark> setting to start using your component`,
errorType: "setting",
}))
}
}
return result
}
const BudibasePrefix = "@budibase/standard-components/"
function getMissingAncestors(
screen: Screen,
definitions: Record<string, ComponentDefinition>
) {
const result: Record<string, UIComponentError[]> = {}
function checkMissingAncestors(component: Component, ancestors: string[]) {
for (const child of component._children || []) {
checkMissingAncestors(child, [...ancestors, component._component])
}
const definition = definitions[component._component]
if (!definition?.requiredAncestors?.length) {
return
}
const missingAncestors = definition.requiredAncestors.filter(
ancestor => !ancestors.includes(`${BudibasePrefix}${ancestor}`)
)
if (missingAncestors.length) {
const pluralise = (name: string) => {
return name.endsWith("s") ? `${name}'` : `${name}s`
}
result[component._id!] = missingAncestors.map(ancestor => {
const ancestorDefinition = definitions[`${BudibasePrefix}${ancestor}`]
return {
message: `${pluralise(definition.name)} need to be inside a
<mark>${ancestorDefinition.name}</mark>`,
errorType: "ancestor-setting",
ancestor: {
name: ancestorDefinition.name,
fullType: `${BudibasePrefix}${ancestor}`,
},
}
})
}
}
checkMissingAncestors(screen.props, [])
return result
}
export function findComponentsBySettingsType(
screen: Screen,
type: string | string[],
definitions: Record<string, ComponentDefinition>
) {
const typesArray = Array.isArray(type) ? type : [type]
const result: {
component: Component
setting: {
type: string
key: string
}
}[] = []
function recurseFieldComponentsInChildren(component: ScreenProps) {
if (!component) {
return
}
const definition = definitions[component._component]
const setting = definition?.settings?.find((s: any) =>
typesArray.includes(s.type)
)
if (setting) {
result.push({
component,
setting: { type: setting.type, key: setting.key },
})
}
component._children?.forEach(child => {
recurseFieldComponentsInChildren(child)
})
}
recurseFieldComponentsInChildren(screen?.props)
return result
}
export const screenComponents = derived(
[selectedScreen],
([$selectedScreen]) => {
if (!$selectedScreen) {
return []
}
return findAllComponents($selectedScreen.props) as Component[]
}
)

View File

@ -10,18 +10,35 @@ import {
navigationStore,
selectedComponent,
} from "@/stores/builder"
import { createHistoryStore } from "@/stores/builder/history"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import {
FetchAppPackageResponse,
DeleteScreenResponse,
Screen,
Component,
SaveScreenResponse,
ComponentDefinition,
} from "@budibase/types"
export const INITIAL_SCREENS_STATE = {
screens: [],
selectedScreenId: null,
interface ScreenState {
screens: Screen[]
selectedScreenId?: string
}
export class ScreenStore extends BudiStore {
export const initialScreenState: ScreenState = {
screens: [],
}
// Review the nulls
export class ScreenStore extends BudiStore<ScreenState> {
history: HistoryStore<Screen>
delete: (screens: Screen) => Promise<void>
save: (screen: Screen) => Promise<Screen>
constructor() {
super(INITIAL_SCREENS_STATE)
super(initialScreenState)
// Bind scope
this.select = this.select.bind(this)
@ -38,14 +55,15 @@ export class ScreenStore extends BudiStore {
this.removeCustomLayout = this.removeCustomLayout.bind(this)
this.history = createHistoryStore({
getDoc: id => get(this.store).screens?.find(screen => screen._id === id),
getDoc: (id: string) =>
get(this.store).screens?.find(screen => screen._id === id),
selectDoc: this.select,
afterAction: () => {
// Ensure a valid component is selected
if (!get(selectedComponent)) {
this.update(state => ({
componentStore.update(state => ({
...state,
selectedComponentId: get(this.store).selected?.props._id,
selectedComponentId: get(selectedScreen)?._id,
}))
}
},
@ -59,14 +77,14 @@ export class ScreenStore extends BudiStore {
* Reset entire store back to base config
*/
reset() {
this.store.set({ ...INITIAL_SCREENS_STATE })
this.store.set({ ...initialScreenState })
}
/**
* Replace ALL store screens with application package screens
* @param {object} pkg
* @param {FetchAppPackageResponse} pkg
*/
syncAppScreens(pkg) {
syncAppScreens(pkg: FetchAppPackageResponse) {
this.update(state => ({
...state,
screens: [...pkg.screens],
@ -79,7 +97,7 @@ export class ScreenStore extends BudiStore {
* @param {string} screenId
* @returns
*/
select(screenId) {
select(screenId: string) {
// Check this screen exists
const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId)
@ -103,18 +121,18 @@ export class ScreenStore extends BudiStore {
* Recursively parses the entire screen doc and checks for components
* violating illegal child configurations.
*
* @param {object} screen
* @param {Screen} screen
* @throws Will throw an error containing the name of the component causing
* the invalid screen state
*/
validate(screen) {
validate(screen: Screen) {
// Recursive function to find any illegal children in component trees
const findIllegalChild = (
component,
illegalChildren = [],
legalDirectChildren = []
) => {
const type = component._component
component: Component,
illegalChildren: string[] = [],
legalDirectChildren: string[] = []
): string | undefined => {
const type: string = component._component
if (illegalChildren.includes(type)) {
return type
@ -137,7 +155,13 @@ export class ScreenStore extends BudiStore {
illegalChildren = []
}
const definition = componentStore.getDefinition(component._component)
const definition: ComponentDefinition | null =
componentStore.getDefinition(component._component)
if (definition == null) {
throw `Invalid defintion ${component._component}`
}
// Reset whitelist for direct children
legalDirectChildren = []
if (definition?.legalDirectChildren?.length) {
@ -172,7 +196,7 @@ export class ScreenStore extends BudiStore {
const illegalChild = findIllegalChild(screen.props)
if (illegalChild) {
const def = componentStore.getDefinition(illegalChild)
throw `You can't place a ${def.name} here`
throw `You can't place a ${def?.name} here`
}
}
@ -180,10 +204,9 @@ export class ScreenStore extends BudiStore {
* Core save method. If creating a new screen, the store will sync the target
* screen id to ensure that it is selected in the builder
*
* @param {object} screen
* @returns {object}
* @param {Screen} screen The screen being modified/created
*/
async saveScreen(screen) {
async saveScreen(screen: Screen) {
const appState = get(appStore)
// Validate screen structure if the app supports it
@ -228,9 +251,9 @@ export class ScreenStore extends BudiStore {
/**
* After saving a screen, sync plugins and routes to the appStore
* @param {object} savedScreen
* @param {Screen} savedScreen
*/
async syncScreenData(savedScreen) {
async syncScreenData(savedScreen: Screen) {
const appState = get(appStore)
// If plugins changed we need to fetch the latest app metadata
let usedPlugins = appState.usedPlugins
@ -256,28 +279,35 @@ export class ScreenStore extends BudiStore {
* This is slightly better than just a traditional "patch" endpoint and this
* supports deeply mutating the current doc rather than just appending data.
*/
sequentialScreenPatch = Utils.sequential(async (patchFn, screenId) => {
const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId)
if (!screen) {
return
}
let clone = cloneDeep(screen)
const result = patchFn(clone)
sequentialScreenPatch = Utils.sequential(
async (
patchFn: (screen: Screen) => boolean,
screenId: string
): Promise<Screen | void> => {
const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId)
if (!screen) {
return
}
let clone = cloneDeep(screen)
const result = patchFn(clone)
// An explicit false result means skip this change
if (result === false) {
return
// An explicit false result means skip this change
if (result === false) {
return
}
return this.save(clone)
}
return this.save(clone)
})
)
/**
* @param {function} patchFn
* @param {Function} patchFn the patch action to be applied
* @param {string | null} screenId
* @returns
*/
async patch(patchFn, screenId) {
async patch(
patchFn: (screen: Screen) => any,
screenId?: string | null
): Promise<SaveScreenResponse | void> {
// Default to the currently selected screen
if (!screenId) {
const state = get(this.store)
@ -294,11 +324,11 @@ export class ScreenStore extends BudiStore {
* the screen supplied. If no screen is provided, the target has
* been removed by another user and will be filtered from the store.
* Used to marshal updates for the websocket
* @param {string} screenId
* @param {object} screen
* @returns
*
* @param {string} screenId the target screen id
* @param {Screen} screen the replacement screen
*/
async replace(screenId, screen) {
async replace(screenId: string, screen: Screen) {
if (!screenId) {
return
}
@ -334,20 +364,27 @@ export class ScreenStore extends BudiStore {
* Any deleted screens will then have their routes/links purged
*
* Wrapped by {@link delete}
* @param {object | array} screens
* @returns
* @param {Screen } screens
*/
async deleteScreen(screens) {
const screensToDelete = Array.isArray(screens) ? screens : [screens]
async deleteScreen(screen: Screen) {
const screensToDelete = [screen]
// Build array of promises to speed up bulk deletions
let promises = []
let deleteUrls = []
screensToDelete.forEach(screen => {
// Delete the screen
promises.push(API.deleteScreen(screen._id, screen._rev))
// Remove links to this screen
deleteUrls.push(screen.routing.route)
})
let promises: Promise<DeleteScreenResponse>[] = []
let deleteUrls: string[] = []
// In this instance _id will have been set
// Underline the expectation that _id and _rev will be set after filtering
screensToDelete
.filter(
(screen): screen is Screen & { _id: string; _rev: string } =>
!!screen._id || !!screen._rev
)
.forEach(screen => {
// Delete the screen
promises.push(API.deleteScreen(screen._id, screen._rev))
// Remove links to this screen
deleteUrls.push(screen.routing.route)
})
await Promise.all(promises)
await navigationStore.deleteLink(deleteUrls)
const deletedIds = screensToDelete.map(screen => screen._id)
@ -359,12 +396,15 @@ export class ScreenStore extends BudiStore {
})
// Deselect the current screen if it was deleted
if (deletedIds.includes(state.selectedScreenId)) {
state.selectedScreenId = null
componentStore.update(state => ({
...state,
selectedComponentId: null,
}))
if (
state.selectedScreenId &&
deletedIds.includes(state.selectedScreenId)
) {
delete state.selectedScreenId
componentStore.update(state => {
delete state.selectedComponentId
return state
})
}
// Update routing
@ -375,7 +415,6 @@ export class ScreenStore extends BudiStore {
return state
})
return null
}
/**
@ -384,18 +423,17 @@ export class ScreenStore extends BudiStore {
* After a successful update, this method ensures that there is only
* ONE home screen per user Role.
*
* @param {object} screen
* @param {Screen} screen
* @param {string} name e.g "routing.homeScreen" or "showNavigation"
* @param {any} value
* @returns
*/
async updateSetting(screen, name, value) {
async updateSetting(screen: Screen, name: string, value: any) {
if (!screen || !name) {
return
}
// Apply setting update
const patchFn = screen => {
const patchFn = (screen: Screen) => {
if (!screen) {
return false
}
@ -422,7 +460,7 @@ export class ScreenStore extends BudiStore {
)
})
if (otherHomeScreens.length && updatedScreen.routing.homeScreen) {
const patchFn = screen => {
const patchFn = (screen: Screen) => {
screen.routing.homeScreen = false
}
for (let otherHomeScreen of otherHomeScreens) {
@ -432,11 +470,11 @@ export class ScreenStore extends BudiStore {
}
// Move to layouts store
async removeCustomLayout(screen) {
async removeCustomLayout(screen: Screen) {
// Pull relevant settings from old layout, if required
const layout = get(layoutStore).layouts.find(x => x._id === screen.layoutId)
const patchFn = screen => {
screen.layoutId = null
const patchFn = (screen: Screen) => {
delete screen.layoutId
screen.showNavigation = layout?.props.navigation !== "None"
screen.width = layout?.props.width || "Large"
}
@ -446,11 +484,14 @@ export class ScreenStore extends BudiStore {
/**
* Parse the entire screen component tree and ensure settings are valid
* and up-to-date. Ensures stability after a product update.
* @param {object} screen
* @param {Screen} screen
*/
async enrichEmptySettings(screen) {
async enrichEmptySettings(screen: Screen) {
// Flatten the recursive component tree
const components = findAllMatchingComponents(screen.props, x => x)
const components = findAllMatchingComponents(
screen.props,
(x: Component) => x
)
// Iterate over all components and run checks
components.forEach(component => {
@ -459,6 +500,13 @@ export class ScreenStore extends BudiStore {
})
})
}
/**
* Provides a list of screens that are used by a given source ID (table, view, datasource, query)
*/
async usageInScreens(sourceId: string) {
return API.usageInScreens(sourceId)
}
}
export const screenStore = new ScreenStore()

View File

@ -3,7 +3,7 @@ import { get, writable } from "svelte/store"
import { API } from "@/api"
import { Constants } from "@budibase/frontend-core"
import { componentStore, appStore } from "@/stores/builder"
import { INITIAL_SCREENS_STATE, ScreenStore } from "@/stores/builder/screens"
import { initialScreenState, ScreenStore } from "@/stores/builder/screens"
import {
getScreenFixture,
getComponentFixture,
@ -73,7 +73,7 @@ describe("Screens store", () => {
vi.clearAllMocks()
const screenStore = new ScreenStore()
ctx.test = {
ctx.bb = {
get store() {
return get(screenStore)
},
@ -81,74 +81,76 @@ describe("Screens store", () => {
}
})
it("Create base screen store with defaults", ctx => {
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
it("Create base screen store with defaults", ({ bb }) => {
expect(bb.store).toStrictEqual(initialScreenState)
})
it("Syncs all screens from the app package", ctx => {
expect(ctx.test.store.screens.length).toBe(0)
it("Syncs all screens from the app package", ({ bb }) => {
expect(bb.store.screens.length).toBe(0)
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
bb.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens).toStrictEqual(screens)
expect(bb.store.screens).toStrictEqual(screens)
})
it("Reset the screen store back to the default state", ctx => {
expect(ctx.test.store.screens.length).toBe(0)
it("Reset the screen store back to the default state", ({ bb }) => {
expect(bb.store.screens.length).toBe(0)
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens).toStrictEqual(screens)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens).toStrictEqual(screens)
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
selectedScreenId: screens[0]._id,
}))
ctx.test.screenStore.reset()
bb.screenStore.reset()
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
expect(bb.store).toStrictEqual(initialScreenState)
})
it("Marks a valid screen as selected", ctx => {
it("Marks a valid screen as selected", ({ bb }) => {
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens.length).toBe(2)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens.length).toBe(2)
ctx.test.screenStore.select(screens[0]._id)
bb.screenStore.select(screens[0]._id)
expect(ctx.test.store.selectedScreenId).toEqual(screens[0]._id)
expect(bb.store.selectedScreenId).toEqual(screens[0]._id)
})
it("Skip selecting a screen if it is not present", ctx => {
it("Skip selecting a screen if it is not present", ({ bb }) => {
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens.length).toBe(2)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens.length).toBe(2)
ctx.test.screenStore.select("screen_abc")
bb.screenStore.select("screen_abc")
expect(ctx.test.store.selectedScreenId).toBeNull()
expect(bb.store.selectedScreenId).toBeUndefined()
})
it("Approve a valid empty screen config", ctx => {
it("Approve a valid empty screen config", ({ bb }) => {
const coreScreen = getScreenFixture()
ctx.test.screenStore.validate(coreScreen.json())
bb.screenStore.validate(coreScreen.json())
})
it("Approve a valid screen config with one component and no illegal children", ctx => {
it("Approve a valid screen config with one component and no illegal children", ({
bb,
}) => {
const coreScreen = getScreenFixture()
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
@ -157,12 +159,12 @@ describe("Screens store", () => {
const defSpy = vi.spyOn(componentStore, "getDefinition")
defSpy.mockReturnValueOnce(COMPONENT_DEFINITIONS.formblock)
ctx.test.screenStore.validate(coreScreen.json())
bb.screenStore.validate(coreScreen.json())
expect(defSpy).toHaveBeenCalled()
})
it("Reject an attempt to nest invalid components", ctx => {
it("Reject an attempt to nest invalid components", ({ bb }) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -178,14 +180,14 @@ describe("Screens store", () => {
return defMap[comp]
})
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
)
expect(defSpy).toHaveBeenCalled()
})
it("Reject an attempt to deeply nest invalid components", ctx => {
it("Reject an attempt to deeply nest invalid components", ({ bb }) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -210,14 +212,16 @@ describe("Screens store", () => {
return defMap[comp]
})
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
)
expect(defSpy).toHaveBeenCalled()
})
it("Save a brand new screen and add it to the store. No validation", async ctx => {
it("Save a brand new screen and add it to the store. No validation", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -225,7 +229,7 @@ describe("Screens store", () => {
appStore.set({ features: { componentValidation: false } })
expect(ctx.test.store.screens.length).toBe(0)
expect(bb.store.screens.length).toBe(0)
const newDocId = getScreenDocId()
const newDoc = { ...coreScreen.json(), _id: newDocId }
@ -235,15 +239,15 @@ describe("Screens store", () => {
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
routes: [],
})
await ctx.test.screenStore.save(coreScreen.json())
await bb.screenStore.save(coreScreen.json())
expect(saveSpy).toHaveBeenCalled()
expect(ctx.test.store.screens.length).toBe(1)
expect(bb.store.screens.length).toBe(1)
expect(ctx.test.store.screens[0]).toStrictEqual(newDoc)
expect(bb.store.screens[0]).toStrictEqual(newDoc)
expect(ctx.test.store.selectedScreenId).toBe(newDocId)
expect(bb.store.selectedScreenId).toBe(newDocId)
// The new screen should be selected
expect(get(componentStore).selectedComponentId).toBe(
@ -251,7 +255,7 @@ describe("Screens store", () => {
)
})
it("Sync an updated screen to the screen store on save", async ctx => {
it("Sync an updated screen to the screen store on save", async ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -261,7 +265,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -279,16 +283,18 @@ describe("Screens store", () => {
})
// Saved the existing screen having modified it.
await ctx.test.screenStore.save(existingScreens[2].json())
await bb.screenStore.save(existingScreens[2].json())
expect(routeSpy).toHaveBeenCalled()
expect(saveSpy).toHaveBeenCalled()
// On save, the screen is spliced back into the store with the saved content
expect(ctx.test.store.screens[2]).toStrictEqual(existingScreens[2].json())
expect(bb.store.screens[2]).toStrictEqual(existingScreens[2].json())
})
it("Sync API data to relevant stores on save. Updated plugins", async ctx => {
it("Sync API data to relevant stores on save. Updated plugins", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const newDocId = getScreenDocId()
@ -318,7 +324,7 @@ describe("Screens store", () => {
routes: [],
})
await ctx.test.screenStore.syncScreenData(newDoc)
await bb.screenStore.syncScreenData(newDoc)
expect(routeSpy).toHaveBeenCalled()
expect(appPackageSpy).toHaveBeenCalled()
@ -326,7 +332,9 @@ describe("Screens store", () => {
expect(get(appStore).usedPlugins).toStrictEqual(plugins)
})
it("Sync API updates to relevant stores on save. Plugins unchanged", async ctx => {
it("Sync API updates to relevant stores on save. Plugins unchanged", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const newDocId = getScreenDocId()
@ -343,7 +351,7 @@ describe("Screens store", () => {
routes: [],
})
await ctx.test.screenStore.syncScreenData(newDoc)
await bb.screenStore.syncScreenData(newDoc)
expect(routeSpy).toHaveBeenCalled()
expect(appPackageSpy).not.toHaveBeenCalled()
@ -352,46 +360,48 @@ describe("Screens store", () => {
expect(get(appStore).usedPlugins).toStrictEqual([plugin])
})
it("Proceed to patch if appropriate config are supplied", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch").mockImplementation(
() => {
return false
}
)
it("Proceed to patch if appropriate config are supplied", async ({ bb }) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch").mockImplementation(() => {
return false
})
const noop = () => {}
await ctx.test.screenStore.patch(noop, "test")
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
await bb.screenStore.patch(noop, "test")
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
noop,
"test"
)
})
it("Return from the patch if all valid config are not present", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
await ctx.test.screenStore.patch()
expect(ctx.test.screenStore.sequentialScreenPatch).not.toBeCalled()
it("Return from the patch if all valid config are not present", async ({
bb,
}) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
await bb.screenStore.patch()
expect(bb.screenStore.sequentialScreenPatch).not.toBeCalled()
})
it("Acquire the currently selected screen on patch, if not specified", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
await ctx.test.screenStore.patch()
it("Acquire the currently selected screen on patch, if not specified", async ({
bb,
}) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
await bb.screenStore.patch()
const noop = () => {}
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
selectedScreenId: "screen_123",
}))
await ctx.test.screenStore.patch(noop)
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
await bb.screenStore.patch(noop)
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
noop,
"screen_123"
)
})
// Used by the websocket
it("Ignore a call to replace if no screenId is provided", ctx => {
it("Ignore a call to replace if no screenId is provided", ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -400,14 +410,16 @@ describe("Screens store", () => {
screenDoc._json._id = existingDocId
return screenDoc.json()
})
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
bb.screenStore.syncAppScreens({ screens: existingScreens })
ctx.test.screenStore.replace()
bb.screenStore.replace()
expect(ctx.test.store.screens).toStrictEqual(existingScreens)
expect(bb.store.screens).toStrictEqual(existingScreens)
})
it("Remove a screen from the store if a single screenId is supplied", ctx => {
it("Remove a screen from the store if a single screenId is supplied", ({
bb,
}) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -416,17 +428,17 @@ describe("Screens store", () => {
screenDoc._json._id = existingDocId
return screenDoc.json()
})
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
bb.screenStore.syncAppScreens({ screens: existingScreens })
ctx.test.screenStore.replace(existingScreens[1]._id)
bb.screenStore.replace(existingScreens[1]._id)
const filtered = existingScreens.filter(
screen => screen._id != existingScreens[1]._id
)
expect(ctx.test.store.screens).toStrictEqual(filtered)
expect(bb.store.screens).toStrictEqual(filtered)
})
it("Replace an existing screen with a new version of itself", ctx => {
it("Replace an existing screen with a new version of itself", ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -436,7 +448,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -444,15 +456,14 @@ describe("Screens store", () => {
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
existingScreens[2].addChild(formBlock)
ctx.test.screenStore.replace(
existingScreens[2]._id,
existingScreens[2].json()
)
bb.screenStore.replace(existingScreens[2]._id, existingScreens[2].json())
expect(ctx.test.store.screens.length).toBe(4)
expect(bb.store.screens.length).toBe(4)
})
it("Add a screen when attempting to replace one not present in the store", ctx => {
it("Add a screen when attempting to replace one not present in the store", ({
bb,
}) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -462,7 +473,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -470,13 +481,13 @@ describe("Screens store", () => {
const newScreenDoc = getScreenFixture()
newScreenDoc._json._id = getScreenDocId()
ctx.test.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
bb.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
expect(ctx.test.store.screens.length).toBe(5)
expect(ctx.test.store.screens[4]).toStrictEqual(newScreenDoc.json())
expect(bb.store.screens.length).toBe(5)
expect(bb.store.screens[4]).toStrictEqual(newScreenDoc.json())
})
it("Delete a single screen and remove it from the store", async ctx => {
it("Delete a single screen and remove it from the store", async ({ bb }) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -486,14 +497,14 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
const deleteSpy = vi.spyOn(API, "deleteScreen")
await ctx.test.screenStore.delete(existingScreens[2].json())
await bb.screenStore.delete(existingScreens[2].json())
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
routes: [],
@ -501,13 +512,15 @@ describe("Screens store", () => {
expect(deleteSpy).toBeCalled()
expect(ctx.test.store.screens.length).toBe(2)
expect(bb.store.screens.length).toBe(2)
// Just confirm that the routes at are being initialised
expect(get(appStore).routes).toEqual([])
})
it("Upon delete, reset selected screen and component ids if the screen was selected", async ctx => {
it("Upon delete, reset selected screen and component ids if the screen was selected", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -517,7 +530,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
selectedScreenId: existingScreens[2]._json._id,
@ -528,14 +541,16 @@ describe("Screens store", () => {
selectedComponentId: existingScreens[2]._json._id,
}))
await ctx.test.screenStore.delete(existingScreens[2].json())
await bb.screenStore.delete(existingScreens[2].json())
expect(ctx.test.store.screens.length).toBe(2)
expect(get(componentStore).selectedComponentId).toBeNull()
expect(ctx.test.store.selectedScreenId).toBeNull()
expect(bb.store.screens.length).toBe(2)
expect(get(componentStore).selectedComponentId).toBeUndefined()
expect(bb.store.selectedScreenId).toBeUndefined()
})
it("Delete multiple is not supported and should leave the store unchanged", async ctx => {
it("Delete multiple is not supported and should leave the store unchanged", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -547,7 +562,7 @@ describe("Screens store", () => {
const storeScreens = existingScreens.map(screen => screen.json())
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -556,42 +571,40 @@ describe("Screens store", () => {
const deleteSpy = vi.spyOn(API, "deleteScreen")
await ctx.test.screenStore.delete(targets)
await bb.screenStore.delete(targets)
expect(deleteSpy).not.toHaveBeenCalled()
expect(ctx.test.store.screens.length).toBe(3)
expect(ctx.test.store.screens).toStrictEqual(storeScreens)
expect(bb.store.screens.length).toBe(3)
expect(bb.store.screens).toStrictEqual(storeScreens)
})
it("Update a screen setting", async ctx => {
it("Update a screen setting", async ({ bb }) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [screenDoc.json()],
}))
const patchedDoc = screenDoc.json()
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async patchFn => {
patchFn(patchedDoc)
return
})
await ctx.test.screenStore.updateSetting(
patchedDoc,
"showNavigation",
false
)
await bb.screenStore.updateSetting(patchedDoc, "showNavigation", false)
expect(patchSpy).toBeCalled()
expect(patchedDoc.showNavigation).toBe(false)
})
it("Ensure only one homescreen per role after updating setting. All screens same role", async ctx => {
it("Ensure only one homescreen per role after updating setting. All screens same role", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -611,23 +624,21 @@ describe("Screens store", () => {
// Set the 2nd screen as the home screen
storeScreens[1].routing.homeScreen = true
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: storeScreens,
}))
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async (patchFn, screenId) => {
const target = ctx.test.store.screens.find(
screen => screen._id === screenId
)
const target = bb.store.screens.find(screen => screen._id === screenId)
patchFn(target)
await ctx.test.screenStore.replace(screenId, target)
await bb.screenStore.replace(screenId, target)
})
await ctx.test.screenStore.updateSetting(
await bb.screenStore.updateSetting(
storeScreens[0],
"routing.homeScreen",
true
@ -637,13 +648,15 @@ describe("Screens store", () => {
expect(patchSpy).toBeCalledTimes(2)
// The new homescreen for BASIC
expect(ctx.test.store.screens[0].routing.homeScreen).toBe(true)
expect(bb.store.screens[0].routing.homeScreen).toBe(true)
// The previous home screen for the BASIC role is now unset
expect(ctx.test.store.screens[1].routing.homeScreen).toBe(false)
expect(bb.store.screens[1].routing.homeScreen).toBe(false)
})
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ctx => {
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ({
bb,
}) => {
const expectedRoles = [
Constants.Roles.BASIC,
Constants.Roles.POWER,
@ -675,30 +688,24 @@ describe("Screens store", () => {
sorted[9].routing.homeScreen = true
// Set screens state
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: sorted,
}))
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async (patchFn, screenId) => {
const target = ctx.test.store.screens.find(
screen => screen._id === screenId
)
const target = bb.store.screens.find(screen => screen._id === screenId)
patchFn(target)
await ctx.test.screenStore.replace(screenId, target)
await bb.screenStore.replace(screenId, target)
})
// ADMIN homeScreen updated from 0 to 2
await ctx.test.screenStore.updateSetting(
sorted[2],
"routing.homeScreen",
true
)
await bb.screenStore.updateSetting(sorted[2], "routing.homeScreen", true)
const results = ctx.test.store.screens.reduce((acc, screen) => {
const results = bb.store.screens.reduce((acc, screen) => {
if (screen.routing.homeScreen) {
acc[screen.routing.roleId] = acc[screen.routing.roleId] || []
acc[screen.routing.roleId].push(screen)
@ -706,7 +713,7 @@ describe("Screens store", () => {
return acc
}, {})
const screens = ctx.test.store.screens
const screens = bb.store.screens
// Should still only be one of each homescreen
expect(results[Constants.Roles.ADMIN].length).toBe(1)
expect(screens[2].routing.homeScreen).toBe(true)
@ -724,74 +731,80 @@ describe("Screens store", () => {
expect(patchSpy).toBeCalledTimes(2)
})
it("Sequential patch check. Exit if the screenId is not valid.", async ctx => {
it("Sequential patch check. Exit if the screenId is not valid.", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
// A screen with this Id does not exist
await ctx.test.screenStore.sequentialScreenPatch(() => {}, "123")
await bb.screenStore.sequentialScreenPatch(() => {}, "123")
expect(saveSpy).not.toBeCalled()
})
it("Sequential patch check. Exit if the patchFn result is false", async ctx => {
it("Sequential patch check. Exit if the patchFn result is false", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
// Set screens state
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
// Returning false from the patch will abort the save
await ctx.test.screenStore.sequentialScreenPatch(() => {
await bb.screenStore.sequentialScreenPatch(() => {
return false
}, "123")
expect(saveSpy).not.toBeCalled()
})
it("Sequential patch check. Patch applied and save requested", async ctx => {
it("Sequential patch check. Patch applied and save requested", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
await ctx.test.screenStore.sequentialScreenPatch(screen => {
await bb.screenStore.sequentialScreenPatch(screen => {
screen.name = "updated"
}, existingDocId)

View File

@ -16,7 +16,14 @@ import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
import { Automation, Datasource, Role, Table, UIUser } from "@budibase/types"
import {
Automation,
Datasource,
Role,
Table,
UIUser,
Screen,
} from "@budibase/types"
export const createBuilderWebsocket = (appId: string) => {
const socket = createWebsocket("/socket/builder")

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { createAdminStore } from "./admin"
import { AdminStore } from "./admin"
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
@ -46,16 +46,7 @@ describe("admin store", () => {
ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn() }
writable.mockReturnValue(ctx.writableReturn)
ctx.returnedStore = createAdminStore()
})
it("returns the created store", ctx => {
expect(ctx.returnedStore).toEqual({
subscribe: expect.toBe(ctx.writableReturn.subscribe),
init: expect.toBeFunc(),
unload: expect.toBeFunc(),
getChecklist: expect.toBeFunc(),
})
ctx.returnedStore = new AdminStore()
})
describe("init method", () => {

View File

@ -1,4 +1,4 @@
import { writable, get } from "svelte/store"
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import { banner } from "@budibase/bbui"
@ -7,42 +7,44 @@ import {
GetEnvironmentResponse,
SystemStatusResponse,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface PortalAdminStore extends GetEnvironmentResponse {
interface AdminState extends GetEnvironmentResponse {
loaded: boolean
checklist?: ConfigChecklistResponse
status?: SystemStatusResponse
}
export function createAdminStore() {
const admin = writable<PortalAdminStore>({
loaded: false,
multiTenancy: false,
cloud: false,
isDev: false,
disableAccountPortal: false,
offlineMode: false,
maintenance: [],
})
export class AdminStore extends BudiStore<AdminState> {
constructor() {
super({
loaded: false,
multiTenancy: false,
cloud: false,
isDev: false,
disableAccountPortal: false,
offlineMode: false,
maintenance: [],
})
}
async function init() {
await getChecklist()
await getEnvironment()
async init() {
await this.getChecklist()
await this.getEnvironment()
// enable system status checks in the cloud
if (get(admin).cloud) {
await getSystemStatus()
checkStatus()
if (get(this.store).cloud) {
await this.getSystemStatus()
this.checkStatus()
}
admin.update(store => {
this.update(store => {
store.loaded = true
return store
})
}
async function getEnvironment() {
async getEnvironment() {
const environment = await API.getEnvironment()
admin.update(store => {
this.update(store => {
store.multiTenancy = environment.multiTenancy
store.cloud = environment.cloud
store.disableAccountPortal = environment.disableAccountPortal
@ -56,43 +58,36 @@ export function createAdminStore() {
})
}
const checkStatus = async () => {
const health = get(admin)?.status?.health
async checkStatus() {
const health = get(this.store).status?.health
if (!health?.passing) {
await banner.showStatus()
}
}
async function getSystemStatus() {
async getSystemStatus() {
const status = await API.getSystemStatus()
admin.update(store => {
this.update(store => {
store.status = status
return store
})
}
async function getChecklist() {
async getChecklist() {
const tenantId = get(auth).tenantId
const checklist = await API.getChecklist(tenantId)
admin.update(store => {
this.update(store => {
store.checklist = checklist
return store
})
}
function unload() {
admin.update(store => {
unload() {
this.update(store => {
store.loaded = false
return store
})
}
return {
subscribe: admin.subscribe,
init,
unload,
getChecklist,
}
}
export const admin = createAdminStore()
export const admin = new AdminStore()

View File

@ -13,7 +13,7 @@ interface PortalAuditLogsStore {
logs?: SearchAuditLogsResponse
}
export class AuditLogsStore extends BudiStore<PortalAuditLogsStore> {
class AuditLogsStore extends BudiStore<PortalAuditLogsStore> {
constructor() {
super({})
}

View File

@ -1,38 +1,31 @@
import { writable } from "svelte/store"
import { BudiStore } from "../BudiStore"
type GotoFuncType = (path: string) => void
interface PortalNavigationStore {
interface NavigationState {
initialisated: boolean
goto: GotoFuncType
}
export function createNavigationStore() {
const store = writable<PortalNavigationStore>({
initialisated: false,
goto: undefined as any,
})
const { set, subscribe } = store
class NavigationStore extends BudiStore<NavigationState> {
constructor() {
super({
initialisated: false,
goto: undefined as any,
})
}
const init = (gotoFunc: GotoFuncType) => {
init(gotoFunc: GotoFuncType) {
if (typeof gotoFunc !== "function") {
throw new Error(
`gotoFunc must be a function, found a "${typeof gotoFunc}" instead`
)
}
set({
this.set({
initialisated: true,
goto: gotoFunc,
})
}
return {
subscribe,
actions: {
init,
},
}
}
export const navigation = createNavigationStore()
export const navigation = new NavigationStore()

View File

@ -1,16 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function templatesStore() {
const { subscribe, set } = writable([])
return {
subscribe,
load: async () => {
const templates = await API.getAppTemplates()
set(templates)
},
}
}
export const templates = templatesStore()

View File

@ -0,0 +1,16 @@
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { TemplateMetadata } from "@budibase/types"
class TemplateStore extends BudiStore<TemplateMetadata[]> {
constructor() {
super([])
}
async load() {
const templates = await API.getAppTemplates()
this.set(templates)
}
}
export const templates = new TemplateStore()

View File

@ -1,45 +0,0 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { get } from "svelte/store"
export const createTemporalStore = () => {
const initialValue = {}
const localStorageKey = `bb-temporal`
const store = createLocalStorageStore(localStorageKey, initialValue)
const setExpiring = (key, data, duration) => {
const updated = {
...data,
expiry: Date.now() + duration * 1000,
}
store.update(state => ({
...state,
[key]: updated,
}))
}
const getExpiring = key => {
const entry = get(store)[key]
if (!entry) {
return
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
store.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
return {
subscribe: store.subscribe,
actions: { setExpiring, getExpiring },
}
}
export const temporalStore = createTemporalStore()

View File

@ -0,0 +1,53 @@
import { get } from "svelte/store"
import { BudiStore, PersistenceType } from "../BudiStore"
type TemporalItem = Record<string, any> & { expiry: number }
type TemporalState = Record<string, TemporalItem>
class TemporalStore extends BudiStore<TemporalState> {
constructor() {
super(
{},
{
persistence: {
key: "bb-temporal",
type: PersistenceType.LOCAL,
},
}
)
}
setExpiring = (
key: string,
data: Record<string, any>,
durationSeconds: number
) => {
const updated: TemporalItem = {
...data,
expiry: Date.now() + durationSeconds * 1000,
}
this.update(state => ({
...state,
[key]: updated,
}))
}
getExpiring(key: string) {
const entry = get(this.store)[key]
if (!entry) {
return null
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
this.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
}
export const temporalStore = new TemporalStore()

View File

@ -1,37 +0,0 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { derived } from "svelte/store"
import {
DefaultBuilderTheme,
ensureValidTheme,
getThemeClassNames,
ThemeOptions,
ThemeClassPrefix,
} from "@budibase/shared-core"
export const getThemeStore = () => {
const themeElement = document.documentElement
const initialValue = {
theme: DefaultBuilderTheme,
}
const store = createLocalStorageStore("bb-theme", initialValue)
const derivedStore = derived(store, $store => ({
...$store,
theme: ensureValidTheme($store.theme, DefaultBuilderTheme),
}))
// Update theme class when store changes
derivedStore.subscribe(({ theme }) => {
const classNames = getThemeClassNames(theme).split(" ")
ThemeOptions.forEach(option => {
const className = `${ThemeClassPrefix}${option.id}`
themeElement.classList.toggle(className, classNames.includes(className))
})
})
return {
...store,
subscribe: derivedStore.subscribe,
}
}
export const themeStore = getThemeStore()

View File

@ -0,0 +1,45 @@
import { derived, Writable } from "svelte/store"
import {
DefaultBuilderTheme,
ensureValidTheme,
getThemeClassNames,
ThemeOptions,
ThemeClassPrefix,
} from "@budibase/shared-core"
import { Theme } from "@budibase/types"
import { DerivedBudiStore, PersistenceType } from "../BudiStore"
interface ThemeState {
theme: Theme
}
class ThemeStore extends DerivedBudiStore<ThemeState, ThemeState> {
constructor() {
const makeDerivedStore = (store: Writable<ThemeState>) => {
return derived(store, $store => ({
...$store,
theme: ensureValidTheme($store.theme, DefaultBuilderTheme),
}))
}
super({ theme: DefaultBuilderTheme }, makeDerivedStore, {
persistence: {
key: "bb-theme",
type: PersistenceType.LOCAL,
},
})
// Update theme class when store changes
this.subscribe(({ theme }) => {
const classNames = getThemeClassNames(theme).split(" ")
ThemeOptions.forEach(option => {
const className = `${ThemeClassPrefix}${option.id}`
document.documentElement.classList.toggle(
className,
classNames.includes(className)
)
})
})
}
}
export const themeStore = new ThemeStore()

View File

@ -11,11 +11,8 @@
<script>
import { getContext, setContext, onMount } from "svelte"
import { writable, get } from "svelte/store"
import {
enrichProps,
propsAreSame,
getSettingsDefinition,
} from "utils/componentProps"
import { enrichProps, propsAreSame } from "utils/componentProps"
import { getSettingsDefinition } from "@budibase/frontend-core"
import {
builderStore,
devToolsStore,
@ -29,7 +26,6 @@
import EmptyPlaceholder from "components/app/EmptyPlaceholder.svelte"
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
import { BudibasePrefix } from "../stores/components.js"
import {
decodeJSBinding,
findHBSBlocks,
@ -102,7 +98,6 @@
let definition
let settingsDefinition
let settingsDefinitionMap
let missingRequiredSettings = false
// Temporary styles which can be added in the app preview for things like
// DND. We clear these whenever a new instance is received.
@ -137,16 +132,14 @@
// Derive definition properties which can all be optional, so need to be
// coerced to booleans
$: componentErrors = instance?._meta?.errors
$: hasChildren = !!definition?.hasChildren
$: showEmptyState = definition?.showEmptyState !== false
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0
$: editable = !!definition?.editable && !hasMissingRequiredSettings
$: requiredAncestors = definition?.requiredAncestors || []
$: missingRequiredAncestors = requiredAncestors.filter(
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
$: hasMissingRequiredSettings = !!componentErrors?.find(
e => e.errorType === "setting"
)
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
$: errorState = hasMissingRequiredSettings || hasMissingRequiredAncestors
$: editable = !!definition?.editable && !hasMissingRequiredSettings
$: hasComponentErrors = componentErrors?.length > 0
// Interactive components can be selected, dragged and highlighted inside
// the builder preview
@ -212,7 +205,7 @@
styles: normalStyles,
draggable,
definition,
errored: errorState,
errored: hasComponentErrors,
}
// When dragging and dropping, pad components to allow dropping between
@ -245,9 +238,8 @@
name,
editing,
type: instance._component,
errorState,
errorState: hasComponentErrors,
parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id],
darkMode,
})
@ -304,40 +296,6 @@
staticSettings = instanceSettings.staticSettings
dynamicSettings = instanceSettings.dynamicSettings
// Check if we have any missing required settings
missingRequiredSettings = settingsDefinition.filter(setting => {
let empty = instance[setting.key] == null || instance[setting.key] === ""
let missing = setting.required && empty
// Check if this setting depends on another, as it may not be required
if (setting.dependsOn) {
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
const dependsOnValue = setting.dependsOn.value
const realDependentValue = instance[dependsOnKey]
const sectionDependsOnKey =
setting.sectionDependsOn?.setting || setting.sectionDependsOn
const sectionDependsOnValue = setting.sectionDependsOn?.value
const sectionRealDependentValue = instance[sectionDependsOnKey]
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
}
return missing
})
// When considering bindings we can ignore children, so we remove that
// before storing the reference stringified version
const noChildren = JSON.stringify({ ...instance, _children: null })
@ -680,7 +638,7 @@
class:pad
class:parent={hasChildren}
class:block={isBlock}
class:error={errorState}
class:error={hasComponentErrors}
class:root={isRoot}
data-id={id}
data-name={name}
@ -688,11 +646,8 @@
data-parent={$component.id}
use:gridLayout={gridMetadata}
>
{#if errorState}
<ComponentErrorState
{missingRequiredSettings}
{missingRequiredAncestors}
/>
{#if hasComponentErrors}
<ComponentErrorState {componentErrors} />
{:else}
<svelte:component this={constructor} bind:this={ref} {...initialSettings}>
{#if children.length}

View File

@ -5,6 +5,7 @@
import { get, derived, readable } from "svelte/store"
import { featuresStore } from "stores"
import { Grid } from "@budibase/frontend-core"
// import { processStringSync } from "@budibase/string-templates"
// table is actually any datasource, but called table for legacy compatibility
export let table
@ -42,6 +43,7 @@
let gridContext
let minHeight = 0
$: id = $component.id
$: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light")
$: parsedColumns = getParsedColumns(columns)
@ -65,7 +67,6 @@
const clean = gridContext?.rows.actions.cleanRow || (x => x)
const cleaned = rows.map(clean)
const goldenRow = generateGoldenSample(cleaned)
const id = get(component).id
return {
// Not sure what this one is for...
[id]: goldenRow,
@ -104,6 +105,7 @@
order: idx,
conditions: column.conditions,
visible: !!column.active,
// format: createFormatter(column),
}
if (column.width) {
overrides[column.field].width = column.width
@ -112,6 +114,13 @@
return overrides
}
// const createFormatter = column => {
// if (typeof column.format !== "string" || !column.format.trim().length) {
// return null
// }
// return row => processStringSync(column.format, { [id]: row })
// }
const enrichButtons = buttons => {
if (!buttons?.length) {
return null

View File

@ -1,8 +1,8 @@
<script>
import { Layout, Toggle } from "@budibase/bbui"
import { getSettingsDefinition } from "@budibase/frontend-core"
import DevToolsStat from "./DevToolsStat.svelte"
import { componentStore } from "stores/index.js"
import { getSettingsDefinition } from "utils/componentProps.js"
let showEnrichedSettings = true

View File

@ -1,30 +1,26 @@
<script lang="ts">
import { getContext } from "svelte"
import { Icon } from "@budibase/bbui"
import MissingRequiredSetting from "./MissingRequiredSetting.svelte"
import MissingRequiredAncestor from "./MissingRequiredAncestor.svelte"
import { UIComponentError } from "@budibase/types"
import ComponentErrorStateCta from "./ComponentErrorStateCTA.svelte"
export let missingRequiredSettings:
| { key: string; label: string }[]
| undefined
export let missingRequiredAncestors: string[] | undefined
export let componentErrors: UIComponentError[] | undefined
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
$: styles = { ...$component.styles, normal: {}, custom: null, empty: true }
$: requiredSetting = missingRequiredSettings?.[0]
$: requiredAncestor = missingRequiredAncestors?.[0]
$: errorMessage = componentErrors?.[0]
</script>
{#if $builderStore.inBuilder}
{#if $component.errorState}
<div class="component-placeholder" use:styleable={styles}>
<Icon name="Alert" color="var(--spectrum-global-color-static-red-600)" />
{#if requiredAncestor}
<MissingRequiredAncestor {requiredAncestor} />
{:else if requiredSetting}
<MissingRequiredSetting {requiredSetting} />
{#if errorMessage}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html errorMessage.message}
<ComponentErrorStateCta error={errorMessage} />
{/if}
</div>
{/if}
@ -34,7 +30,7 @@
.component-placeholder {
display: flex;
flex-direction: row;
justify-content: flex-start;
justify-content: center;
align-items: center;
color: var(--spectrum-global-color-gray-600);
font-size: var(--font-size-s);

Some files were not shown because too many files have changed in this diff Show More