Merge pull request #15499 from Budibase/feature/pre-empt-data-source-deletion

Data section resource deletion warning
This commit is contained in:
Michael Drury 2025-02-07 13:02:38 +00:00 committed by GitHub
commit b4086e1187
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 691 additions and 352 deletions

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. * 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 // this covers both datasources and datasource plus
return !!id && id.startsWith(`${DocumentType.DATASOURCE}${SEPARATOR}`) 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. * Gets parameters for retrieving workspaces.
*/ */

View File

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

View File

@ -7,7 +7,7 @@
import IntegrationIcon from "@/components/backend/DatasourceNavigator/IntegrationIcon.svelte" import IntegrationIcon from "@/components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import UpdateDatasourceModal from "@/components/backend/DatasourceNavigator/modals/UpdateDatasourceModal.svelte" 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 export let datasource
@ -71,7 +71,10 @@
{/if} {/if}
</NavItem> </NavItem>
<UpdateDatasourceModal {datasource} bind:this={editModal} /> <UpdateDatasourceModal {datasource} bind:this={editModal} />
<DeleteConfirmationModal {datasource} bind:this={deleteConfirmationModal} /> <DeleteDataConfirmModal
source={datasource}
bind:this={deleteConfirmationModal}
/>
<style> <style>
.datasource-icon { .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" } from "@/helpers/data/utils"
import { goto as gotoStore, isActive } from "@roxi/routify" import { goto as gotoStore, isActive } from "@roxi/routify"
import { import {
datasources,
queries, queries,
userSelectedResourceMap, userSelectedResourceMap,
contextMenuStore, contextMenuStore,
} from "@/stores/builder" } from "@/stores/builder"
import NavItem from "@/components/common/NavItem.svelte" 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" import { notifications, Icon } from "@budibase/bbui"
export let datasource export let datasource
export let query export let query
let confirmDeleteDialog let confirmDeleteModal
// goto won't work in the context menu callback if the store is called directly // goto won't work in the context menu callback if the store is called directly
$: goto = $gotoStore $: goto = $gotoStore
@ -31,7 +30,7 @@
keyBind: null, keyBind: null,
visible: true, visible: true,
disabled: false, disabled: false,
callback: confirmDeleteDialog.show, callback: confirmDeleteModal.show,
}, },
{ {
icon: "Duplicate", 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 => { const openContextMenu = e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -90,14 +75,7 @@
<Icon size="S" hoverable name="MoreSmallList" on:click={openContextMenu} /> <Icon size="S" hoverable name="MoreSmallList" on:click={openContextMenu} />
</NavItem> </NavItem>
<ConfirmDialog <DeleteDataConfirmModal source={query} bind:this={confirmDeleteModal} />
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>
<style> <style>
</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 NavItem from "@/components/common/NavItem.svelte"
import { isActive } from "@roxi/routify" import { isActive } from "@roxi/routify"
import EditModal from "./EditModal.svelte" import EditModal from "./EditModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte" import DeleteConfirmationModal from "../../modals/DeleteDataConfirmationModal.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { DB_TYPE_EXTERNAL } from "@/constants/backend" import { DB_TYPE_EXTERNAL } from "@/constants/backend"
@ -65,4 +65,4 @@
{/if} {/if}
</NavItem> </NavItem>
<EditModal {table} bind:this={editModal} /> <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 onOk = undefined
export let onCancel = undefined export let onCancel = undefined
export let warning = true export let warning = true
export let disabled export let disabled = false
let modal let modal

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

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() export const screenStore = new ScreenStore()

View File

@ -2,12 +2,14 @@ import {
DeleteScreenResponse, DeleteScreenResponse,
SaveScreenRequest, SaveScreenRequest,
SaveScreenResponse, SaveScreenResponse,
UsageInScreensResponse,
} from "@budibase/types" } from "@budibase/types"
import { BaseAPIClient } from "./types" import { BaseAPIClient } from "./types"
export interface ScreenEndpoints { export interface ScreenEndpoints {
saveScreen: (screen: SaveScreenRequest) => Promise<SaveScreenResponse> saveScreen: (screen: SaveScreenRequest) => Promise<SaveScreenResponse>
deleteScreen: (id: string, rev: string) => Promise<DeleteScreenResponse> deleteScreen: (id: string, rev: string) => Promise<DeleteScreenResponse>
usageInScreens: (sourceId: string) => Promise<UsageInScreensResponse>
} }
export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({ export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({
@ -32,4 +34,10 @@ export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({
url: `/api/screens/${id}/${rev}`, 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 { import {
events,
context, context,
tenancy,
db as dbCore, db as dbCore,
events,
roles, roles,
tenancy,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { updateAppPackage } from "./application" import { updateAppPackage } from "./application"
import { import {
Plugin, DeleteScreenResponse,
ScreenProps,
Screen,
UserCtx,
FetchScreenResponse, FetchScreenResponse,
Plugin,
SaveScreenRequest, SaveScreenRequest,
SaveScreenResponse, SaveScreenResponse,
DeleteScreenResponse, Screen,
ScreenProps,
ScreenUsage,
UsageInScreensResponse,
UserCtx,
} from "@budibase/types" } from "@budibase/types"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
import sdk from "../../sdk"
import { sdk as sharedSdk } from "@budibase/shared-core"
export async function fetch(ctx: UserCtx<void, FetchScreenResponse>) { export async function fetch(ctx: UserCtx<void, FetchScreenResponse>) {
const db = context.getAppDB() const screens = await sdk.screens.fetch()
const screens = (
await db.allDocs(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map((el: any) => el.doc)
const roleId = ctx.user?.role?._id as string const roleId = ctx.user?.role?._id as string
if (!roleId) { if (!roleId) {
@ -140,3 +136,23 @@ function findPlugins(component: ScreenProps, foundPlugins: string[]) {
} }
component._children.forEach(child => findPlugins(child, foundPlugins)) 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), authorized(permissions.BUILDER),
controller.destroy controller.destroy
) )
.post(
"/api/screens/usage/:sourceId",
authorized(permissions.BUILDER),
controller.usage
)
export default router export default router

View File

@ -1,9 +1,24 @@
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
import { events, roles } from "@budibase/backend-core" 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", () => { describe("/screens", () => {
let config = setup.getConfig() let config = setup.getConfig()
@ -18,7 +33,7 @@ describe("/screens", () => {
describe("fetch", () => { describe("fetch", () => {
it("should be able to create a layout", async () => { 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.length).toEqual(1)
expect(screens.some(s => s._id === screen._id)).toEqual(true) expect(screens.some(s => s._id === screen._id)).toEqual(true)
}) })
@ -52,28 +67,22 @@ describe("/screens", () => {
inherits: [role1._id!, role2._id!], inherits: [role1._id!, role2._id!],
permissionId: BuiltinPermissionID.WRITE, permissionId: BuiltinPermissionID.WRITE,
}) })
screen1 = await config.api.screen.save( screen1 = await config.api.screen.save({
{ ...basicScreen(),
...basicScreen(), routing: {
routing: { roleId: role1._id!,
roleId: role1._id!, route: "/foo",
route: "/foo", homeScreen: false,
homeScreen: false,
},
}, },
{ status: 200 } })
) screen2 = await config.api.screen.save({
screen2 = await config.api.screen.save( ...basicScreen(),
{ routing: {
...basicScreen(), roleId: role2._id!,
routing: { route: "/bar",
roleId: role2._id!, homeScreen: false,
route: "/bar",
homeScreen: false,
},
}, },
{ status: 200 } })
)
// get into prod app // get into prod app
await config.publish() await config.publish()
}) })
@ -81,10 +90,7 @@ describe("/screens", () => {
async function checkScreens(roleId: string, screenIds: string[]) { async function checkScreens(roleId: string, screenIds: string[]) {
await config.loginAsRole(roleId, async () => { await config.loginAsRole(roleId, async () => {
const res = await config.api.application.getDefinition( const res = await config.api.application.getDefinition(
config.prodAppId!, config.getProdAppId()
{
status: 200,
}
) )
expect(res.screens.length).toEqual(screenIds.length) expect(res.screens.length).toEqual(screenIds.length)
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort()) expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
@ -114,10 +120,7 @@ describe("/screens", () => {
}, },
async () => { async () => {
const res = await config.api.application.getDefinition( const res = await config.api.application.getDefinition(
config.prodAppId!, config.prodAppId!
{
status: 200,
}
) )
const screenIds = [screen._id!, screen1._id!] const screenIds = [screen._id!, screen1._id!]
expect(res.screens.length).toEqual(screenIds.length) expect(res.screens.length).toEqual(screenIds.length)
@ -134,9 +137,7 @@ describe("/screens", () => {
it("should be able to create a screen", async () => { it("should be able to create a screen", async () => {
const screen = basicScreen() const screen = basicScreen()
const responseScreen = await config.api.screen.save(screen, { const responseScreen = await config.api.screen.save(screen)
status: 200,
})
expect(responseScreen._rev).toBeDefined() expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name) expect(responseScreen.name).toEqual(screen.name)
@ -145,13 +146,13 @@ describe("/screens", () => {
it("should be able to update a screen", async () => { it("should be able to update a screen", async () => {
const screen = basicScreen() 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._id = responseScreen._id
screen._rev = responseScreen._rev screen._rev = responseScreen._rev
screen.name = "edit" screen.name = "edit"
jest.clearAllMocks() jest.clearAllMocks()
responseScreen = await config.api.screen.save(screen, { status: 200 }) responseScreen = await config.api.screen.save(screen)
expect(responseScreen._rev).toBeDefined() expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name) expect(responseScreen.name).toEqual(screen.name)
@ -171,8 +172,7 @@ describe("/screens", () => {
it("should be able to delete the screen", async () => { it("should be able to delete the screen", async () => {
const response = await config.api.screen.destroy( const response = await config.api.screen.destroy(
screen._id!, screen._id!,
screen._rev!, screen._rev!
{ status: 200 }
) )
expect(response.message).toBeDefined() expect(response.message).toBeDefined()
expect(events.screen.deleted).toHaveBeenCalledTimes(1) 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 { roles } from "@budibase/backend-core"
import { BASE_LAYOUT_PROP_IDS } from "./layouts" import { BASE_LAYOUT_PROP_IDS } from "./layouts"
import { Screen } from "@budibase/types" import { Screen, Table, Query, ViewV2, Component } from "@budibase/types"
export function createHomeScreen( export function createHomeScreen(
config: { config: {
@ -53,3 +53,177 @@ export function createHomeScreen(
name: "home-screen", 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 return false
} }
export function isInternal(opts: { table?: Table; tableId?: string }): boolean {
return !isExternal(opts)
}
export function isTable(table: any): table is Table { export function isTable(table: any): table is Table {
return table._id && docIds.isTableId(table._id) 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 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">) { function guardDuplicateCalculationFields(view: Omit<ViewV2, "id" | "version">) {
const seen: Record<string, Record<CalculationType, boolean>> = {} const seen: Record<string, Record<CalculationType, boolean>> = {}
const calculationFields = helpers.views.calculationFields(view) 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 views from "./app/views"
import * as permissions from "./app/permissions" import * as permissions from "./app/permissions"
import * as rowActions from "./app/rowActions" import * as rowActions from "./app/rowActions"
import * as screens from "./app/screens"
import * as common from "./app/common"
const sdk = { const sdk = {
backups, backups,
@ -22,10 +24,12 @@ const sdk = {
datasources, datasources,
queries, queries,
plugins, plugins,
screens,
views, views,
permissions, permissions,
links, links,
rowActions, rowActions,
common,
} }
// default export for TS // 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" import { Expectations, TestAPI } from "./base"
export class ScreenAPI extends TestAPI { 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 { LoopInput } from "../../definitions/automations"
import { merge } from "lodash" import { merge } from "lodash"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
export {
createTableScreen,
createQueryScreen,
createViewScreen,
} from "../../constants/screens"
const { BUILTIN_ROLE_IDS } = roles const { BUILTIN_ROLE_IDS } = roles

View File

@ -1,3 +1,4 @@
export * as applications from "./applications" export * as applications from "./applications"
export * as automations from "./automations" export * as automations from "./automations"
export * as users from "./users" 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 { export interface FetchScreenRoutingResponse {
routes: ScreenRoutingJson routes: ScreenRoutingJson
@ -15,3 +15,13 @@ export interface SaveScreenResponse extends Screen {}
export interface DeleteScreenResponse { export interface DeleteScreenResponse {
message: string 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[] 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

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