Add back in working component tree with scrolling and dropdown menus

This commit is contained in:
Andrew Kingston 2022-04-26 13:44:21 +01:00
parent 853ca15bb4
commit 168ec6634e
24 changed files with 260 additions and 214 deletions

View File

@ -2,7 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants" import { LAYOUT_NAMES } from "../constants"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
export const store = getFrontendStore() export const store = getFrontendStore()
@ -13,32 +13,26 @@ export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId) return $store.screens.find(screen => screen._id === $store.selectedScreenId)
}) })
export const currentAsset = selectedScreen
export const selectedComponent = derived( export const selectedComponent = derived(
[store, currentAsset], [store, selectedScreen],
([$store, $currentAsset]) => { ([$store, $selectedScreen]) => {
if (!$currentAsset || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null
} }
return findComponent($currentAsset?.props, $store.selectedComponentId) return findComponent($selectedScreen?.props, $store.selectedComponentId)
} }
) )
export const selectedComponentPath = derived( export const selectedComponentPath = derived(
[store, currentAsset], [store, selectedScreen],
([$store, $currentAsset]) => { ([$store, $selectedScreen]) => {
return findComponentPath( return findComponentPath(
$currentAsset?.props, $selectedScreen?.props,
$store.selectedComponentId $store.selectedComponentId
).map(component => component._id) ).map(component => component._id)
} }
) )
export const currentAssetName = derived(currentAsset, $currentAsset => {
return $currentAsset?.name
})
export const mainLayout = derived(store, $store => { export const mainLayout = derived(store, $store => {
return $store.layouts?.find( return $store.layouts?.find(
layout => layout._id === LAYOUT_NAMES.MASTER.PRIVATE layout => layout._id === LAYOUT_NAMES.MASTER.PRIVATE
@ -47,4 +41,5 @@ export const mainLayout = derived(store, $store => {
export const selectedAccessRole = writable("BASIC") export const selectedAccessRole = writable("BASIC")
export const screenSearchString = writable(null) // For compatibility
export const currentAsset = selectedScreen

View File

@ -48,7 +48,6 @@ const INITIAL_FRONTEND_STATE = {
}, },
currentFrontEndType: "none", currentFrontEndType: "none",
selectedLayoutId: "", selectedLayoutId: "",
selectedComponentId: "",
errors: [], errors: [],
hasAppPackage: false, hasAppPackage: false,
libraries: null, libraries: null,
@ -61,6 +60,7 @@ const INITIAL_FRONTEND_STATE = {
// URL params // URL params
selectedScreenId: null, selectedScreenId: null,
selectedComponentId: null,
} }
export const getFrontendStore = () => { export const getFrontendStore = () => {
@ -300,32 +300,6 @@ export const getFrontendStore = () => {
}, },
}, },
components: { components: {
select: component => {
const asset = get(currentAsset)
if (!asset || !component) {
return
}
// If this is the root component, select the asset instead
const parent = findComponentParent(asset.props, component._id)
if (parent == null) {
const state = get(store)
const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT
if (isLayout) {
store.actions.layouts.select(asset._id)
} else {
store.actions.screens.select(asset._id)
}
return
}
// Otherwise select the component
store.update(state => {
state.selectedComponentId = component._id
state.currentView = "component"
return state
})
},
getDefinition: componentName => { getDefinition: componentName => {
if (!componentName) { if (!componentName) {
return null return null
@ -464,7 +438,10 @@ export const getFrontendStore = () => {
parent._children = parent._children.filter( parent._children = parent._children.filter(
child => child._id !== component._id child => child._id !== component._id
) )
store.actions.components.select(parent) store.update(state => {
state.selectedComponentId = parent._id
return state
})
} }
await store.actions.preview.saveSelected() await store.actions.preview.saveSelected()
}, },
@ -488,7 +465,10 @@ export const getFrontendStore = () => {
parent._children = parent._children.filter( parent._children = parent._children.filter(
child => child._id !== component._id child => child._id !== component._id
) )
store.actions.components.select(parent) store.update(state => {
state.selectedComponentId = parent._id
return state
})
} }
} }
}, },
@ -539,7 +519,7 @@ export const getFrontendStore = () => {
// Save and select the new component // Save and select the new component
promises.push(store.actions.preview.saveSelected()) promises.push(store.actions.preview.saveSelected())
store.actions.components.select(componentToPaste) state.selectedComponentId = componentToPaste._id
return state return state
}) })
await Promise.all(promises) await Promise.all(promises)

View File

@ -1,114 +0,0 @@
<script>
import {
ActionMenu,
ActionButton,
MenuItem,
Icon,
notifications,
} from "@budibase/bbui"
import { store, currentAssetName, selectedComponent } from "builderStore"
import structure from "./componentStructure.json"
$: enrichedStructure = enrichStructure(structure, $store.components)
const isChildAllowed = ({ name }, selectedComponent) => {
const currentComponent = store.actions.components.getDefinition(
selectedComponent?._component
)
return currentComponent?.illegalChildren?.includes(name.toLowerCase())
}
const enrichStructure = (structure, definitions) => {
let enrichedStructure = []
structure.forEach(item => {
if (typeof item === "string") {
const def = definitions[`@budibase/standard-components/${item}`]
if (def) {
enrichedStructure.push({
...def,
isCategory: false,
})
}
} else {
enrichedStructure.push({
...item,
isCategory: true,
children: enrichStructure(item.children || [], definitions),
})
}
})
return enrichedStructure
}
const onItemChosen = async item => {
if (!item.isCategory) {
try {
await store.actions.components.create(item.component)
} catch (error) {
notifications.error("Error creating component")
}
}
}
</script>
<div class="components">
{#each enrichedStructure as item}
<ActionMenu disabled={!item.isCategory}>
<ActionButton
icon={item.icon}
disabled={!item.isCategory && isChildAllowed(item, $selectedComponent)}
quiet
size="S"
slot="control"
dataCy={`category-${item.name}`}
on:click={() => onItemChosen(item)}
>
<div class="buttonContent">
{item.name}
{#if item.isCategory}
<Icon size="S" name="ChevronDown" />
{/if}
</div>
</ActionButton>
{#each item.children || [] as item}
{#if !item.showOnAsset || item.showOnAsset.includes($currentAssetName)}
<MenuItem
dataCy={`component-${item.name}`}
icon={item.icon}
on:click={() => onItemChosen(item)}
disabled={isChildAllowed(item, $selectedComponent)}
>
{item.name}
</MenuItem>
{/if}
{/each}
</ActionMenu>
{/each}
</div>
<style>
.components {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
}
.components :global(> *) {
height: 32px;
display: grid;
place-items: center;
}
.buttonContent {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-end;
}
.buttonContent :global(svg) {
margin-left: 2px !important;
margin-right: 0 !important;
margin-bottom: -1px;
}
</style>

View File

@ -127,9 +127,6 @@
<Tab title="Screens"> <Tab title="Screens">
<div class="tab-content-padding"> <div class="tab-content-padding">
<BBUILayout noPadding gap="XS" /> <BBUILayout noPadding gap="XS" />
<div class="nav-items-container" bind:this={scrollRef}>
<ComponentNavigationTree />
</div>
</div> </div>
</Tab> </Tab>
<Tab title="Layouts"> <Tab title="Layouts">
@ -190,15 +187,6 @@
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.nav-items-container {
border-top: var(--border-light);
margin: 0 calc(-1 * var(--spacing-xl));
padding: var(--spacing-m) 0;
flex: 1 1 auto;
overflow: auto;
height: 0;
position: relative;
}
.nav-items-container--layouts { .nav-items-container--layouts {
border-top: none; border-top: none;
margin-top: calc(-1 * var(--spectrum-global-dimension-static-size-150)); margin-top: calc(-1 * var(--spectrum-global-dimension-static-size-150));

View File

@ -23,7 +23,9 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="body">
<slot /> <slot />
</div>
</div> </div>
<style> <style>
@ -35,7 +37,6 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
overflow: auto;
} }
.navigation-panel.wide { .navigation-panel.wide {
width: 360px; width: 360px;
@ -78,4 +79,13 @@
.add-button :global(svg) { .add-button :global(svg) {
fill: white; fill: white;
} }
.body {
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style> </style>

View File

@ -64,20 +64,11 @@
{#each paths as path, idx (path)} {#each paths as path, idx (path)}
<PathTree border={idx > 0} {path} route={routes[path]} /> <PathTree border={idx > 0} {path} route={routes[path]} />
{/each} {/each}
{#if !paths.length} {#if !paths.length}{/if}
<div class="empty">
There aren't any screens configured with this access role.
</div>
{/if}
</div> </div>
<style> <style>
.root.has-screens { .root.has-screens {
min-width: max-content; min-width: max-content;
} }
div.empty {
font-size: var(--font-size-s);
color: var(--grey-5);
padding: var(--spacing-xs) var(--spacing-xl);
}
</style> </style>

View File

@ -1,10 +1,5 @@
<script> <script>
import { import { store, selectedComponent, currentAsset } from "builderStore"
store,
selectedComponent,
currentAsset,
screenSearchString,
} from "builderStore"
import instantiateStore from "./dragDropStore" import instantiateStore from "./dragDropStore"
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import PathDropdownMenu from "./PathDropdownMenu.svelte" import PathDropdownMenu from "./PathDropdownMenu.svelte"

View File

@ -12,7 +12,7 @@
$: roleName = $roles.find(x => x._id === roleId)?.name || "Unknown" $: roleName = $roles.find(x => x._id === roleId)?.name || "Unknown"
// Needs to be absolute as we embed this component from multiple different URLs // Needs to be absolute as we embed this component from multiple different URLs
$: newComponentUrl = `/builder/app/${store.appId}/design/components/${$selectedScreen?._id}/new` $: newComponentUrl = `/builder/app/${$store.appId}/design/components/${$selectedScreen?._id}/new`
const getRoleColor = roleId => { const getRoleColor = roleId => {
return RoleColours[roleId] || "#ffa500" return RoleColours[roleId] || "#ffa500"

View File

@ -140,7 +140,10 @@
try { try {
if (type === "select-component" && data.id) { if (type === "select-component" && data.id) {
store.actions.components.select({ _id: data.id }) store.update(state => {
state.selectedComponentId = data.id
return state
})
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateProp(data.prop, data.value) await store.actions.components.updateProp(data.prop, data.value)
} else if (type === "delete-component" && data.id) { } else if (type === "delete-component" && data.id) {

View File

@ -1,9 +1,63 @@
<script> <script>
import NavigationPanel from "components/design/NavigationPanel/NavigationPanel.svelte" import NavigationPanel from "components/design/NavigationPanel/NavigationPanel.svelte"
import ComponentTree from "./ComponentTree.svelte"
import instantiateStore from "./dragDropStore.js"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { Layout, Search } from "@budibase/bbui" import { store, selectedScreen, selectedComponent } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import { setContext } from "svelte"
let searchString const dragDropStore = instantiateStore()
let scrollRef
const scrollTo = bounds => {
if (!bounds) {
return
}
const sidebarWidth = 259
const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {}
// Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 40
if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth
} else {
newOffsets.left = 0
}
if (newOffsets.left === scrollLeft) {
delete newOffsets.left
}
// Calculate top offset
const offsetY = bounds.top - scrollBounds?.top + scrollTop
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
} else if (offsetY < scrollTop + navItemHeight) {
newOffsets.top = offsetY - navItemHeight
} else {
delete newOffsets.top
}
// Skip if offset is unchanged
if (newOffsets.left == null && newOffsets.top == null) {
return
}
// Smoothly scroll to the offset
scrollRef.scroll({
...newOffsets,
behavior: "smooth",
})
}
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
})
</script> </script>
<NavigationPanel <NavigationPanel
@ -11,11 +65,36 @@
showAddButton showAddButton
onClickAddButton={() => $goto("../new")} onClickAddButton={() => $goto("../new")}
> >
<Layout paddingX="L" paddingY="XL" gap="S"> <div class="nav-items-container" bind:this={scrollRef}>
<Search <NavItem
placeholder="Search" text="Screen"
value={searchString} withArrow
on:change={e => (searchString = e.detail)} indentLevel={0}
selected={$store.selectedComponentId === $selectedScreen?.props._id}
opened
scrollable
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
>
<ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem>
<ComponentTree
level={0}
components={$selectedScreen?.props._children}
currentComponent={$selectedComponent}
{dragDropStore}
/> />
</Layout> </div>
</NavigationPanel> </NavigationPanel>
<style>
.nav-items-container {
margin: 0 calc(-1 * var(--spacing-l));
padding: var(--spacing-xl) var(--spacing-l);
flex: 1 1 auto;
overflow: auto;
height: 0;
position: relative;
}
</style>

View File

@ -9,16 +9,11 @@
export let components = [] export let components = []
export let currentComponent export let currentComponent
export let onSelect = () => {}
export let level = 0 export let level = 0
export let dragDropStore export let dragDropStore
let closedNodes = {} let closedNodes = {}
const selectComponent = component => {
store.actions.components.select(component)
}
const dragstart = component => e => { const dragstart = component => e => {
e.dataTransfer.dropEffect = DropEffect.MOVE e.dataTransfer.dropEffect = DropEffect.MOVE
dragDropStore.actions.dragstart(component) dragDropStore.actions.dragstart(component)
@ -86,7 +81,11 @@
<ul> <ul>
{#each components || [] as component, index (component._id)} {#each components || [] as component, index (component._id)}
<li on:click|stopPropagation={() => selectComponent(component)}> <li
on:click|stopPropagation={() => {
$store.selectedComponentId = component._id
}}
>
{#if $dragDropStore?.targetComponent === component && $dragDropStore.dropPosition === DropPosition.ABOVE} {#if $dragDropStore?.targetComponent === component && $dragDropStore.dropPosition === DropPosition.ABOVE}
<div <div
on:drop={onDrop} on:drop={onDrop}
@ -98,6 +97,7 @@
{/if} {/if}
<NavItem <NavItem
scrollable
draggable draggable
on:dragend={dragDropStore.actions.reset} on:dragend={dragDropStore.actions.reset}
on:dragstart={dragstart(component)} on:dragstart={dragstart(component)}
@ -117,7 +117,6 @@
<svelte:self <svelte:self
components={component._children} components={component._children}
{currentComponent} {currentComponent}
{onSelect}
{dragDropStore} {dragDropStore}
level={level + 1} level={level + 1}
/> />

View File

@ -0,0 +1,52 @@
<script>
import { store } from "builderStore"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
export let component
$: definition = store.actions.components.getDefinition(component?._component)
$: noPaste = !$store.componentToPaste
// "editable" has been repurposed for inline text editing.
// It remains here for legacy compatibility.
// Future components should define "static": true for indicate they should
// not show a context menu.
$: showMenu = definition?.editable !== false && definition?.static !== true
const storeComponentForCopy = (cut = false) => {
store.actions.components.copy(component, cut)
}
const pasteComponent = mode => {
try {
store.actions.components.paste(component, mode)
} catch (error) {
notifications.error("Error saving component")
}
}
</script>
{#if showMenu}
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
Copy
</MenuItem>
<MenuItem
icon="ShowOneLayer"
on:click={() => pasteComponent("inside")}
disabled={noPaste}
>
Paste inside
</MenuItem>
</ActionMenu>
{/if}
<style>
.icon {
display: grid;
place-items: center;
}
</style>

View File

@ -0,0 +1,30 @@
<script>
import { syncURLToState } from "helpers/urlStateSync"
import { store, selectedScreen } from "builderStore"
import { goto, params, redirect } from "@roxi/routify"
import { onDestroy } from "svelte"
import { findComponent } from "builderStore/componentUtils"
// Keep URL and state in sync for selected component ID
const stopSyncing = syncURLToState({
keys: [
{
url: "componentId",
state: "selectedComponentId",
validate: componentId => {
return !!findComponent($selectedScreen.props, componentId)
},
fallbackUrl: "../",
},
],
store,
params,
goto,
redirect,
baseUrl: "..",
})
onDestroy(stopSyncing)
</script>
<slot />

View File

@ -18,6 +18,7 @@
params, params,
goto, goto,
redirect, redirect,
baseUrl: "..",
}) })
onDestroy(stopSyncing) onDestroy(stopSyncing)

View File

@ -6,7 +6,7 @@
onMount(() => { onMount(() => {
if ($selectedScreen) { if ($selectedScreen) {
// Select the screen slot if a screen exists // Select the screen slot if a screen exists
$redirect(`./slot`) $redirect(`./${$selectedScreen.props._id}`)
} else { } else {
// Otherwise go up so we can select a new valid screen // Otherwise go up so we can select a new valid screen
$redirect("../") $redirect("../")

View File

@ -10,6 +10,7 @@
Icon, Icon,
Body, Body,
Divider, Divider,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import structure from "./componentStructure.json" import structure from "./componentStructure.json"
import { store } from "builderStore" import { store } from "builderStore"
@ -75,7 +76,20 @@
return structure return structure
} }
const addComponent = () => {} const isChildAllowed = ({ name }, selectedComponent) => {
const currentComponent = store.actions.components.getDefinition(
selectedComponent?._component
)
return currentComponent?.illegalChildren?.includes(name.toLowerCase())
}
const addComponent = async item => {
try {
await store.actions.components.create(item.component)
} catch (error) {
notifications.error("Error creating component")
}
}
</script> </script>
<NavigationPanel <NavigationPanel
@ -125,7 +139,7 @@
{/each} {/each}
{:else} {:else}
<Layout paddingX="L" paddingY="XL" gap="S"> <Layout paddingX="L" paddingY="XL" gap="S">
<Body size="S">Blocks are a collection of pre-built components</Body> <Body>Blocks are a collection of pre-built components</Body>
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
{#each blocks as block} {#each blocks as block}
<div class="component block"> <div class="component block">

View File

@ -0,0 +1,10 @@
<script>
import SettingsPanel from "components/design/SettingsPanel/SettingsPanel.svelte"
import { Body, Layout } from "@budibase/bbui"
</script>
<SettingsPanel title="Screen" icon="WebPage">
<Layout paddingX="L" paddingY="XL">
<Body>The component you add will be placed inside Screen</Body>
</Layout>
</SettingsPanel>

View File

@ -1,5 +1,7 @@
<script> <script>
import ComponentListPanel from "./_components/ComponentListPanel.svelte" import NewComponentPanel from "./_components/NewComponentPanel.svelte"
import NewComponentTargetPanel from "./_components/NewComponentTargetPanel.svelte"
</script> </script>
<ComponentListPanel /> <NewComponentPanel />
<NewComponentTargetPanel />

View File

@ -67,6 +67,19 @@
<ScreenDropdownMenu screenId={screen._id} /> <ScreenDropdownMenu screenId={screen._id} />
</NavItem> </NavItem>
{/each} {/each}
{#if !filteredScreens?.length}
<div class="empty">
There aren't any screens matching the current filters
</div>
{/if}
</NavigationPanel> </NavigationPanel>
<ScreenWizard bind:showModal={showNewScreenModal} /> <ScreenWizard bind:showModal={showNewScreenModal} />
<style>
.empty {
font-size: var(--spectrum-global-dimension-font-size-75);
color: var(--grey-7);
padding: 0 var(--spacing-l);
}
</style>

View File

@ -6,9 +6,7 @@
<NavigationPanel title="Theme"> <NavigationPanel title="Theme">
<Layout paddingX="L" paddingY="XL" gap="S"> <Layout paddingX="L" paddingY="XL" gap="S">
<Body size="S"> <Body>Your theme is set across all the screens within your app</Body>
Your theme is set across all the screens within your app
</Body>
<ThemeEditor /> <ThemeEditor />
</Layout> </Layout>
</NavigationPanel> </NavigationPanel>