Merge pull request #868 from Budibase/routing-ui

Routing ui
This commit is contained in:
Martin McKeaveney 2020-11-20 10:17:18 +00:00 committed by GitHub
commit e369cbc6b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 337 additions and 297 deletions

View File

@ -31,6 +31,12 @@ export const currentScreens = derived(store, $store => {
: Object.values(currentScreens)
})
export const selectedPage = derived(store, $store => {
if (!$store.pages) return null
return $store.pages[$store.currentPageName || "main"]
})
export const initialise = async () => {
try {
await analytics.activate()

View File

@ -5,8 +5,7 @@ import {
getBuiltin,
makePropsSafe,
} from "components/userInterface/pagesParsing/createProps"
import { getExactComponent } from "components/userInterface/pagesParsing/searchComponents"
import { allScreens, backendUiStore } from "builderStore"
import { allScreens, backendUiStore, selectedPage } from "builderStore"
import { generate_screen_css } from "../generate_css"
import { fetchComponentLibDefinitions } from "../loadComponentLibraries"
import api from "../api"
@ -37,6 +36,7 @@ const INITIAL_FRONTEND_STATE = {
hasAppPackage: false,
libraries: null,
appId: "",
routes: {},
}
export const getFrontendStore = () => {
@ -111,10 +111,9 @@ export const getFrontendStore = () => {
store.update(state => {
state.currentFrontEndType = type
const pageOrScreen =
type === "page"
? state.pages[state.currentPageName]
: state.pages[state.currentPageName]._screens[0]
const page = get(selectedPage)
const pageOrScreen = type === "page" ? page : page._screens[0]
state.currentComponentInfo = pageOrScreen ? pageOrScreen.props : null
state.currentPreviewItem = pageOrScreen
@ -122,10 +121,21 @@ export const getFrontendStore = () => {
return state
})
},
screens: {
select: screenName => {
routing: {
fetch: async () => {
const response = await api.get("/api/routing")
const json = await response.json()
store.update(state => {
const screen = getExactComponent(get(allScreens), screenName, true)
state.routes = json.routes
return state
})
},
},
screens: {
select: screenId => {
store.update(state => {
const screen = get(allScreens).find(screen => screen._id === screenId)
state.currentPreviewItem = screen
state.currentFrontEndType = "screen"
state.currentView = "detail"
@ -158,32 +168,25 @@ export const getFrontendStore = () => {
await savePromise
},
save: async screen => {
const storeContents = get(store)
const pageName = storeContents.currentPageName || "main"
const currentPage = storeContents.pages[pageName]
const currentPageScreens = currentPage._screens
const page = get(selectedPage)
const currentPageScreens = page._screens
const creatingNewScreen = screen._id === undefined
let savePromise
const response = await api.post(
`/api/screens/${currentPage._id}`,
screen
)
const response = await api.post(`/api/screens/${page._id}`, screen)
const json = await response.json()
screen._rev = json.rev
screen._id = json.id
const foundScreen = currentPageScreens.findIndex(
el => el._id === screen._id
)
const foundScreen = page._screens.findIndex(el => el._id === screen._id)
if (foundScreen !== -1) {
currentPageScreens.splice(foundScreen, 1)
page._screens.splice(foundScreen, 1)
}
currentPageScreens.push(screen)
page._screens.push(screen)
// TODO: should carry out all server updates to screen in a single call
store.update(state => {
state.pages[pageName]._screens = currentPageScreens
page._screens = currentPageScreens
if (creatingNewScreen) {
state.currentPreviewItem = screen
@ -209,21 +212,21 @@ export const getFrontendStore = () => {
store.actions.screens.regenerateCss(currentPreviewItem)
}
},
delete: async (screensToDelete, pageName) => {
delete: async screens => {
let deletePromise
const screensToDelete = Array.isArray(screens) ? screens : [screens]
store.update(state => {
if (pageName == null) {
pageName = state.pages.main.name
}
for (let screenToDelete of Array.isArray(screensToDelete)
? screensToDelete
: [screensToDelete]) {
const currentPage = get(selectedPage)
for (let screenToDelete of screensToDelete) {
// Remove screen from current page as well
// TODO: Should be done server side
state.pages[pageName]._screens = state.pages[
pageName
]._screens.filter(scr => scr._id !== screenToDelete._id)
currentPage._screens = currentPage._screens.filter(
scr => scr._id !== screenToDelete._id
)
deletePromise = api.delete(
`/api/screens/${screenToDelete._id}/${screenToDelete._rev}`
)
@ -309,14 +312,13 @@ export const getFrontendStore = () => {
create: (componentToAdd, presetProps) => {
store.update(state => {
function findSlot(component_array) {
for (let i = 0; i < component_array.length; i += 1) {
if (component_array[i]._component === "##builtin/screenslot") {
for (let component of component_array) {
if (component._component === "##builtin/screenslot") {
return true
}
if (component_array[i]._children) findSlot(component_array[i])
if (component._children) findSlot(component)
}
return false
}
@ -377,7 +379,7 @@ export const getFrontendStore = () => {
component._id
)
parent._children = parent._children.filter(
c => c._id !== component._id
child => child._id !== component._id
)
store.actions.components.select(parent)
}

View File

@ -0,0 +1,113 @@
<script>
import { goto } from "@sveltech/routify"
import { store } from "builderStore"
import { getComponentDefinition } from "builderStore/storeUtils"
import { DropEffect, DropPosition } from "./dragDropStore"
import ComponentDropdownMenu from "../ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte"
export let components = []
export let currentComponent
export let onSelect = () => {}
export let level = 0
export let dragDropStore
const isScreenslot = name => name === "##builtin/screenslot"
const selectComponent = component => {
// Set current component
store.actions.components.select(component)
// Get ID path
const path = store.actions.components.findRoute(component)
// Go to correct URL
$goto(`./:page/:screen/${path}`)
}
const dragstart = component => e => {
e.dataTransfer.dropEffect = DropEffect.MOVE
dragDropStore.actions.dragstart(component)
}
const dragover = (component, index) => e => {
const canHaveChildrenButIsEmpty =
getComponentDefinition($store, component._component).children &&
component._children.length === 0
e.dataTransfer.dropEffect = DropEffect.COPY
// how far down the mouse pointer is on the drop target
const mousePosition = e.offsetY / e.currentTarget.offsetHeight
dragDropStore.actions.dragover({
component,
index,
canHaveChildrenButIsEmpty,
mousePosition,
})
return false
}
</script>
<ul>
{#each components as component, index (component._id)}
<li on:click|stopPropagation={() => selectComponent(component)}>
{#if $dragDropStore?.targetComponent === component && $dragDropStore.dropPosition === DropPosition.ABOVE}
<div
on:drop={dragDropStore.actions.drop}
ondragover="return false"
ondragenter="return false"
class="drop-item"
style="margin-left: {(level + 1) * 16}px" />
{/if}
<NavItem
draggable
on:dragend={dragDropStore.actions.reset}
on:dragstart={dragstart(component)}
on:dragover={dragover(component, index)}
on:drop={dragDropStore.actions.drop}
text={isScreenslot(component._component) ? 'Screenslot' : component._instanceName}
withArrow
indentLevel={level + 3}
selected={currentComponent === component}>
<ComponentDropdownMenu {component} />
</NavItem>
{#if component._children}
<svelte:self
components={component._children}
{currentComponent}
{onSelect}
{dragDropStore}
level={level + 1} />
{/if}
{#if $dragDropStore?.targetComponent === component && ($dragDropStore.dropPosition === DropPosition.INSIDE || $dragDropStore.dropPosition === DropPosition.BELOW)}
<div
on:drop={dragDropStore.actions.drop}
ondragover="return false"
ondragenter="return false"
class="drop-item"
style="margin-left: {(level + ($dragDropStore.dropPosition === DropPosition.INSIDE ? 3 : 1)) * 16}px" />
{/if}
</li>
{/each}
</ul>
<style>
ul {
list-style: none;
padding-left: 0;
margin: 0;
}
.drop-item {
border-radius: var(--border-radius-m);
height: 32px;
background: var(--grey-3);
}
</style>

View File

@ -0,0 +1,51 @@
<script>
import { writable } from "svelte/store"
import { goto } from "@sveltech/routify"
import { store } from "builderStore"
import instantiateStore from "./dragDropStore"
import ComponentsTree from "./ComponentTree.svelte"
import NavItem from "components/common/NavItem.svelte"
import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte"
const dragDropStore = instantiateStore()
export let route
export let path
export let indent
$: selectedScreen = $store.currentPreviewItem
const changeScreen = screenId => {
// select the route
store.actions.screens.select(screenId)
$goto(`./:page/${screenId}`)
}
</script>
<NavItem
icon="ri-folder-line"
text={path}
opened={true}
withArrow={route.subpaths} />
{#each Object.entries(route.subpaths) as [url, subpath]}
{#each Object.values(subpath.screens) as screenId}
<NavItem
icon="ri-artboard-2-line"
indentLevel={indent || 1}
selected={$store.currentPreviewItem._id === screenId}
opened={$store.currentPreviewItem._id === screenId}
text={url === "/" ? "Home" : url}
withArrow={route.subpaths}
on:click={() => changeScreen(screenId)}>
<ScreenDropdownMenu screen={screenId} />
</NavItem>
{#if selectedScreen?._id === screenId}
<ComponentsTree
components={selectedScreen.props._children}
currentComponent={$store.currentComponentInfo}
{dragDropStore} />
{/if}
{/each}
{/each}

View File

@ -1,6 +1,6 @@
<script>
import { goto } from "@sveltech/routify"
import { store } from "builderStore"
import { store, allScreens } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { DropdownMenu } from "@budibase/bbui"
@ -13,12 +13,14 @@
let anchor
const deleteScreen = () => {
store.actions.screens.delete(screen, $store.currentPageName)
const screenToDelete = $allScreens.find(scr => scr._id === screen)
store.actions.screens.delete(screenToDelete)
store.actions.routing.fetch()
// update the page if required
store.update(state => {
if (state.currentPreviewItem.name === screen.name) {
if (state.currentPreviewItem._id === screen) {
store.actions.pages.select($store.currentPageName)
notifier.success(`Screen ${screen.name} deleted successfully.`)
notifier.success(`Screen ${screenToDelete.name} deleted successfully.`)
$goto(`./:page/page-layout`)
}
return state
@ -42,7 +44,7 @@
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you wish to delete the screen '${screen.props._instanceName}' ?`}
body={'Are you sure you wish to delete this screen?'}
okText="Delete Screen"
onOk={deleteScreen} />

View File

@ -0,0 +1,92 @@
import { writable } from "svelte/store"
import { store as frontendStore } from "builderStore"
export const DropEffect = {
MOVE: "move",
COPY: "copy",
}
export const DropPosition = {
ABOVE: "above",
BELOW: "below",
INSIDE: "inside",
}
export default function() {
const store = writable({})
store.actions = {
dragstart: component => {
store.update(state => {
state.dragged = component
return state
})
},
dragover: ({
component,
index,
canHaveChildrenButIsEmpty,
mousePosition,
}) => {
store.update(state => {
state.targetComponent = component
// only allow dropping inside when container is empty
// if container has children, drag over them
if (canHaveChildrenButIsEmpty && index === 0) {
// hovered above center of target
if (mousePosition < 0.4) {
state.dropPosition = DropPosition.ABOVE
}
// hovered around bottom of target
if (mousePosition > 0.8) {
state.dropPosition = DropPosition.BELOW
}
// hovered in center of target
if (mousePosition > 0.4 && mousePosition < 0.8) {
state.dropPosition = DropPosition.INSIDE
}
return
}
// bottom half
if (mousePosition > 0.5) {
state.dropPosition = DropPosition.BELOW
} else {
state.dropPosition = canHaveChildrenButIsEmpty
? DropPosition.INSIDE
: DropPosition.ABOVE
}
return state
})
},
reset: () => {
store.update(state => {
state.dropPosition = ""
state.targetComponent = null
state.dragged = null
return state
})
},
drop: () => {
store.update(state => {
if (state.targetComponent !== state.dragged) {
frontendStore.actions.components.copy(state.dragged, true)
frontendStore.actions.components.paste(
state.targetComponent,
state.dropPosition
)
}
store.actions.reset()
return state
})
},
}
return store
}

View File

@ -0,0 +1,11 @@
<script>
import { goto } from "@sveltech/routify"
import { store } from "builderStore"
import PathTree from "./PathTree.svelte"
</script>
<div class="root">
{#each Object.keys($store.routes) as path}
<PathTree {path} route={$store.routes[path]} />
{/each}
</div>

View File

@ -1,61 +0,0 @@
<script>
import { goto } from "@sveltech/routify"
import ComponentsHierarchyChildren from "./ComponentsHierarchyChildren.svelte"
import { trimCharsStart, trimChars } from "lodash/fp"
import { pipe } from "../../helpers"
import { store } from "builderStore"
import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte"
import { writable } from "svelte/store"
import NavItem from "components/common/NavItem.svelte"
export let screens = []
$: sortedScreens = screens.sort((s1, s2) => {
const name1 = s1.props._instanceName?.toLowerCase() ?? ""
const name2 = s2.props._instanceName?.toLowerCase() ?? ""
return name1 > name2 ? 1 : -1
})
/*
Using a store here seems odd....
have a look in the <ComponentsHierarchyChildren /> code file to find out why.
I have commented the dragDropStore parameter
*/
const dragDropStore = writable({})
let confirmDeleteDialog
let componentToDelete = ""
const normalizedName = name =>
pipe(name, [
trimCharsStart("./"),
trimCharsStart("~/"),
trimCharsStart("../"),
trimChars(" "),
])
const changeScreen = screen => {
store.actions.screens.select(screen.props._instanceName)
$goto(`./:page/${screen.props._instanceName}`)
}
</script>
<div class="root">
{#each sortedScreens as screen}
<NavItem
icon="ri-artboard-2-line"
text={screen.props._instanceName}
withArrow={screen.props._children.length}
selected={$store.currentComponentInfo._id === screen.props._id}
opened={$store.currentPreviewItem.name === screen.props._id}
on:click={() => changeScreen(screen)}>
<ScreenDropdownMenu {screen} />
</NavItem>
{#if $store.currentPreviewItem.props._instanceName && $store.currentPreviewItem.props._instanceName === screen.props._instanceName && screen.props._children}
<ComponentsHierarchyChildren
components={screen.props._children}
currentComponent={$store.currentComponentInfo}
{dragDropStore} />
{/if}
{/each}
</div>

View File

@ -1,181 +0,0 @@
<script>
import { goto } from "@sveltech/routify"
import { store } from "builderStore"
import { last } from "lodash/fp"
import { pipe } from "../../helpers"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte"
import { getComponentDefinition } from "builderStore/storeUtils"
export let components = []
export let currentComponent
export let onSelect = () => {}
export let level = 0
/*
"dragDropStore" is a svelte store.
This component is recursive... a tree.
Using a single, shared store, all the nodes in the tree can subscribe to state that is changed by other nodes, in the same tree.
e.g. Say i have the structure
- Heading 1
- Container
- Heading 2
- Heading 3
- Heading 4
1. When I dragover "Heading 1", a placeholder drop-slot appears below it
2. I drag down a bit so the cursor is INSIDE the container (i.e. now in a child <ComponentsHierarchyChildren />)
3. Using store subscribes... the original drop-slot now knows that it should disappear, and a new one is created inside the container.
*/
export let dragDropStore
let dropUnderComponent
let componentToDrop
const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
const get_name = s => (!s ? "" : last(s.split("/")))
const get_capitalised_name = name => pipe(name, [get_name, capitalise])
const isScreenslot = name => name === "##builtin/screenslot"
const selectComponent = component => {
// Set current component
store.actions.components.select(component)
// Get ID path
const path = store.actions.components.findRoute(component)
// Go to correct URL
$goto(`./:page/:screen/${path}`)
}
const dragstart = component => e => {
e.dataTransfer.dropEffect = "move"
dragDropStore.update(s => {
s.componentToDrop = component
return s
})
}
const dragover = (component, index) => e => {
const canHaveChildrenButIsEmpty =
getComponentDefinition($store, component._component).children &&
component._children.length === 0
e.dataTransfer.dropEffect = "copy"
dragDropStore.update(s => {
const isBottomHalf = e.offsetY > e.currentTarget.offsetHeight / 2
s.targetComponent = component
// only allow dropping inside when container type
// is empty. If it has children, the user can drag over
// it's existing children
if (canHaveChildrenButIsEmpty) {
if (index === 0) {
// when its the first component in the screen,
// we divide into 3, so we can paste above, inside or below
const pos = e.offsetY / e.currentTarget.offsetHeight
if (pos < 0.4) {
s.dropPosition = "above"
} else if (pos > 0.8) {
// purposely giving this the least space as it is often covered
// by the component below's "above" space
s.dropPosition = "below"
} else {
s.dropPosition = "inside"
}
} else {
s.dropPosition = isBottomHalf ? "below" : "inside"
}
} else {
s.dropPosition = isBottomHalf ? "below" : "above"
}
return s
})
return false
}
const drop = () => {
if ($dragDropStore.targetComponent !== $dragDropStore.componentToDrop) {
store.actions.components.copy($dragDropStore.componentToDrop, true)
store.actions.components.paste(
$dragDropStore.targetComponent,
$dragDropStore.dropPosition
)
}
dragDropStore.update(s => {
s.dropPosition = ""
s.targetComponent = null
s.componentToDrop = null
return s
})
}
const dragend = () => {
dragDropStore.update(s => {
s.dropPosition = ""
s.targetComponent = null
s.componentToDrop = null
return s
})
}
</script>
<ul>
{#each components as component, index (component._id)}
<li on:click|stopPropagation={() => selectComponent(component)}>
{#if $dragDropStore && $dragDropStore.targetComponent === component && $dragDropStore.dropPosition === 'above'}
<div
on:drop={drop}
ondragover="return false"
ondragenter="return false"
class="drop-item"
style="margin-left: {(level + 1) * 16}px" />
{/if}
<NavItem
draggable
on:dragend={dragend}
on:dragstart={dragstart(component)}
on:dragover={dragover(component, index)}
on:drop={drop}
text={isScreenslot(component._component) ? 'Screenslot' : component._instanceName}
withArrow
indentLevel={level + 1}
selected={currentComponent === component}>
<ComponentDropdownMenu {component} />
</NavItem>
{#if component._children}
<svelte:self
components={component._children}
{currentComponent}
{onSelect}
{dragDropStore}
level={level + 1} />
{/if}
{#if $dragDropStore && $dragDropStore.targetComponent === component && ($dragDropStore.dropPosition === 'inside' || $dragDropStore.dropPosition === 'below')}
<div
on:drop={drop}
ondragover="return false"
ondragenter="return false"
class="drop-item"
style="margin-left: {(level + ($dragDropStore.dropPosition === 'inside' ? 3 : 1)) * 16}px" />
{/if}
</li>
{/each}
</ul>
<style>
ul {
list-style: none;
padding-left: 0;
margin: 0;
}
.drop-item {
border-radius: var(--border-radius-m);
height: 32px;
background: var(--grey-3);
}
</style>

View File

@ -1,12 +1,20 @@
<script>
import { onMount } from "svelte"
import { store, currentScreens } from "builderStore"
import ComponentsHierarchy from "components/userInterface/ComponentsHierarchy.svelte"
import api from "builderStore/api"
import ComponentNavigationTree from "components/userInterface/ComponentNavigationTree/index.svelte"
import PageLayout from "components/userInterface/PageLayout.svelte"
import PagesList from "components/userInterface/PagesList.svelte"
import NewScreenModal from "components/userInterface/NewScreenModal.svelte"
import { Modal } from "@budibase/bbui"
let modal
let routes = {}
onMount(() => {
store.actions.routing.fetch()
})
</script>
<div class="title">
@ -16,7 +24,7 @@
<PagesList />
<div class="nav-items-container">
<PageLayout layout={$store.pages[$store.currentPageName]} />
<ComponentsHierarchy screens={$currentScreens} />
<ComponentNavigationTree />
</div>
<Modal bind:this={modal}>
<NewScreenModal />

View File

@ -1,6 +1,6 @@
<script>
import { goto } from "@sveltech/routify"
import ComponentsHierarchyChildren from "./ComponentsHierarchyChildren.svelte"
import ComponentTree from "./ComponentNavigationTree/ComponentTree.svelte"
import NavItem from "components/common/NavItem.svelte"
import { last } from "lodash/fp"
import { store } from "builderStore"
@ -37,8 +37,7 @@
on:click={setCurrentScreenToLayout} />
{#if $store.currentPreviewItem?.name === _layout.title && _layout.component.props._children}
<ComponentsHierarchyChildren
thisComponent={_layout.component.props}
<ComponentTree
components={_layout.component.props._children}
currentComponent={$store.currentComponentInfo}
{dragDropStore} />

View File

@ -10,9 +10,7 @@
if ($params.screen !== "page-layout") {
const currentScreenName = decodeURI($params.screen)
const validScreen =
$allScreens.findIndex(
screen => screen.props._instanceName === currentScreenName
) !== -1
$allScreens.findIndex(screen => screen._id === currentScreenName) !== -1
if (!validScreen) {
// Go to main layout if URL set to invalid screen
@ -27,8 +25,8 @@
// Get the correct screen children.
const screenChildren = $store.pages[$params.page]._screens.find(
screen =>
screen.props._instanceName === $params.screen ||
screen.props._instanceName === decodeURIComponent($params.screen)
screen._id === $params.screen ||
screen._id === decodeURIComponent($params.screen)
).props._children
findComponent(componentIds, screenChildren)
}

View File

@ -55,7 +55,7 @@
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^4.0.4",
"svelte": "3.23.x",
"svelte": "^3.29.7",
"svelte-jester": "^1.0.6"
},
"gitHead": "e4e053cb6ff9a0ddc7115b44ccaa24b8ec41fb9a"

View File

@ -43,7 +43,7 @@ export const screenRouter = ({ screens, onScreenSelected, window }) => {
return sanitize(url)
}
const routes = screens.map(s => makeRootedPath(s.routing?.route))
const routes = screens.map(screen => makeRootedPath(screen?.routing.route))
let fallback = routes.findIndex(([p]) => p === makeRootedPath("*"))
if (fallback < 0) fallback = 0