Merge branch 'master' into execute-script-v2

This commit is contained in:
Sam Rose 2025-02-10 11:10:54 +00:00 committed by GitHub
commit 33a91cef67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 1226 additions and 639 deletions

View File

@ -54,17 +54,21 @@
</h3>
<br /><br />
## ✨ Features
### 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.4.3",
"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,25 +1,25 @@
<script>
<script lang="ts">
import "@spectrum-css/actionbutton/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
import { hexToRGBA } from "../helpers"
export let quiet = false
export let selected = false
export let disabled = false
export let icon = ""
export let size = "M"
export let active = false
export let fullWidth = false
export let noPadding = false
export let tooltip = ""
export let accentColor = null
export let quiet: boolean = false
export let selected: boolean = false
export let disabled: boolean = false
export let icon: string = ""
export let size: "S" | "M" | "L" = "M"
export let active: boolean = false
export let fullWidth: boolean = false
export let noPadding: boolean = false
export let tooltip: string = ""
export let accentColor: string | null = null
let showTooltip = false
$: accentStyle = getAccentStyle(accentColor)
const getAccentStyle = color => {
const getAccentStyle = (color: string | null) => {
if (!color) {
return ""
}

View File

@ -1,7 +1,7 @@
<script>
<script lang="ts">
import "@spectrum-css/divider/dist/index-vars.css"
export let size = "M"
export let size: "S" | "M" | "L" = "M"
export let vertical = false
export let noMargin = false

View File

@ -1,8 +1,9 @@
<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 value: string | null = null
export let placeholder: string | undefined = undefined
export let type = "text"
export let disabled = false
@ -11,7 +12,7 @@
export let updateOnChange = true
export let quiet = false
export let align: "left" | "right" | "center" | undefined = undefined
export let autofocus = false
export let autofocus: boolean | null = false
export let autocomplete: boolean | undefined
const dispatch = createEventDispatcher()
@ -24,7 +25,7 @@
return
}
if (type === "number") {
const float = parseFloat(newValue)
const float = parseFloat(newValue as string)
newValue = isNaN(float) ? null : float
}
dispatch("change", newValue)
@ -37,31 +38,31 @@
focus = true
}
const onBlur = (event: any) => {
const onBlur = (event: UIEvent) => {
if (readonly || disabled) {
return
}
focus = false
updateValue(event.target.value)
updateValue(event?.target?.value)
}
const onInput = (event: any) => {
const onInput = (event: UIEvent) => {
if (readonly || !updateOnChange || disabled) {
return
}
updateValue(event.target.value)
updateValue(event.target?.value)
}
const updateValueOnEnter = (event: any) => {
const updateValueOnEnter = (event: UIEvent) => {
if (readonly || disabled) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
updateValue(event.target?.value)
}
}
const getInputMode = (type: any) => {
const getInputMode = (type: string) => {
if (type === "bigint") {
return "numeric"
}
@ -77,7 +78,7 @@
onMount(async () => {
if (disabled) return
focus = autofocus
focus = autofocus || false
if (focus) {
await tick()
field.focus()

View File

@ -1,18 +1,18 @@
<script>
<script lang="ts">
import "@spectrum-css/link/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let href = "#"
export let size = "M"
export let quiet = false
export let primary = false
export let secondary = false
export let overBackground = false
export let target = undefined
export let download = undefined
export let disabled = false
export let tooltip = null
export let href: string | null = "#"
export let size: "S" | "M" | "L" = "M"
export let quiet: boolean = false
export let primary: boolean = false
export let secondary: boolean = false
export let overBackground: boolean = false
export let target: string | undefined = undefined
export let download: boolean | undefined = undefined
export let disabled: boolean = false
export let tooltip: string | null = null
const dispatch = createEventDispatcher()

View File

@ -1,15 +1,15 @@
<script>
<script lang="ts">
import Icon from "../Icon/Icon.svelte"
import StatusLight from "../StatusLight/StatusLight.svelte"
export let icon = null
export let iconColor = null
export let title = null
export let subtitle = null
export let url = null
export let hoverable = false
export let showArrow = false
export let selected = false
export let icon: string | undefined = undefined
export let iconColor: string | undefined = undefined
export let title: string | undefined = undefined
export let subtitle: string | undefined = undefined
export let url: string | undefined = undefined
export let hoverable: boolean = false
export let showArrow: boolean = false
export let selected: boolean = false
</script>
<a

View File

@ -1,20 +1,29 @@
<script>
import { ActionButton, List, ListItem, Button } from "@budibase/bbui"
import DetailPopover from "@/components/common/DetailPopover.svelte"
import { screenStore, appStore } from "@/stores/builder"
<script lang="ts">
import { Button } from "@budibase/bbui"
import ScreensPopover from "@/components/common/ScreensPopover.svelte"
import { screenStore } from "@/stores/builder"
import { getContext, createEventDispatcher } from "svelte"
const { datasource } = getContext("grid")
import type { Screen, ScreenUsage } from "@budibase/types"
const dispatch = createEventDispatcher()
let popover
const { datasource }: { datasource: any } = getContext("grid")
let popover: any
$: ds = $datasource
$: resourceId = ds?.type === "table" ? ds.tableId : ds?.id
$: connectedScreens = findConnectedScreens($screenStore.screens, resourceId)
$: screenCount = connectedScreens.length
$: screenUsage = connectedScreens.map(
(screen: Screen): ScreenUsage => ({
url: screen.routing?.route,
_id: screen._id!,
})
)
const findConnectedScreens = (screens, resourceId) => {
const findConnectedScreens = (
screens: Screen[],
resourceId: string
): Screen[] => {
return screens.filter(screen => {
return JSON.stringify(screen).includes(`"${resourceId}"`)
})
@ -26,34 +35,16 @@
}
</script>
<DetailPopover title="Screens" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="WebPage"
selected={open || screenCount}
quiet
accentColor="#364800"
>
Screens{screenCount ? `: ${screenCount}` : ""}
</ActionButton>
</svelte:fragment>
{#if !connectedScreens.length}
There aren't any screens connected to this data.
{:else}
The following screens are connected to this data.
<List>
{#each connectedScreens as screen}
<ListItem
title={screen.routing.route}
url={`/builder/app/${$appStore.appId}/design/${screen._id}`}
showArrow
/>
{/each}
</List>
{/if}
<div>
<ScreensPopover
bind:this={popover}
screens={screenUsage}
icon="WebPage"
accentColor="#364800"
showCount
>
<svelte:fragment slot="footer">
<Button secondary icon="WebPage" on:click={generateScreen}>
Generate app screen
</Button>
</div>
</DetailPopover>
</svelte:fragment>
</ScreensPopover>

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,3 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="8" height="8">
<circle cx="4" cy="4" r="4" stroke-width="0" fill="currentColor" />
<script lang="ts">
export let color = "currentColor"
export let size: "S" | "M" = "M"
const sizes = {
S: 6,
M: 8,
}
$: sizePx = sizes[size]
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${sizePx} ${sizePx}`}
width={`${sizePx}`}
height={`${sizePx}`}
>
<circle
cx={sizePx / 2}
cy={sizePx / 2}
r={sizePx / 2}
stroke-width="0"
fill={color}
/>
</svg>

Before

Width:  |  Height:  |  Size: 157 B

After

Width:  |  Height:  |  Size: 417 B

View File

@ -0,0 +1,58 @@
<script lang="ts">
import {
List,
ListItem,
ActionButton,
PopoverAlignment,
} from "@budibase/bbui"
import DetailPopover from "@/components/common/DetailPopover.svelte"
import { appStore } from "@/stores/builder"
import type { ScreenUsage } from "@budibase/types"
export let screens: ScreenUsage[] = []
export let icon = "DeviceDesktop"
export let accentColor: string | null | undefined = null
export let showCount = false
export let align = PopoverAlignment.Left
let popover: any
export function show() {
popover?.show()
}
export function hide() {
popover?.hide()
}
</script>
<DetailPopover title="Screens" bind:this={popover} {align}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
{icon}
quiet
selected={open || !!(showCount && screens.length)}
{accentColor}
on:click={show}
>
Screens{showCount && screens.length ? `: ${screens.length}` : ""}
</ActionButton>
</svelte:fragment>
{#if !screens.length}
There aren't any screens connected to this data.
{:else}
The following screens are connected to this data.
<List>
{#each screens as screen}
<ListItem
title={screen.url}
url={`/builder/app/${$appStore.appId}/design/${screen._id}`}
showArrow
/>
{/each}
</List>
{/if}
<slot name="footer" />
</DetailPopover>

View File

@ -25,16 +25,16 @@
export let wide
let highlightType
let domElement
$: highlightedProp = $builderStore.highlightedSetting
$: allBindings = getAllBindings(bindings, componentBindings, nested)
$: safeValue = getSafeValue(value, defaultValue, allBindings)
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
$: if (value) {
highlightType =
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
}
$: isHighlighted = highlightedProp?.key === key
$: highlightType = isHighlighted ? `highlighted-${highlightedProp?.type}` : ""
const getAllBindings = (bindings, componentBindings, nested) => {
if (!nested) {
@ -74,9 +74,19 @@
? defaultValue
: enriched
}
function scrollToElement(element) {
element?.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
$: highlightedProp && isHighlighted && scrollToElement(domElement)
</script>
<div
bind:this={domElement}
id={`${key}-prop-control-wrap`}
class={`property-control ${highlightType}`}
class:wide={!label || labelHidden || wide === true}

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { onMount } from "svelte"
import { screenStore } from "@/stores/builder"
import ScreensPopover from "@/components/common/ScreensPopover.svelte"
import type { ScreenUsage } from "@budibase/types"
export let sourceId: string
let screens: ScreenUsage[] = []
let popover: any
export function show() {
popover?.show()
}
export function hide() {
popover?.hide()
}
onMount(async () => {
let response = await screenStore.usageInScreens(sourceId)
screens = response?.screens
})
</script>
<ScreensPopover
bind:this={popover}
{screens}
icon="WebPage"
accentColor="#364800"
showCount
/>

View File

@ -23,6 +23,7 @@
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import QueryViewerSavePromptModal from "./QueryViewerSavePromptModal.svelte"
import { Utils } from "@budibase/frontend-core"
import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte"
export let query
let queryHash
@ -170,6 +171,7 @@
</Body>
</div>
<div class="controls">
<ConnectedQueryScreens sourceId={query._id} />
<Button disabled={loading} on:click={runQuery} overBackground>
<Icon size="S" name="Play" />
Run query</Button
@ -384,6 +386,8 @@
}
.controls {
display: flex;
align-items: center;
flex-shrink: 0;
}

View File

@ -49,6 +49,7 @@
runtimeToReadableMap,
toBindingsArray,
} from "@/dataBinding"
import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte"
export let queryId
@ -502,9 +503,12 @@
on:change={() => (query.flags.urlName = false)}
on:save={saveQuery}
/>
<div class="access">
<Label>Access</Label>
<AccessLevelSelect {query} {saveId} />
<div class="controls">
<ConnectedQueryScreens sourceId={query._id} />
<div class="access">
<Label>Access</Label>
<AccessLevelSelect {query} {saveId} />
</div>
</div>
</div>
<div class="url-block">
@ -825,6 +829,12 @@
justify-content: space-between;
}
.controls {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.access {
display: flex;
gap: var(--spacing-m);

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

@ -3,6 +3,8 @@
import AppPreview from "./AppPreview.svelte"
import { screenStore, appStore } from "@/stores/builder"
import UndoRedoControl from "@/components/common/UndoRedoControl.svelte"
import ScreenErrorsButton from "./ScreenErrorsButton.svelte"
import { Divider } from "@budibase/bbui"
</script>
<div class="app-panel">
@ -15,6 +17,8 @@
{#if $appStore.clientFeatures.devicePreview}
<DevicePreviewSelect />
{/if}
<Divider vertical />
<ScreenErrorsButton />
</div>
</div>
<div class="content">
@ -62,7 +66,7 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
gap: var(--spacing-l);
}
.content {
flex: 1 1 auto;

View File

@ -183,16 +183,6 @@
toggleAddComponent()
} else if (type === "highlight-setting") {
builderStore.highlightSetting(data.setting, "error")
// Also scroll setting into view
const selector = `#${data.setting}-prop-control`
const element = document.querySelector(selector)?.parentElement
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
} else if (type === "eject-block") {
const { id, definition } = data
await componentStore.handleEjectBlock(id, definition)

View File

@ -0,0 +1,124 @@
<script lang="ts">
import type { UIComponentError } from "@budibase/types"
import {
builderStore,
componentStore,
screenComponentErrorList,
screenComponentsList,
} from "@/stores/builder"
import {
AbsTooltip,
ActionButton,
Icon,
Link,
Popover,
PopoverAlignment,
TooltipPosition,
} from "@budibase/bbui"
import CircleIndicator from "@/components/common/Icons/CircleIndicator.svelte"
let button: any
let popover: any
$: hasErrors = !!$screenComponentErrorList.length
function getErrorTitle(error: UIComponentError) {
const titleParts = [
$screenComponentsList.find(c => c._id === error.componentId)!
._instanceName,
]
if (error.errorType === "setting" && error.cause === "invalid") {
titleParts.push(error.label)
}
return titleParts.join(" - ")
}
async function onErrorClick(error: UIComponentError) {
componentStore.select(error.componentId)
if (error.errorType === "setting") {
builderStore.highlightSetting(error.key, "error")
}
}
</script>
<div bind:this={button} class="error-button">
<AbsTooltip
text={!hasErrors ? "No errors found!" : ""}
position={TooltipPosition.Top}
>
<ActionButton
quiet
disabled={!hasErrors}
on:click={() => popover.show()}
size="M"
icon="Alert"
/>
{#if hasErrors}
<div class="error-indicator">
<CircleIndicator
size="S"
color="var(--spectrum-global-color-static-red-600)"
/>
</div>
{/if}
</AbsTooltip>
</div>
<Popover
bind:this={popover}
anchor={button}
align={PopoverAlignment.Right}
maxWidth={400}
showPopover={hasErrors}
>
<div class="error-popover">
{#each $screenComponentErrorList as error}
<div class="error">
<Icon
name="Alert"
color="var(--spectrum-global-color-static-red-600)"
size="S"
/>
<div>
<Link overBackground on:click={() => onErrorClick(error)}>
{getErrorTitle(error)}
</Link>:
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html error.message}
</div>
</div>
{/each}
</div>
</Popover>
<style>
.error-button {
position: relative;
}
.error-indicator {
position: absolute;
top: 0;
right: 8px;
}
.error-popover {
display: flex;
flex-direction: column;
}
.error-popover .error {
display: inline-flex;
flex-direction: row;
padding: var(--spacing-m);
gap: var(--spacing-s);
align-items: start;
}
.error-popover .error:not(:last-child) {
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
}
.error-popover .error :global(mark) {
background: unset;
color: unset;
}
.error-popover .error :global(.spectrum-Link) {
display: inline-block;
}
</style>

View File

@ -21,7 +21,7 @@ import {
tables,
componentTreeNodesStore,
builderStore,
screenComponents,
screenComponentsList,
} from "@/stores/builder"
import { buildFormSchema, getSchemaForDatasource } from "@/dataBinding"
import {
@ -450,7 +450,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
}
const componentName = getSequentialName(
get(screenComponents),
get(screenComponentsList),
`New ${definition.friendlyName || definition.name}`,
{
getName: c => c._instanceName,

View File

@ -17,9 +17,9 @@ import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets"
import {
screenComponents,
screenComponentsList,
screenComponentErrors,
findComponentsBySettingsType,
screenComponentErrorList,
} from "./screenComponent"
// Backend
@ -72,9 +72,9 @@ export {
snippets,
rowActions,
appPublished,
screenComponents,
screenComponentsList,
screenComponentErrors,
findComponentsBySettingsType,
screenComponentErrorList,
}
export const reset = () => {

View File

@ -4,11 +4,10 @@ import { selectedScreen } from "./screens"
import { viewsV2 } from "./viewsV2"
import {
UIDatasourceType,
Screen,
Component,
UIComponentError,
ScreenProps,
ComponentDefinition,
DependsOnComponentSetting,
} from "@budibase/types"
import { queries } from "./queries"
import { views } from "./views"
@ -21,14 +20,11 @@ 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,
}),
{}
)
): Record<string, TItem> {
return list.reduce<Record<string, TItem>>((result, item) => {
result[item[key] as string] = item
return result
}, {})
}
const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
@ -46,7 +42,18 @@ const validationKeyByType: Record<UIDatasourceType, string | null> = {
jsonarray: "value",
}
export const screenComponentErrors = derived(
export const screenComponentsList = derived(
[selectedScreen],
([$selectedScreen]): Component[] => {
if (!$selectedScreen) {
return []
}
return findAllComponents($selectedScreen.props)
}
)
export const screenComponentErrorList = derived(
[selectedScreen, tables, views, viewsV2, queries, componentStore],
([
$selectedScreen,
@ -55,9 +62,9 @@ export const screenComponentErrors = derived(
$viewsV2,
$queries,
$componentStore,
]): Record<string, UIComponentError[]> => {
]): UIComponentError[] => {
if (!$selectedScreen) {
return {}
return []
}
const datasources = {
@ -69,116 +76,152 @@ export const screenComponentErrors = derived(
const { components: definitions } = $componentStore
const errors = {
...getInvalidDatasources($selectedScreen, datasources, definitions),
...getMissingAncestors($selectedScreen, definitions),
...getMissingRequiredSettings($selectedScreen, definitions),
const errors: UIComponentError[] = []
function checkComponentErrors(component: Component, ancestors: string[]) {
errors.push(...getInvalidDatasources(component, datasources, definitions))
errors.push(...getMissingRequiredSettings(component, definitions))
errors.push(...getMissingAncestors(component, definitions, ancestors))
for (const child of component._children || []) {
checkComponentErrors(child, [...ancestors, component._component])
}
}
checkComponentErrors($selectedScreen?.props, [])
return errors
}
)
function getInvalidDatasources(
screen: Screen,
component: Component,
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 result: UIComponentError[] = []
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const datasourceTypes = ["table", "dataSource"]
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const possibleSettings = definitions[component._component]?.settings?.filter(
s => datasourceTypes.includes(s.type)
)
if (possibleSettings) {
for (const setting of possibleSettings) {
const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const componentBindings = getBindableProperties(screen, component._id)
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const componentDatasources = {
...reduceBy("rowId", bindings.extractRelationships(componentBindings)),
...reduceBy("value", bindings.extractFields(componentBindings)),
...reduceBy("value", bindings.extractJSONArrayFields(componentBindings)),
}
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const resourceId = componentSettings[validationKey]
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [
{
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.push({
componentId: component._id!,
key: setting.key,
label: setting.label || setting.key,
message: `The ${friendlyTypeName} named "${label}" could not be found`,
errorType: "setting",
},
]
cause: "invalid",
})
}
}
}
return result
}
function parseDependsOn(dependsOn: DependsOnComponentSetting | undefined): {
key?: string
value?: string
} {
if (dependsOn === undefined) {
return {}
}
if (typeof dependsOn === "string") {
return { key: dependsOn }
}
return { key: dependsOn.setting, value: dependsOn.value }
}
function getMissingRequiredSettings(
screen: Screen,
component: Component,
definitions: Record<string, ComponentDefinition>
) {
const allComponents = findAllComponents(screen.props) as Component[]
const result: UIComponentError[] = []
const result: Record<string, UIComponentError[]> = {}
for (const component of allComponents) {
const definition = definitions[component._component]
const definition = definitions[component._component]
const settings = getSettingsDefinition(definition)
const settings = getSettingsDefinition(definition)
const missingRequiredSettings = settings.filter((setting: any) => {
let empty =
component[setting.key] == null || component[setting.key] === ""
let missing = setting.required && empty
const missingRequiredSettings = settings.filter(setting => {
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]
// Check if this setting depends on another, as it may not be required
if (setting.dependsOn) {
const { key: dependsOnKey, value: dependsOnValue } = parseDependsOn(
setting.dependsOn
)
const realDependentValue =
component[dependsOnKey as keyof typeof component]
const sectionDependsOnKey =
setting.sectionDependsOn?.setting || setting.sectionDependsOn
const sectionDependsOnValue = setting.sectionDependsOn?.value
const sectionRealDependentValue = component[sectionDependsOnKey]
const { key: sectionDependsOnKey, value: sectionDependsOnValue } =
parseDependsOn(setting.sectionDependsOn)
const sectionRealDependentValue =
component[sectionDependsOnKey as keyof typeof component]
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
return missing
})
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
}
if (missingRequiredSettings?.length) {
result[component._id!] = missingRequiredSettings.map((s: any) => ({
return missing
})
if (missingRequiredSettings?.length) {
result.push(
...missingRequiredSettings.map<UIComponentError>(s => ({
componentId: component._id!,
key: s.key,
label: s.label || s.key,
message: `Add the <mark>${s.label}</mark> setting to start using your component`,
errorType: "setting",
cause: "missing",
}))
}
)
}
return result
@ -186,34 +229,31 @@ function getMissingRequiredSettings(
const BudibasePrefix = "@budibase/standard-components/"
function getMissingAncestors(
screen: Screen,
definitions: Record<string, ComponentDefinition>
) {
const result: Record<string, UIComponentError[]> = {}
component: Component,
definitions: Record<string, ComponentDefinition>,
ancestors: string[]
): UIComponentError[] {
const definition = definitions[component._component]
function checkMissingAncestors(component: Component, ancestors: string[]) {
for (const child of component._children || []) {
checkMissingAncestors(child, [...ancestors, component._component])
if (!definition?.requiredAncestors?.length) {
return []
}
const result: UIComponentError[] = []
const missingAncestors = definition.requiredAncestors.filter(
ancestor => !ancestors.includes(`${BudibasePrefix}${ancestor}`)
)
if (missingAncestors.length) {
const pluralise = (name: string) => {
return name.endsWith("s") ? `${name}'` : `${name}s`
}
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 => {
result.push(
...missingAncestors.map<UIComponentError>(ancestor => {
const ancestorDefinition = definitions[`${BudibasePrefix}${ancestor}`]
return {
componentId: component._id!,
message: `${pluralise(definition.name)} need to be inside a
<mark>${ancestorDefinition.name}</mark>`,
errorType: "ancestor-setting",
@ -223,59 +263,19 @@ function getMissingAncestors(
},
}
})
}
}
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[]
export const screenComponentErrors = derived(
[screenComponentErrorList],
([$list]): Record<string, UIComponentError[]> => {
return $list.reduce<Record<string, UIComponentError[]>>((obj, error) => {
obj[error.componentId] ??= []
obj[error.componentId].push(error)
return obj
}, {})
}
)

View File

@ -500,6 +500,13 @@ export class ScreenStore extends BudiStore<ScreenState> {
})
})
}
/**
* 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

@ -3089,12 +3089,6 @@
"type": "tableConditions",
"label": "Conditions",
"key": "conditions"
},
{
"type": "text",
"label": "Format",
"key": "format",
"info": "Changing format will display values as text"
}
]
},
@ -7691,8 +7685,7 @@
{
"type": "columns/grid",
"key": "columns",
"resetOn": "table",
"nested": true
"resetOn": "table"
}
]
},

View File

@ -5,7 +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"
// import { processStringSync } from "@budibase/string-templates"
// table is actually any datasource, but called table for legacy compatibility
export let table
@ -105,7 +105,7 @@
order: idx,
conditions: column.conditions,
visible: !!column.active,
format: createFormatter(column),
// format: createFormatter(column),
}
if (column.width) {
overrides[column.field].width = column.width
@ -114,12 +114,12 @@
return overrides
}
const createFormatter = column => {
if (typeof column.format !== "string" || !column.format.trim().length) {
return null
}
return row => processStringSync(column.format, { [id]: row })
}
// 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) {

View File

@ -2,12 +2,14 @@ import {
DeleteScreenResponse,
SaveScreenRequest,
SaveScreenResponse,
UsageInScreensResponse,
} from "@budibase/types"
import { BaseAPIClient } from "./types"
export interface ScreenEndpoints {
saveScreen: (screen: SaveScreenRequest) => Promise<SaveScreenResponse>
deleteScreen: (id: string, rev: string) => Promise<DeleteScreenResponse>
usageInScreens: (sourceId: string) => Promise<UsageInScreensResponse>
}
export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({
@ -32,4 +34,10 @@ export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({
url: `/api/screens/${id}/${rev}`,
})
},
usageInScreens: async sourceId => {
return await API.post({
url: `/api/screens/usage/${sourceId}`,
})
},
})

View File

@ -1,34 +1,30 @@
import { getScreenParams, generateScreenID, DocumentType } from "../../db/utils"
import { DocumentType, generateScreenID } from "../../db/utils"
import {
events,
context,
tenancy,
db as dbCore,
events,
roles,
tenancy,
} from "@budibase/backend-core"
import { updateAppPackage } from "./application"
import {
Plugin,
ScreenProps,
Screen,
UserCtx,
DeleteScreenResponse,
FetchScreenResponse,
Plugin,
SaveScreenRequest,
SaveScreenResponse,
DeleteScreenResponse,
Screen,
ScreenProps,
ScreenUsage,
UsageInScreensResponse,
UserCtx,
} from "@budibase/types"
import { builderSocket } from "../../websockets"
import sdk from "../../sdk"
import { sdk as sharedSdk } from "@budibase/shared-core"
export async function fetch(ctx: UserCtx<void, FetchScreenResponse>) {
const db = context.getAppDB()
const screens = (
await db.allDocs(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map((el: any) => el.doc)
const screens = await sdk.screens.fetch()
const roleId = ctx.user?.role?._id as string
if (!roleId) {
@ -140,3 +136,23 @@ function findPlugins(component: ScreenProps, foundPlugins: string[]) {
}
component._children.forEach(child => findPlugins(child, foundPlugins))
}
export async function usage(ctx: UserCtx<void, UsageInScreensResponse>) {
const sourceId = ctx.params.sourceId
const sourceType = sdk.common.getSourceType(sourceId)
const allScreens = await sdk.screens.fetch()
const response: ScreenUsage[] = []
for (let screen of allScreens) {
const found = sharedSdk.screens.findInSettings(screen, sourceId)
if (found.length !== 0) {
response.push({
url: screen.routing.route,
_id: screen._id!,
})
}
}
ctx.body = {
sourceType,
screens: response,
}
}

View File

@ -19,5 +19,10 @@ router
authorized(permissions.BUILDER),
controller.destroy
)
.post(
"/api/screens/usage/:sourceId",
authorized(permissions.BUILDER),
controller.usage
)
export default router

View File

@ -1,9 +1,24 @@
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities"
import { events, roles } from "@budibase/backend-core"
import { Screen, Role, BuiltinPermissionID } from "@budibase/types"
import {
Screen,
Role,
BuiltinPermissionID,
SourceType,
UsageInScreensResponse,
} from "@budibase/types"
const { basicScreen } = setup.structures
const {
basicScreen,
createTableScreen,
createViewScreen,
createQueryScreen,
basicTable,
viewV2,
basicQuery,
basicDatasource,
} = setup.structures
describe("/screens", () => {
let config = setup.getConfig()
@ -18,7 +33,7 @@ describe("/screens", () => {
describe("fetch", () => {
it("should be able to create a layout", async () => {
const screens = await config.api.screen.list({ status: 200 })
const screens = await config.api.screen.list()
expect(screens.length).toEqual(1)
expect(screens.some(s => s._id === screen._id)).toEqual(true)
})
@ -52,28 +67,22 @@ describe("/screens", () => {
inherits: [role1._id!, role2._id!],
permissionId: BuiltinPermissionID.WRITE,
})
screen1 = await config.api.screen.save(
{
...basicScreen(),
routing: {
roleId: role1._id!,
route: "/foo",
homeScreen: false,
},
screen1 = await config.api.screen.save({
...basicScreen(),
routing: {
roleId: role1._id!,
route: "/foo",
homeScreen: false,
},
{ status: 200 }
)
screen2 = await config.api.screen.save(
{
...basicScreen(),
routing: {
roleId: role2._id!,
route: "/bar",
homeScreen: false,
},
})
screen2 = await config.api.screen.save({
...basicScreen(),
routing: {
roleId: role2._id!,
route: "/bar",
homeScreen: false,
},
{ status: 200 }
)
})
// get into prod app
await config.publish()
})
@ -81,10 +90,7 @@ describe("/screens", () => {
async function checkScreens(roleId: string, screenIds: string[]) {
await config.loginAsRole(roleId, async () => {
const res = await config.api.application.getDefinition(
config.prodAppId!,
{
status: 200,
}
config.getProdAppId()
)
expect(res.screens.length).toEqual(screenIds.length)
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
@ -114,10 +120,7 @@ describe("/screens", () => {
},
async () => {
const res = await config.api.application.getDefinition(
config.prodAppId!,
{
status: 200,
}
config.prodAppId!
)
const screenIds = [screen._id!, screen1._id!]
expect(res.screens.length).toEqual(screenIds.length)
@ -134,9 +137,7 @@ describe("/screens", () => {
it("should be able to create a screen", async () => {
const screen = basicScreen()
const responseScreen = await config.api.screen.save(screen, {
status: 200,
})
const responseScreen = await config.api.screen.save(screen)
expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name)
@ -145,13 +146,13 @@ describe("/screens", () => {
it("should be able to update a screen", async () => {
const screen = basicScreen()
let responseScreen = await config.api.screen.save(screen, { status: 200 })
let responseScreen = await config.api.screen.save(screen)
screen._id = responseScreen._id
screen._rev = responseScreen._rev
screen.name = "edit"
jest.clearAllMocks()
responseScreen = await config.api.screen.save(screen, { status: 200 })
responseScreen = await config.api.screen.save(screen)
expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name)
@ -171,8 +172,7 @@ describe("/screens", () => {
it("should be able to delete the screen", async () => {
const response = await config.api.screen.destroy(
screen._id!,
screen._rev!,
{ status: 200 }
screen._rev!
)
expect(response.message).toBeDefined()
expect(events.screen.deleted).toHaveBeenCalledTimes(1)
@ -186,4 +186,57 @@ describe("/screens", () => {
})
})
})
describe("usage", () => {
beforeEach(async () => {
await config.init()
await config.api.screen.save(basicScreen())
})
function confirmScreen(usage: UsageInScreensResponse, screen: Screen) {
expect(usage.screens.length).toEqual(1)
expect(usage.screens[0].url).toEqual(screen.routing.route)
expect(usage.screens[0]._id).toEqual(screen._id!)
}
it("should find table usage", async () => {
const table = await config.api.table.save(basicTable())
const screen = await config.api.screen.save(
createTableScreen("BudibaseDB", table)
)
const usage = await config.api.screen.usage(table._id!)
expect(usage.sourceType).toEqual(SourceType.TABLE)
confirmScreen(usage, screen)
})
it("should find view usage", async () => {
const table = await config.api.table.save(basicTable())
const view = await config.api.viewV2.create(
viewV2.createRequest(table._id!),
{ status: 201 }
)
const screen = await config.api.screen.save(
createViewScreen("BudibaseDB", view)
)
const usage = await config.api.screen.usage(view.id)
expect(usage.sourceType).toEqual(SourceType.VIEW)
confirmScreen(usage, screen)
})
it("should find datasource/query usage", async () => {
const datasource = await config.api.datasource.create(
basicDatasource().datasource
)
const query = await config.api.query.save(basicQuery(datasource._id!))
const screen = await config.api.screen.save(
createQueryScreen(datasource._id!, query)
)
const dsUsage = await config.api.screen.usage(datasource._id!)
expect(dsUsage.sourceType).toEqual(SourceType.DATASOURCE)
confirmScreen(dsUsage, screen)
const queryUsage = await config.api.screen.usage(query._id!)
expect(queryUsage.sourceType).toEqual(SourceType.QUERY)
confirmScreen(queryUsage, screen)
})
})
})

View File

@ -1,6 +1,6 @@
import { roles } from "@budibase/backend-core"
import { BASE_LAYOUT_PROP_IDS } from "./layouts"
import { Screen } from "@budibase/types"
import { Screen, Table, Query, ViewV2, Component } from "@budibase/types"
export function createHomeScreen(
config: {
@ -53,3 +53,177 @@ export function createHomeScreen(
name: "home-screen",
}
}
function heading(text: string): Component {
return {
_id: "c1bff24cd821e41d18c894ac77a80ef99",
_component: "@budibase/standard-components/heading",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_instanceName: "Table heading",
_children: [],
text,
}
}
export function createTableScreen(
datasourceName: string,
table: Table
): Screen {
return {
props: {
_id: "cad0a0904cacd4678a2ac094e293db1a5",
_component: "@budibase/standard-components/container",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [
heading("table"),
{
_id: "ca6304be2079147bb9933092c4f8ce6fa",
_component: "@budibase/standard-components/gridblock",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_instanceName: "table - Table",
_children: [],
table: {
label: table.name,
tableId: table._id!,
type: "table",
datasourceName,
},
},
],
_instanceName: "table - List",
layout: "grid",
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "grow",
gap: "M",
},
routing: {
route: "/table",
roleId: "ADMIN",
homeScreen: false,
},
name: "screen-id",
}
}
export function createViewScreen(datasourceName: string, view: ViewV2): Screen {
return {
props: {
_id: "cc359092bbd6c4e10b57827155edb7872",
_component: "@budibase/standard-components/container",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [
heading("view"),
{
_id: "ccb4a9e3734794864b5c65b012a0bdc5a",
_component: "@budibase/standard-components/gridblock",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_instanceName: "view - Table",
_children: [],
table: {
...view,
name: view.name,
tableId: view.tableId,
id: view.id,
label: view.name,
type: "viewV2",
},
},
],
_instanceName: "view - List",
layout: "grid",
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "grow",
gap: "M",
},
routing: {
route: "/view",
roleId: "ADMIN",
homeScreen: false,
},
name: "view-id",
}
}
export function createQueryScreen(datasourceId: string, query: Query): Screen {
return {
props: {
_id: "cc59b217aed264939a6c5249eee39cb25",
_component: "@budibase/standard-components/container",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [
{
_id: "c33a4a6e3cb5343158a08625c06b5cd7c",
_component: "@budibase/standard-components/gridblock",
_styles: {
normal: {},
hover: {},
active: {},
},
_instanceName: "New Table",
table: {
...query,
label: query.name,
_id: query._id!,
name: query.name,
datasourceId: datasourceId,
type: "query",
},
initialSortOrder: "Ascending",
allowAddRows: true,
allowEditRows: true,
allowDeleteRows: true,
stripeRows: false,
quiet: false,
columns: null,
},
],
_instanceName: "Blank screen",
layout: "grid",
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "grow",
gap: "M",
},
routing: {
route: "/query",
roleId: "BASIC",
homeScreen: false,
},
name: "screen-id",
}
}

View File

@ -0,0 +1 @@
export * from "./utils"

View File

@ -0,0 +1,15 @@
import { SourceType } from "@budibase/types"
import { docIds } from "@budibase/backend-core"
export function getSourceType(sourceId: string): SourceType {
if (docIds.isTableId(sourceId)) {
return SourceType.TABLE
} else if (docIds.isViewId(sourceId)) {
return SourceType.VIEW
} else if (docIds.isDatasourceId(sourceId)) {
return SourceType.DATASOURCE
} else if (docIds.isQueryId(sourceId)) {
return SourceType.QUERY
}
throw new Error(`Unknown source type for source "${sourceId}"`)
}

View File

@ -0,0 +1 @@
export * from "./screens"

View File

@ -0,0 +1,15 @@
import { getScreenParams } from "../../../db/utils"
import { context } from "@budibase/backend-core"
import { Screen } from "@budibase/types"
export async function fetch(): Promise<Screen[]> {
const db = context.getAppDB()
return (
await db.allDocs<Screen>(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map(el => el.doc!)
}

View File

@ -11,6 +11,10 @@ export function isExternal(opts: { table?: Table; tableId?: string }): boolean {
return false
}
export function isInternal(opts: { table?: Table; tableId?: string }): boolean {
return !isExternal(opts)
}
export function isTable(table: any): table is Table {
return table._id && docIds.isTableId(table._id)
}

View File

@ -81,6 +81,14 @@ export function isView(view: any): view is ViewV2 {
return view.id && docIds.isViewId(view.id) && view.version === 2
}
export function isInternal(viewId: string) {
if (!docIds.isViewId(viewId)) {
return false
}
const { tableId } = utils.extractViewInfoFromID(viewId)
return !isExternalTableID(tableId)
}
function guardDuplicateCalculationFields(view: Omit<ViewV2, "id" | "version">) {
const seen: Record<string, Record<CalculationType, boolean>> = {}
const calculationFields = helpers.views.calculationFields(view)

View File

@ -11,6 +11,8 @@ import { default as plugins } from "./plugins"
import * as views from "./app/views"
import * as permissions from "./app/permissions"
import * as rowActions from "./app/rowActions"
import * as screens from "./app/screens"
import * as common from "./app/common"
const sdk = {
backups,
@ -22,10 +24,12 @@ const sdk = {
datasources,
queries,
plugins,
screens,
views,
permissions,
links,
rowActions,
common,
}
// default export for TS

View File

@ -1,4 +1,4 @@
import { Screen } from "@budibase/types"
import { Screen, UsageInScreensResponse } from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class ScreenAPI extends TestAPI {
@ -28,4 +28,16 @@ export class ScreenAPI extends TestAPI {
}
)
}
usage = async (
sourceId: string,
expectations?: Expectations
): Promise<UsageInScreensResponse> => {
return this._post<UsageInScreensResponse>(
`/api/screens/usage/${sourceId}`,
{
expectations,
}
)
}
}

View File

@ -39,6 +39,11 @@ import {
import { LoopInput } from "../../definitions/automations"
import { merge } from "lodash"
import { generator } from "@budibase/backend-core/tests"
export {
createTableScreen,
createQueryScreen,
createViewScreen,
} from "../../constants/screens"
const { BUILTIN_ROLE_IDS } = roles

View File

@ -1,3 +1,4 @@
export * as applications from "./applications"
export * as automations from "./automations"
export * as users from "./users"
export * as screens from "./screens"

View File

@ -0,0 +1,23 @@
import { Screen, Component } from "@budibase/types"
export function findInSettings(screen: Screen, toFind: string) {
const foundIn: { setting: string; value: string }[] = []
function recurse(props: Component, parentKey = "") {
for (const [key, value] of Object.entries(props)) {
if (!value) {
continue
}
if (typeof value === "string" && value.includes(toFind)) {
foundIn.push({
setting: parentKey ? `${parentKey}.${key}` : key,
value: value,
})
} else if (typeof value === "object") {
recurse(value, key)
}
}
}
recurse(screen.props)
return foundIn
}

View File

@ -1,4 +1,4 @@
import { ScreenRoutingJson, Screen } from "../../../documents"
import { ScreenRoutingJson, Screen, SourceType } from "../../../documents"
export interface FetchScreenRoutingResponse {
routes: ScreenRoutingJson
@ -15,3 +15,13 @@ export interface SaveScreenResponse extends Screen {}
export interface DeleteScreenResponse {
message: string
}
export interface ScreenUsage {
url: string
_id: string
}
export interface UsageInScreensResponse {
sourceType: SourceType
screens: ScreenUsage[]
}

View File

@ -57,3 +57,10 @@ export interface RestConfig {
}
dynamicVariables?: DynamicVariable[]
}
export enum SourceType {
DATASOURCE = "datasource",
QUERY = "query",
TABLE = "table",
VIEW = "view",
}

View File

@ -0,0 +1,5 @@
export interface UIEvent extends Omit<Event, "target"> {
currentTarget: EventTarget & HTMLInputElement
key?: string
target?: any
}

View File

@ -1,10 +1,13 @@
interface BaseUIComponentError {
componentId: string
message: string
}
interface UISettingComponentError extends BaseUIComponentError {
errorType: "setting"
key: string
label: string
cause: "missing" | "invalid"
}
interface UIAncestorComponentError extends BaseUIComponentError {

View File

@ -15,20 +15,24 @@ export interface ComponentDefinition {
illegalChildren: string[]
}
export type DependsOnComponentSetting =
| string
| {
setting: string
value: string
}
export interface ComponentSetting {
key: string
type: string
label?: string
section?: string
name?: string
required?: boolean
defaultValue?: any
selectAllFields?: boolean
resetOn?: string | string[]
settings?: ComponentSetting[]
dependsOn?:
| string
| {
setting: string
value: string
}
dependsOn?: DependsOnComponentSetting
sectionDependsOn?: DependsOnComponentSetting
}

View File

@ -3,3 +3,4 @@ export * from "./bindings"
export * from "./components"
export * from "./dataFetch"
export * from "./datasource"
export * from "./common"