This commit is contained in:
Adria Navarro 2025-05-19 12:23:08 +02:00 committed by GitHub
commit eceb4c5e7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 535 additions and 69 deletions

View File

@ -17,11 +17,23 @@
export let autocomplete: boolean | string | undefined = undefined
export let helpText: string | undefined = undefined
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{
change: any
enterkey: KeyboardEvent
}>()
const onChange = (e: any) => {
value = e.detail
dispatch("change", e.detail)
}
const onKeyDown = (e: KeyboardEvent) => {
if (readonly || disabled) {
return
}
if (e.key === "Enter") {
dispatch("enterkey", e)
}
}
</script>
<Field {helpText} {label} {labelPosition} {error}>
@ -42,6 +54,7 @@
on:focus
on:keyup
on:keydown
on:keydown={onKeyDown}
>
<slot />
</TextField>

View File

@ -1,6 +1,7 @@
<script>
import { contextMenuStore } from "@/stores/builder"
import { Popover, Menu, MenuItem } from "@budibase/bbui"
import { Menu, MenuItem, Popover } from "@budibase/bbui"
import NewPill from "./common/NewPill.svelte"
let dropdown
let anchor
@ -46,6 +47,11 @@
disabled={item.disabled}
>
{item.name}
<div slot="right">
{#if item.isNew}
<NewPill />
{/if}
</div>
</MenuItem>
{/if}
{/each}

View File

@ -26,7 +26,7 @@
</div>
<Modal bind:this={modal}>
<ChooseIconModal {name} {color} on:change />
<ChooseIconModal bind:name bind:color on:change />
</Modal>
<style>

View File

@ -6,7 +6,7 @@
export let title: string
export let placeholder: string
export let value: string
export let onAdd: () => void
export let onAdd: (_e: Event) => void
export let search: boolean
let searchInput: HTMLInputElement
@ -28,11 +28,11 @@
}
}
const handleAddButton = () => {
const handleAddButton = (e: Event) => {
if (search) {
closeSearch()
} else {
onAdd()
onAdd(e)
}
}
</script>
@ -64,7 +64,7 @@
<div
on:click={handleAddButton}
on:keydown={keyUtils.handleEnter(handleAddButton)}
on:keydown={e => keyUtils.handleEnter(() => handleAddButton(e))}
class="addButton"
class:rotate={search}
>

View File

@ -4,7 +4,7 @@
import { UserAvatars } from "@budibase/frontend-core"
import type { UIUser } from "@budibase/types"
export let icon: string | null
export let icon: string | null = null
export let iconTooltip: string = ""
export let withArrow: boolean = false
export let withActions: boolean = true
@ -26,6 +26,7 @@
export let compact: boolean = false
export let hovering: boolean = false
export let disabled: boolean = false
export let nonSelectable: boolean = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -73,6 +74,7 @@
class:highlighted
class:selectedBy
class:disabled
class:nonSelectable
on:dragend
on:dragstart
on:dragover
@ -156,6 +158,9 @@
justify-content: flex-start;
align-items: stretch;
}
.nav-item.nonSelectable {
cursor: inherit;
}
.nav-item.scrollable {
flex-direction: column;
justify-content: center;
@ -173,7 +178,7 @@
.nav-item.disabled span {
color: var(--spectrum-global-color-gray-700);
}
.nav-item:hover,
.nav-item:not(.nonSelectable):hover,
.hovering {
background-color: var(--spectrum-global-color-gray-200);
--avatars-background: var(--spectrum-global-color-gray-300);

View File

@ -1,12 +1,10 @@
<script lang="ts">
import { Tooltip, StatusLight } from "@budibase/bbui"
import { StatusLight, AbsTooltip } from "@budibase/bbui"
import { roles } from "@/stores/builder"
import { Roles } from "@/constants/backend"
export let roleId: string
let showTooltip = false
$: role = $roles.find(role => role._id === roleId)
$: color =
role?.uiMetadata?.color || "var(--spectrum-global-color-static-magenta-400)"
@ -16,43 +14,6 @@
: `Requires ${role?.uiMetadata?.displayName || "Unknown role"} access`
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="container"
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus
style="--color: {color};"
>
<AbsTooltip text={tooltip} {color}>
<StatusLight square {color} />
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={tooltip} direction="right" />
</div>
{/if}
</div>
<style>
.container {
position: relative;
}
.tooltip {
z-index: 1;
position: absolute;
bottom: -5px;
left: 13px;
display: flex;
flex-direction: row;
justify-content: flex-end;
pointer-events: none;
}
.tooltip :global(.spectrum-Tooltip) {
background: var(--color);
color: white;
font-weight: 600;
max-width: 200px;
}
.tooltip :global(.spectrum-Tooltip-tip) {
border-top-color: var(--color);
}
</style>
</AbsTooltip>

View File

@ -120,7 +120,7 @@
hoverable
name="MoreSmallList"
/>
<div slot="icon" class="icon">
<div slot="right" class="icon">
<RoleIndicator roleId={screen.routing.roleId} />
</div>
</NavItem>

View File

@ -0,0 +1,132 @@
<script lang="ts">
import NavItem from "@/components/common/NavItem.svelte"
import { confirm } from "@/helpers"
import { contextMenuStore, workspaceAppStore } from "@/stores/builder"
import { Icon, Layout, notifications } from "@budibase/bbui"
import type { UIWorkspaceApp } from "@budibase/types"
import { goto } from "@roxi/routify"
import { createEventDispatcher } from "svelte"
import ScreenNavItem from "./ScreenNavItem.svelte"
export let workspaceApp: UIWorkspaceApp
export let searchValue: string
const dispatch = createEventDispatcher<{ edit: void }>()
async function onDelete() {
await confirm({
title: "Confirm Deletion",
body: `Deleting "${workspaceApp.name}" cannot be undone. Are you sure?`,
okText: "Delete app",
warning: true,
onConfirm: async () => {
try {
await workspaceAppStore.delete(workspaceApp._id, workspaceApp._rev)
notifications.success(
`App '${workspaceApp.name}' deleted successfully`
)
} catch (e: any) {
let message = "Error deleting app"
if (e.message) {
message += ` - ${e.message}`
}
notifications.error(message)
}
},
})
}
const openContextMenu = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const items = [
{
icon: "Add",
name: "Add screen",
keyBind: null,
visible: true,
callback: () => $goto("../new?workspaceAppId=" + workspaceApp._id),
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
callback: () => {
dispatch("edit")
},
},
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
callback: onDelete,
},
]
contextMenuStore.open("projectContextMenu", items, {
x: e.clientX,
y: e.clientY,
})
}
$: noResultsMessage = searchValue
? "There aren't screens matching that route"
: ""
</script>
<div class="project-app-nav-item">
<NavItem
on:contextmenu={openContextMenu}
indentLevel={0}
text={workspaceApp.name}
showTooltip
nonSelectable
>
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
<div slot="icon">
<Icon name={workspaceApp.icon} size="XS" color={workspaceApp.iconColor} />
</div>
</NavItem>
</div>
<div class="screens">
{#each workspaceApp.screens as screen (screen._id)}
<div class="screen">
<ScreenNavItem {screen} />
</div>
{:else}
<Layout paddingY="none" paddingX="L">
<div class="no-results">{noResultsMessage}</div>
</Layout>
{/each}
</div>
<style>
.screens {
border-left: 2px solid var(--spectrum-global-color-gray-200);
padding-right: 12px;
margin-left: 20px;
}
.screens .screen {
margin-left: 8px;
}
.screens :global(.nav-item) {
border-radius: 4px;
}
.screens :global(.nav-item-content) {
padding-left: 8px;
}
.no-results {
color: var(--spectrum-global-color-gray-600);
}
.project-app-nav-item :global(.nav-item-content .icon) {
margin-right: 4px;
}
</style>

View File

@ -1,11 +1,17 @@
<script lang="ts">
import { Layout } from "@budibase/bbui"
import { sortedScreens } from "@/stores/builder"
import ScreenNavItem from "./ScreenNavItem.svelte"
import { goto } from "@roxi/routify"
import { getVerticalResizeActions } from "@/components/common/resizable"
import NavHeader from "@/components/common/NavHeader.svelte"
import type { Screen } from "@budibase/types"
import { getVerticalResizeActions } from "@/components/common/resizable"
import { contextMenuStore, sortedScreens } from "@/stores/builder"
import { workspaceAppStore } from "@/stores/builder/workspaceApps"
import { featureFlags } from "@/stores/portal"
import { Layout } from "@budibase/bbui"
import type { WorkspaceApp, Screen, UIWorkspaceApp } from "@budibase/types"
import { goto } from "@roxi/routify"
import WorkspaceAppModal from "../WorkspaceApp/WorkspaceAppModal.svelte"
import WorkspaceAppNavItem from "./WorkspaceAppNavItem.svelte"
import ScreenNavItem from "./ScreenNavItem.svelte"
$: workspaceAppsEnabled = $featureFlags.WORKSPACE_APPS
const [resizable, resizableHandle] = getVerticalResizeActions()
@ -14,7 +20,14 @@
let screensContainer: HTMLDivElement
let scrolling = false
let workspaceAppModal: WorkspaceAppModal
let selectedWorkspaceApp: WorkspaceApp | undefined
$: filteredScreens = getFilteredScreens($sortedScreens, searchValue)
$: filteredWorkspaceApps = getFilteredWorkspaceApps(
$workspaceAppStore.workspaceApps,
searchValue
)
const handleOpenSearch = async () => {
screensContainer.scroll({ top: 0, behavior: "smooth" })
@ -32,9 +45,64 @@
})
}
const getFilteredWorkspaceApps = (
workspaceApps: UIWorkspaceApp[],
searchValue: string
) => {
if (!searchValue) {
return workspaceApps
}
const filteredProjects: UIWorkspaceApp[] = []
for (const workspaceApp of workspaceApps) {
filteredProjects.push({
...workspaceApp,
screens: getFilteredScreens(workspaceApp.screens, searchValue),
})
}
return filteredProjects
}
const handleScroll = (e: any) => {
scrolling = e.target.scrollTop !== 0
}
const onAdd = (e: Event) => {
if (!workspaceAppsEnabled) {
return $goto("../new")
}
const items = [
{
name: "Add app",
keyBind: null,
visible: true,
disabled: false,
callback: () => {
workspaceAppModal.show()
},
isNew: true,
},
{
name: "Add screen",
keyBind: null,
visible: true,
callback: () => $goto("../new"),
},
]
const boundingBox = (e.currentTarget as HTMLElement).getBoundingClientRect()
contextMenuStore.open("newProject", items, {
x: boundingBox.x,
y: boundingBox.y + boundingBox.height,
})
}
function onEditWorkspaceApp(workspaceApp: WorkspaceApp) {
selectedWorkspaceApp = workspaceApp
workspaceAppModal.show()
}
</script>
<div class="screens" class:searching use:resizable>
@ -44,11 +112,19 @@
placeholder="Search for screens"
bind:value={searchValue}
bind:search={searching}
onAdd={() => $goto("../new")}
{onAdd}
/>
</div>
<div on:scroll={handleScroll} bind:this={screensContainer} class="content">
{#if filteredScreens?.length}
{#if workspaceAppsEnabled}
{#each filteredWorkspaceApps as workspaceApp}
<WorkspaceAppNavItem
{workspaceApp}
on:edit={() => onEditWorkspaceApp(workspaceApp)}
{searchValue}
/>
{/each}
{:else if filteredScreens?.length}
{#each filteredScreens as screen (screen._id)}
<ScreenNavItem {screen} />
{/each}
@ -69,6 +145,12 @@
/>
</div>
<WorkspaceAppModal
bind:this={workspaceAppModal}
workspaceApp={selectedWorkspaceApp}
on:hide={() => (selectedWorkspaceApp = undefined)}
/>
<style>
.screens {
display: flex;

View File

@ -0,0 +1,141 @@
<script lang="ts">
import EditableIcon from "@/components/common/EditableIcon.svelte"
import { workspaceAppStore } from "@/stores/builder"
import { Input, keepOpen, Label, Modal, ModalContent } from "@budibase/bbui"
import type { WorkspaceApp } from "@budibase/types"
import type { ZodType } from "zod"
import { z } from "zod"
export let workspaceApp: WorkspaceApp | null = null
let modal: Modal
export const show = () => modal.show()
let errors: Partial<Record<keyof WorkspaceApp, string>> = {}
let data: WorkspaceApp
$: isNew = !workspaceApp
$: title = isNew ? "Create new app" : "Edit app"
const requiredString = (errorMessage: string) =>
z.string({ required_error: errorMessage }).trim().min(1, errorMessage)
const validateWorkspaceApp = (workspaceApp: Partial<WorkspaceApp>) => {
const validator = z.object({
name: requiredString("Name is required.").refine(
val =>
!$workspaceAppStore.workspaceApps
.filter(a => a._id !== workspaceApp._id)
.map(a => a.name.toLowerCase())
.includes(val.toLowerCase()),
{
message: "This name is already taken.",
}
),
urlPrefix: requiredString("Url prefix is required.")
.regex(/^\/\w*$/, {
message:
"Url must start with / and contain only alphanumeric characters.",
})
.refine(
val =>
!$workspaceAppStore.workspaceApps
.filter(a => a._id !== workspaceApp._id)
.map(a => a.urlPrefix.toLowerCase())
.includes(val.toLowerCase()),
{
message: "This url is already taken.",
}
),
icon: z.string(),
iconColor: z.string(),
}) satisfies ZodType<WorkspaceApp>
const validationResult = validator.safeParse(workspaceApp)
errors = {}
if (!validationResult.success) {
errors = Object.entries(
validationResult.error.formErrors.fieldErrors
).reduce<Record<string, string>>((acc, [field, errors]) => {
if (errors[0]) {
acc[field] = errors[0]
}
return acc
}, {})
}
return validationResult
}
function onShow() {
data = {
_id: workspaceApp?._id,
_rev: workspaceApp?._rev,
name: workspaceApp?.name ?? "",
urlPrefix: workspaceApp?.urlPrefix ?? "",
icon: workspaceApp?.icon ?? "Monitoring",
iconColor: workspaceApp?.iconColor ?? "",
}
}
async function onConfirm() {
const validationResult = validateWorkspaceApp(data)
if (validationResult.error) {
return keepOpen
}
const { data: workspaceAppData } = validationResult
if (isNew) {
await workspaceAppStore.add(workspaceAppData)
} else {
await workspaceAppStore.edit({
...workspaceAppData,
_id: workspaceApp!._id,
_rev: workspaceApp!._rev,
})
}
}
async function onEnterKey() {
const result = await onConfirm()
if (result === keepOpen) {
return result
}
modal.hide()
}
$: {
if (data && !data.urlPrefix.startsWith("/")) {
data.urlPrefix = `/${data.urlPrefix}`
}
}
</script>
<Modal bind:this={modal} on:show={onShow} on:hide>
<ModalContent {title} {onConfirm}>
<Input
label="App Name"
on:enterkey={onEnterKey}
bind:value={data.name}
error={errors.name}
/>
<Input
label="Project url"
on:enterkey={onEnterKey}
bind:value={data.urlPrefix}
error={errors.urlPrefix}
/>
<Label size="L">Icon</Label>
<EditableIcon
bind:name={data.icon}
color={data.iconColor || ""}
on:change={e => {
data.iconColor = e.detail
}}
/>
</ModalContent>
</Modal>

View File

@ -20,6 +20,8 @@
import { makeTableOption, makeViewOption } from "./utils"
import type { Screen, Table, ViewV2 } from "@budibase/types"
export let workspaceAppId: string
let mode: string
let screenDetailsModal: Modal
@ -71,7 +73,10 @@
const createScreen = async (screenTemplate: Screen): Promise<Screen> => {
try {
return await screenStore.save(screenTemplate)
return await screenStore.save({
...screenTemplate,
workspaceAppId: workspaceAppId,
})
} catch (error) {
console.error(error)
notifications.error("Error creating screens")

View File

@ -10,6 +10,7 @@
import { licensing } from "@/stores/portal"
import { AutoScreenTypes } from "@/constants"
export let workspaceAppId
export let onClose = null
let createScreenModal
@ -95,7 +96,7 @@
</CreationPage>
</div>
<CreateScreenModal bind:this={createScreenModal} />
<CreateScreenModal {workspaceAppId} bind:this={createScreenModal} />
<style>
.page {

View File

@ -5,6 +5,10 @@
$: onClose = getOnClose($screenStore)
$: workspaceAppId =
new URLSearchParams(window.location.search).get("workspaceAppId") ||
"default"
const getOnClose = ({ screens, selectedScreenId }) => {
if (!screens?.length) {
return null
@ -20,4 +24,4 @@
}
</script>
<NewScreen {onClose} />
<NewScreen {workspaceAppId} {onClose} />

View File

@ -10,8 +10,9 @@ interface MenuItem {
name: string
keyBind: string | null
visible: boolean
disabled: boolean
disabled?: boolean
callback: () => void
isNew?: boolean
}
interface ContextMenuState {

View File

@ -37,6 +37,7 @@ import { flags } from "./flags"
import { rowActions } from "./rowActions"
import componentTreeNodesStore from "./componentTreeNodes"
import { oauth2 } from "./oauth2"
import { workspaceAppStore } from "./workspaceApps"
import { FetchAppPackageResponse } from "@budibase/types"
@ -79,6 +80,7 @@ export {
screenComponentErrors,
screenComponentErrorList,
oauth2,
workspaceAppStore,
}
export const reset = () => {
@ -121,6 +123,7 @@ export const initialise = async (pkg: FetchAppPackageResponse) => {
themeStore.syncAppTheme(application)
snippets.syncMetadata(application)
screenStore.syncAppScreens(pkg)
workspaceAppStore.syncWorkspaceApps(pkg)
layoutStore.syncAppLayouts(pkg)
resetBuilderHistory()
await refreshBuilderData()

View File

@ -23,6 +23,7 @@ import {
SaveScreenResponse,
Screen,
ScreenVariant,
WithRequired,
} from "@budibase/types"
import { featureFlag } from "@/helpers"
@ -39,7 +40,7 @@ export const initialScreenState: ScreenState = {
export class ScreenStore extends BudiStore<ScreenState> {
history: HistoryStore<Screen>
delete: (screens: Screen) => Promise<void>
save: (screen: Screen) => Promise<Screen>
save: (screen: WithRequired<Screen, "workspaceAppId">) => Promise<Screen>
constructor() {
super(initialScreenState)
@ -312,7 +313,10 @@ export class ScreenStore extends BudiStore<ScreenState> {
if (result === false) {
return
}
return this.save(clone)
return this.save({
...clone,
workspaceAppId: clone.workspaceAppId!,
})
}
)

View File

@ -245,7 +245,9 @@ describe("Screens store", () => {
expect(bb.store.screens.length).toBe(1)
expect(bb.store.screens[0]).toStrictEqual(newDoc)
expect(bb.store.screens[0]).toStrictEqual({
...newDoc,
})
expect(bb.store.selectedScreenId).toBe(newDocId)
@ -808,6 +810,10 @@ describe("Screens store", () => {
screen.name = "updated"
}, existingDocId)
expect(saveSpy).toBeCalledWith({ ...original, name: "updated" })
expect(saveSpy).toBeCalledWith({
...original,
workspaceAppId: "default",
name: "updated",
})
})
})

View File

@ -0,0 +1,95 @@
import { DerivedBudiStore } from "@/stores/BudiStore"
import {
WorkspaceApp,
UIWorkspaceApp,
FetchAppPackageResponse,
FeatureFlag,
} from "@budibase/types"
import { derived, Readable } from "svelte/store"
import { screenStore } from "./screens"
import { Helpers } from "@budibase/bbui"
import { featureFlag } from "@/helpers"
interface WorkspaceAppStoreState {
workspaceApps: WorkspaceApp[]
loading: boolean
}
interface DerivedWorkspaceAppStoreState {
workspaceApps: UIWorkspaceApp[]
}
export class WorkspaceAppStore extends DerivedBudiStore<
WorkspaceAppStoreState,
DerivedWorkspaceAppStoreState
> {
constructor() {
const makeDerivedStore = (store: Readable<WorkspaceAppStoreState>) => {
return derived([store, screenStore], ([$store, $screenStore]) => {
const workspaceApps = $store.workspaceApps.map<UIWorkspaceApp>(
workspaceApp => {
return {
...workspaceApp,
screens: $screenStore.screens.filter(
s => s.workspaceAppId === workspaceApp._id
),
}
}
)
return { workspaceApps }
})
}
super(
{
workspaceApps: [],
loading: true,
},
makeDerivedStore
)
}
syncWorkspaceApps(pkg: FetchAppPackageResponse) {
let workspaceApps = featureFlag.isEnabled(FeatureFlag.WORKSPACE_APPS)
? pkg.workspaceApps
: []
this.update(state => ({
...state,
workspaceApps,
loading: false,
}))
}
async add(workspaceApp: WorkspaceApp) {
this.store.update(state => {
state.workspaceApps.push({ ...workspaceApp, _id: Helpers.uuid() })
return state
})
}
async edit(workspaceApp: WorkspaceApp) {
this.store.update(state => {
const index = state.workspaceApps.findIndex(
app => app._id === workspaceApp._id
)
if (index === -1) {
throw new Error(`App not found with id "${workspaceApp._id}"`)
}
state.workspaceApps[index] = {
...workspaceApp,
}
return state
})
}
async delete(_id: string | undefined, _rev: string | undefined) {
this.store.update(state => {
state.workspaceApps = state.workspaceApps.filter(app => app._id !== _id)
return state
})
}
}
export const workspaceAppStore = new WorkspaceAppStore()

View File

@ -18,10 +18,11 @@ export const buildScreenEndpoints = (API: BaseAPIClient): ScreenEndpoints => ({
* @param screen the screen to save
*/
saveScreen: async screen => {
return await API.post({
const result = await API.post<SaveScreenRequest, SaveScreenResponse>({
url: "/api/screens",
body: screen,
})
return result
},
/**

View File

@ -6,4 +6,5 @@ export * from "./dataFetch"
export * from "./datasource"
export * from "./fields"
export * from "./permissions"
export * from "./workspaceApps"
export * from "./stores"

View File

@ -0,0 +1,5 @@
import { WorkspaceApp, Screen } from "../documents"
export interface UIWorkspaceApp extends WorkspaceApp {
screens: Screen[]
}