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 c46501709b
commit 53f3a4755b
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 { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
import { LAYOUT_NAMES } from "../constants"
import { findComponent, findComponentPath } from "./componentUtils"
export const store = getFrontendStore()
@ -13,32 +13,26 @@ export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
})
export const currentAsset = selectedScreen
export const selectedComponent = derived(
[store, currentAsset],
([$store, $currentAsset]) => {
if (!$currentAsset || !$store.selectedComponentId) {
[store, selectedScreen],
([$store, $selectedScreen]) => {
if (!$selectedScreen || !$store.selectedComponentId) {
return null
}
return findComponent($currentAsset?.props, $store.selectedComponentId)
return findComponent($selectedScreen?.props, $store.selectedComponentId)
}
)
export const selectedComponentPath = derived(
[store, currentAsset],
([$store, $currentAsset]) => {
[store, selectedScreen],
([$store, $selectedScreen]) => {
return findComponentPath(
$currentAsset?.props,
$selectedScreen?.props,
$store.selectedComponentId
).map(component => component._id)
}
)
export const currentAssetName = derived(currentAsset, $currentAsset => {
return $currentAsset?.name
})
export const mainLayout = derived(store, $store => {
return $store.layouts?.find(
layout => layout._id === LAYOUT_NAMES.MASTER.PRIVATE
@ -47,4 +41,5 @@ export const mainLayout = derived(store, $store => {
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",
selectedLayoutId: "",
selectedComponentId: "",
errors: [],
hasAppPackage: false,
libraries: null,
@ -61,6 +60,7 @@ const INITIAL_FRONTEND_STATE = {
// URL params
selectedScreenId: null,
selectedComponentId: null,
}
export const getFrontendStore = () => {
@ -300,32 +300,6 @@ export const getFrontendStore = () => {
},
},
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 => {
if (!componentName) {
return null
@ -464,7 +438,10 @@ export const getFrontendStore = () => {
parent._children = parent._children.filter(
child => child._id !== component._id
)
store.actions.components.select(parent)
store.update(state => {
state.selectedComponentId = parent._id
return state
})
}
await store.actions.preview.saveSelected()
},
@ -488,7 +465,10 @@ export const getFrontendStore = () => {
parent._children = parent._children.filter(
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
promises.push(store.actions.preview.saveSelected())
store.actions.components.select(componentToPaste)
state.selectedComponentId = componentToPaste._id
return state
})
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">
<div class="tab-content-padding">
<BBUILayout noPadding gap="XS" />
<div class="nav-items-container" bind:this={scrollRef}>
<ComponentNavigationTree />
</div>
</div>
</Tab>
<Tab title="Layouts">
@ -190,15 +187,6 @@
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 {
border-top: none;
margin-top: calc(-1 * var(--spectrum-global-dimension-static-size-150));

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@
$: roleName = $roles.find(x => x._id === roleId)?.name || "Unknown"
// 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 => {
return RoleColours[roleId] || "#ffa500"

View File

@ -140,7 +140,10 @@
try {
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") {
await store.actions.components.updateProp(data.prop, data.value)
} else if (type === "delete-component" && data.id) {

View File

@ -1,9 +1,63 @@
<script>
import NavigationPanel from "components/design/NavigationPanel/NavigationPanel.svelte"
import ComponentTree from "./ComponentTree.svelte"
import instantiateStore from "./dragDropStore.js"
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>
<NavigationPanel
@ -11,11 +65,36 @@
showAddButton
onClickAddButton={() => $goto("../new")}
>
<Layout paddingX="L" paddingY="XL" gap="S">
<Search
placeholder="Search"
value={searchString}
on:change={e => (searchString = e.detail)}
<div class="nav-items-container" bind:this={scrollRef}>
<NavItem
text="Screen"
withArrow
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>
<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 currentComponent
export let onSelect = () => {}
export let level = 0
export let dragDropStore
let closedNodes = {}
const selectComponent = component => {
store.actions.components.select(component)
}
const dragstart = component => e => {
e.dataTransfer.dropEffect = DropEffect.MOVE
dragDropStore.actions.dragstart(component)
@ -86,7 +81,11 @@
<ul>
{#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}
<div
on:drop={onDrop}
@ -98,6 +97,7 @@
{/if}
<NavItem
scrollable
draggable
on:dragend={dragDropStore.actions.reset}
on:dragstart={dragstart(component)}
@ -117,7 +117,6 @@
<svelte:self
components={component._children}
{currentComponent}
{onSelect}
{dragDropStore}
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,
goto,
redirect,
baseUrl: "..",
})
onDestroy(stopSyncing)

View File

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

View File

@ -10,6 +10,7 @@
Icon,
Body,
Divider,
notifications,
} from "@budibase/bbui"
import structure from "./componentStructure.json"
import { store } from "builderStore"
@ -75,7 +76,20 @@
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>
<NavigationPanel
@ -125,7 +139,7 @@
{/each}
{:else}
<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">
{#each blocks as 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>
import ComponentListPanel from "./_components/ComponentListPanel.svelte"
import NewComponentPanel from "./_components/NewComponentPanel.svelte"
import NewComponentTargetPanel from "./_components/NewComponentTargetPanel.svelte"
</script>
<ComponentListPanel />
<NewComponentPanel />
<NewComponentTargetPanel />

View File

@ -67,6 +67,19 @@
<ScreenDropdownMenu screenId={screen._id} />
</NavItem>
{/each}
{#if !filteredScreens?.length}
<div class="empty">
There aren't any screens matching the current filters
</div>
{/if}
</NavigationPanel>
<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">
<Layout paddingX="L" paddingY="XL" gap="S">
<Body size="S">
Your theme is set across all the screens within your app
</Body>
<Body>Your theme is set across all the screens within your app</Body>
<ThemeEditor />
</Layout>
</NavigationPanel>