Add fade screen transition and fix navigation component casing

This commit is contained in:
Andrew Kingston 2020-11-25 18:30:09 +00:00
parent dc010cd769
commit 07229858c8
23 changed files with 30 additions and 821 deletions

View File

@ -479,7 +479,7 @@ export const getFrontendStore = () => {
// Try to extract a nav component from the master screen
const nav = findChildComponentType(
state.pages.main,
"@budibase/standard-components/Navigation"
"@budibase/standard-components/navigation"
)
if (nav) {
let newLink

View File

@ -1,11 +1,11 @@
<script>
import { TextButton, Body, DropdownMenu, ModalContent } from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import { EVENT_TYPE_MEMBER_NAME } from "../../../../../client/src/old/state/eventHandlers"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
const eventTypeKey = "##eventHandlerType"
export let event
@ -18,8 +18,7 @@
$: actions = event || []
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME])
.component
actionTypes.find(t => t.name === selectedAction[eventTypeKey]).component
const updateEventHandler = (updatedHandler, index) => {
actions[index] = updatedHandler
@ -33,7 +32,7 @@
const addAction = actionType => () => {
const newAction = {
parameters: {},
[EVENT_TYPE_MEMBER_NAME]: actionType.name,
[eventTypeKey]: actionType.name,
}
actions.push(newAction)
selectedAction = newAction
@ -79,7 +78,7 @@
{#each actions as action, index}
<div class="action-container">
<div class="action-header" on:click={selectAction(action)}>
<Body small lh>{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]}</Body>
<Body small lh>{index + 1}. {action[eventTypeKey]}</Body>
<div class="row-expander" class:rotate={action !== selectedAction}>
<ArrowDownIcon />
</div>

View File

@ -1175,7 +1175,7 @@ export default {
},
{
name: "Nav Bar",
_component: "@budibase/standard-components/Navigation",
_component: "@budibase/standard-components/navigation",
description:
"A component for handling the navigation within your app.",
icon: "ri-navigation-line",

View File

@ -30,3 +30,9 @@
<Router on:routeLoading={onRouteLoading} routes={routerConfig} />
</div>
{/if}
<style>
div {
position: relative;
}
</style>

View File

@ -1,4 +1,5 @@
<script>
import { fade } from "svelte/transition"
import { screenStore, routeStore } from "../store"
import Component from "./Component.svelte"
@ -17,5 +18,18 @@
</script>
{#each screens as screen (screen._id)}
<Component definition={screen} />
<div in:fade>
<Component definition={screen} />
</div>
{/each}
<style>
div {
flex: 1 1 auto;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -1,88 +0,0 @@
import { attachChildren } from "./render/attachChildren"
import { createTreeNode } from "./render/prepareRenderComponent"
import { screenRouter } from "./render/screenRouter"
import { createStateManager } from "./state/stateManager"
import { getAppId } from "@budibase/component-sdk"
export const createApp = ({
componentLibraries,
frontendDefinition,
window,
}) => {
let routeTo
let currentUrl
let screenStateManager
const onScreenSlotRendered = screenSlotNode => {
const onScreenSelected = (screen, url) => {
const stateManager = createStateManager({
componentLibraries,
onScreenSlotRendered: () => {},
routeTo,
})
const getAttachChildrenParams = attachChildrenParams(stateManager)
screenSlotNode.props._children = [screen.props]
const initialiseChildParams = getAttachChildrenParams(screenSlotNode)
attachChildren(initialiseChildParams)(screenSlotNode.rootElement, {
hydrate: true,
force: true,
})
if (screenStateManager) screenStateManager.destroy()
screenStateManager = stateManager
currentUrl = url
}
routeTo = screenRouter({
screens: frontendDefinition.screens,
onScreenSelected,
window,
})
const fallbackPath = window.location.pathname.replace(getAppId(), "")
routeTo(currentUrl || fallbackPath)
}
const attachChildrenParams = stateManager => {
const getInitialiseParams = treeNode => ({
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState: stateManager.setup,
})
return getInitialiseParams
}
let rootTreeNode
const pageStateManager = createStateManager({
componentLibraries,
onScreenSlotRendered,
// seems weird, but the routeTo variable may not be available at this point
routeTo: url => routeTo(url),
})
const initialisePage = (page, target, urlPath) => {
currentUrl = urlPath
rootTreeNode = createTreeNode()
rootTreeNode.props = {
_children: [page.props],
}
const getInitialiseParams = attachChildrenParams(pageStateManager)
const initChildParams = getInitialiseParams(rootTreeNode)
attachChildren(initChildParams)(target, {
hydrate: true,
force: true,
})
return rootTreeNode
}
return {
initialisePage,
screenStore: () => screenStateManager.store,
pageStore: () => pageStateManager.store,
routeTo: () => routeTo,
rootNode: () => rootTreeNode,
}
}

View File

@ -1,59 +0,0 @@
import { createApp } from "./createApp"
import { builtins, builtinLibName } from "./render/builtinComponents"
import { getAppId } from "@budibase/component-sdk"
/**
* create a web application from static budibase definition files.
* @param {object} opts - configuration options for budibase client libary
*/
export const loadBudibase = async opts => {
const _window = (opts && opts.window) || window
// const _localStorage = (opts && opts.localStorage) || localStorage
const appId = getAppId(window.document.cookie)
const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"]
const user = {}
const componentLibraryModules = (opts && opts.componentLibraries) || {}
const libraries = frontendDefinition.libraries || []
for (let library of libraries) {
// fetch the JavaScript for the component libraries from the server
componentLibraryModules[library] = await import(
`/componentlibrary?library=${encodeURI(library)}&appId=${appId}`
)
}
componentLibraryModules[builtinLibName] = builtins(_window)
const {
initialisePage,
screenStore,
pageStore,
routeTo,
rootNode,
} = createApp({
componentLibraries: componentLibraryModules,
frontendDefinition,
user,
window: _window,
})
const route = _window.location
? _window.location.pathname.replace(`${appId}/`, "").replace(appId, "")
: ""
initialisePage(frontendDefinition.page, _window.document.body, route)
return {
screenStore,
pageStore,
routeTo,
rootNode,
}
}
if (window) {
window.loadBudibase = loadBudibase
}

View File

@ -1,138 +0,0 @@
import { prepareRenderComponent } from "./prepareRenderComponent"
import { isScreenSlot } from "./builtinComponents"
import deepEqual from "deep-equal"
import appStore from "../state/store"
export const attachChildren = initialiseOpts => (htmlElement, options) => {
const {
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState,
} = initialiseOpts
const anchor = options && options.anchor ? options.anchor : null
const force = options ? options.force : false
const hydrate = options ? options.hydrate : true
const context = options && options.context
if (!force && treeNode.children.length > 0) return treeNode.children
for (let childNode of treeNode.children) {
childNode.destroy()
}
if (!htmlElement) return
if (hydrate) {
while (htmlElement.firstChild) {
htmlElement.removeChild(htmlElement.firstChild)
}
}
const contextStoreKeys = []
// create new context if supplied
if (context) {
let childIndex = 0
// if context is an array, map to new structure
const contextArray = Array.isArray(context) ? context : [context]
for (let ctx of contextArray) {
const key = appStore.create(
ctx,
treeNode.props._id,
childIndex,
treeNode.contextStoreKey
)
contextStoreKeys.push(key)
childIndex++
}
}
const childNodes = []
const createChildNodes = contextStoreKey => {
for (let childProps of treeNode.props._children) {
const { componentName, libName } = splitName(childProps._component)
if (!componentName || !libName) return
const ComponentConstructor = componentLibraries[libName][componentName]
const childNode = prepareRenderComponent({
props: childProps,
parentNode: treeNode,
ComponentConstructor,
htmlElement,
anchor,
// in same context as parent, unless a new one was supplied
contextStoreKey,
})
childNodes.push(childNode)
}
}
if (context) {
// if new context(s) is supplied, then create nodes
// with keys to new context stores
for (let contextStoreKey of contextStoreKeys) {
createChildNodes(contextStoreKey)
}
} else {
// otherwise, use same context store as parent
// which maybe undefined (therfor using the root state)
createChildNodes(treeNode.contextStoreKey)
}
// if everything is equal, then don't re-render
if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children
for (let node of childNodes) {
const initialProps = setupState(node)
node.render(initialProps)
}
const screenSlot = childNodes.find(n => isScreenSlot(n.props._component))
if (onScreenSlotRendered && screenSlot) {
// assuming there is only ever one screen slot
onScreenSlotRendered(screenSlot)
}
treeNode.children = childNodes
return childNodes
}
const splitName = fullname => {
const nameParts = fullname.split("/")
const componentName = nameParts[nameParts.length - 1]
const libName = fullname.substring(
0,
fullname.length - componentName.length - 1
)
return { libName, componentName }
}
const areTreeNodesEqual = (children1, children2) => {
if (children1.length !== children2.length) return false
if (children1 === children2) return true
let isEqual = false
for (let i = 0; i < children1.length; i++) {
// same context and same children, then nothing has changed
isEqual =
deepEqual(children1[i].context, children2[i].context) &&
areTreeNodesEqual(children1[i].children, children2[i].children)
if (!isEqual) return false
if (isScreenSlot(children1[i].parentNode.props._component)) {
isEqual = deepEqual(children1[i].props, children2[i].props)
}
if (!isEqual) return false
}
return true
}

View File

@ -1,10 +0,0 @@
import { screenSlotComponent } from "./screenSlotComponent"
export const builtinLibName = "##builtin"
export const isScreenSlot = componentName =>
componentName === "##builtin/screenslot"
export const builtins = window => ({
screenslot: screenSlotComponent(window),
})

View File

@ -1,88 +0,0 @@
import renderTemplateString from "../state/renderTemplateString"
import appStore from "../state/store"
import hasBinding from "../state/hasBinding"
export const prepareRenderComponent = ({
ComponentConstructor,
htmlElement,
anchor,
props,
parentNode,
contextStoreKey,
}) => {
const thisNode = createTreeNode()
thisNode.parentNode = parentNode
thisNode.props = props
thisNode.contextStoreKey = contextStoreKey
// the treeNode is first created (above), and then this
// render method is add. The treeNode is returned, and
// render is called later (in attachChildren)
thisNode.render = initialProps => {
thisNode.component = new ComponentConstructor({
target: htmlElement,
props: initialProps,
hydrate: false,
anchor,
})
// finds the root element of the component, which was created by the contructor above
// we use this later to attach a className to. This is how styles
// are applied by the builder
thisNode.rootElement = htmlElement.children[htmlElement.children.length - 1]
let [componentName] = props._component.match(/[a-z]*$/)
if (props._id && thisNode.rootElement) {
thisNode.rootElement.classList.add(`${componentName}-${props._id}`)
}
// make this node listen to the store
if (thisNode.stateBound) {
const unsubscribe = appStore.subscribe(state => {
const storeBoundProps = Object.keys(initialProps._bb.props).filter(p =>
hasBinding(initialProps._bb.props[p])
)
if (storeBoundProps.length > 0) {
const toSet = {}
for (let prop of storeBoundProps) {
const propValue = initialProps._bb.props[prop]
toSet[prop] = renderTemplateString(propValue, state)
}
thisNode.component.$set(toSet)
}
}, thisNode.contextStoreKey)
thisNode.unsubscribe = unsubscribe
}
}
return thisNode
}
export const createTreeNode = () => ({
context: {},
props: {},
rootElement: null,
parentNode: null,
children: [],
bindings: [],
component: null,
unsubscribe: () => {},
render: () => {},
get destroy() {
const node = this
return () => {
if (node.children) {
// destroy children first - from leaf nodes up
for (let child of node.children) {
child.destroy()
}
}
if (node.unsubscribe) node.unsubscribe()
if (node.component && node.component.$destroy) node.component.$destroy()
for (let onDestroyItem of node.onDestroy) {
onDestroyItem()
}
}
},
onDestroy: [],
})

View File

@ -1,122 +0,0 @@
import regexparam from "regexparam"
import appStore from "../state/store"
import { getAppId } from "@budibase/component-sdk"
export const screenRouter = ({ screens, onScreenSelected, window }) => {
function sanitize(url) {
if (!url) return url
return url
.split("/")
.map(part => {
// if parameter, then use as is
if (part.startsWith(":")) return part
return encodeURIComponent(part)
})
.join("/")
.toLowerCase()
}
const isRunningLocally = () => {
const hostname = (window.location && window.location.hostname) || ""
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168")
)
}
const makeRootedPath = url => {
if (isRunningLocally()) {
const appId = getAppId()
if (url) {
url = sanitize(url)
if (!url.startsWith("/")) {
url = `/${url}`
}
if (url.startsWith(`/${appId}`)) {
return url
}
return `/${appId}${url}`
}
return `/${appId}`
}
return sanitize(url)
}
const routes = screens.map(s => makeRootedPath(s.routing?.route))
let fallback = routes.findIndex(([p]) => p === makeRootedPath("*"))
if (fallback < 0) fallback = 0
let current
function route(url) {
const _url = makeRootedPath(url.state || url)
current = routes.findIndex(
p =>
p !== makeRootedPath("*") &&
new RegExp("^" + p.toLowerCase() + "$").test(_url.toLowerCase())
)
const params = {}
if (current === -1) {
routes.forEach((p, i) => {
// ignore home - which matched everything
if (p === makeRootedPath("*")) return
const pm = regexparam(p)
const matches = pm.pattern.exec(_url)
if (!matches) return
let j = 0
while (j < pm.keys.length) {
params[pm.keys[j]] = matches[++j] || null
}
current = i
})
}
appStore.update(state => {
state["##routeParams"] = params
return state
})
const screenIndex = current !== -1 ? current : fallback
try {
!url.state && history.pushState(_url, null, _url)
} catch (_) {
// ignoring an exception here as the builder runs an iframe, which does not like this
}
onScreenSelected(screens[screenIndex], _url)
}
function click(e) {
const x = e.target.closest("a")
const y = x && x.getAttribute("href")
if (
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey ||
e.button ||
e.defaultPrevented
)
return
const target = (x && x.target) || "_self"
if (!y || target !== "_self" || x.host !== location.host) return
e.preventDefault()
route(y)
}
addEventListener("popstate", route)
addEventListener("pushstate", route)
addEventListener("click", click)
return route
}

View File

@ -1,14 +0,0 @@
export const screenSlotComponent = window => {
return function(opts) {
const node = window.document.createElement("DIV")
const $set = props => {
props._bb.attachChildren(node)
}
const $destroy = () => {
if (opts.target && node) opts.target.removeChild(node)
}
this.$set = $set
this.$destroy = $destroy
opts.target.appendChild(node)
}
}

View File

@ -1,34 +0,0 @@
import setBindableComponentProp from "./setBindableComponentProp"
import { attachChildren } from "../render/attachChildren"
import store from "./store"
export const bbFactory = ({
componentLibraries,
onScreenSlotRendered,
runEventActions,
}) => {
return (treeNode, setupState) => {
const attachParams = {
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState,
}
return {
attachChildren: attachChildren(attachParams),
props: treeNode.props,
call: async eventName =>
eventName &&
(await runEventActions(
treeNode.props[eventName],
store.getState(treeNode.contextStoreKey)
)),
setBinding: setBindableComponentProp(treeNode),
parent,
store: store.getStore(treeNode.contextStoreKey),
// these parameters are populated by screenRouter
routeParams: () => store.getState()["##routeParams"],
}
}
}

View File

@ -1,42 +0,0 @@
import renderTemplateString from "./renderTemplateString"
import { updateRow, saveRow, deleteRow } from "@budibase/component-sdk"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => {
const handlers = {
"Navigate To": param => routeTo(param && param.url),
"Update Row": updateRow,
"Save Row": saveRow,
"Delete Row": deleteRow,
}
// when an event is called, this is what gets run
const runEventActions = async (actions, state) => {
if (!actions) return
// calls event handlers sequentially
for (let action of actions) {
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
const parameters = createParameters(action.parameters, state)
if (handler) {
await handler(parameters, state)
}
}
}
return runEventActions
}
// this will take a parameters obj, iterate all keys, and do a mustache render
// for every string. It will work recursively if it encounnters an {}
const createParameters = (parameterTemplateObj, state) => {
const parameters = {}
for (let key in parameterTemplateObj) {
if (typeof parameterTemplateObj[key] === "string") {
parameters[key] = renderTemplateString(parameterTemplateObj[key], state)
} else if (typeof parameterTemplateObj[key] === "object") {
parameters[key] = createParameters(parameterTemplateObj[key], state)
}
}
return parameters
}

View File

@ -1,11 +0,0 @@
export const setContext = treeNode => (key, value) =>
(treeNode.context[key] = value)
export const getContext = treeNode => key => {
if (treeNode.context && treeNode.context[key] !== undefined)
return treeNode.context[key]
if (!treeNode.context.$parent) return
return getContext(treeNode.parentNode)(key)
}

View File

@ -1 +0,0 @@
export default value => typeof value === "string" && value.includes("{{")

View File

@ -1,17 +0,0 @@
import mustache from "mustache"
// this is a much more liberal version of mustache's escape function
// ...just ignoring < and > to prevent tags from user input
// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78
const entityMap = {
"<": "&lt;",
">": "&gt;",
}
mustache.escape = text =>
String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) {
return entityMap[s] || s
})
export default mustache.render

View File

@ -1,13 +0,0 @@
import appStore from "./store"
export default treeNode => (propName, value) => {
if (!propName || propName.length === 0) return
if (!treeNode) return
const componentId = treeNode.props._id
appStore.update(state => {
state[componentId] = state[componentId] || {}
state[componentId][propName] = value
return state
}, treeNode.contextStoreKey)
}

View File

@ -1,65 +0,0 @@
import { eventHandlers } from "./eventHandlers"
import { bbFactory } from "./bbComponentApi"
import renderTemplateString from "./renderTemplateString"
import appStore from "./store"
import hasBinding from "./hasBinding"
const doNothing = () => {}
doNothing.isPlaceholder = true
const isMetaProp = propName =>
propName === "_component" ||
propName === "_children" ||
propName === "_id" ||
propName === "_style" ||
propName === "_code" ||
propName === "_codeMeta" ||
propName === "_styles"
export const createStateManager = ({
componentLibraries,
onScreenSlotRendered,
routeTo,
}) => {
let runEventActions = eventHandlers(routeTo)
const bb = bbFactory({
componentLibraries,
onScreenSlotRendered,
runEventActions,
})
const setup = _setup(bb)
return {
setup,
destroy: () => {},
}
}
const _setup = bb => node => {
const props = node.props
const initialProps = { ...props }
for (let propName in props) {
if (isMetaProp(propName)) continue
const propValue = props[propName]
const isBound = hasBinding(propValue)
if (isBound) {
const state = appStore.getState(node.contextStoreKey)
initialProps[propName] = renderTemplateString(propValue, state)
if (!node.stateBound) {
node.stateBound = true
}
}
}
const setup = _setup(bb)
initialProps._bb = bb(node, setup)
return initialProps
}

View File

@ -1,108 +0,0 @@
import { writable } from "svelte/store"
// we assume that the reference to this state object
// will remain for the life of the application
const rootState = {}
const rootStore = writable(rootState)
const contextStores = {}
// contextProviderId is the component id that provides the data for the context
const contextStoreKey = (dataProviderId, childIndex) =>
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}`
// creates a store for a datacontext (e.g. each item in a list component)
// overrides store if already exists
const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
const key = contextStoreKey(dataProviderId, childIndex)
const state = { data }
// add reference to parent state object,
// so we can use bindings like state.parent.parent
// (if no parent, then parent is rootState )
state.parent = parentContextStoreId
? contextStores[parentContextStoreId].state
: rootState
contextStores[key] = {
store: writable(state),
subscriberCount: 0,
state,
parentContextStoreId,
}
return key
}
const subscribe = (subscription, storeKey) => {
if (!storeKey) {
return rootStore.subscribe(subscription)
}
const contextStore = contextStores[storeKey]
// we are subscribing to multiple stores,
// we dont want to run our listener for every subscription, the first time
// as this could repeatedly run $set on the same component
// ... which already has its initial properties set properly
const ignoreFirstSubscription = () => {
let hasRunOnce = false
return () => {
if (hasRunOnce) subscription(contextStore.state)
hasRunOnce = true
}
}
const unsubscribes = [rootStore.subscribe(ignoreFirstSubscription())]
// we subscribe to all stores in the hierarchy
const ancestorSubscribe = ctxStore => {
// unsubscribe func returned by svelte store
const svelteUnsub = ctxStore.store.subscribe(ignoreFirstSubscription())
// we wrap the svelte unsubscribe, so we can
// cleanup stores when they are no longer subscribed to
const unsub = () => {
ctxStore.subscriberCount = contextStore.subscriberCount - 1
// when no subscribers left, we delete the store
if (ctxStore.subscriberCount === 0) {
delete ctxStore[storeKey]
}
svelteUnsub()
}
unsubscribes.push(unsub)
if (ctxStore.parentContextStoreId) {
ancestorSubscribe(contextStores[ctxStore.parentContextStoreId])
}
}
ancestorSubscribe(contextStore)
// our final unsubscribe function calls unsubscribe on all stores
return () => unsubscribes.forEach(u => u())
}
const findStore = (dataProviderId, childIndex) =>
dataProviderId
? contextStores[contextStoreKey(dataProviderId, childIndex)].store
: rootStore
const update = (updatefunc, dataProviderId, childIndex) =>
findStore(dataProviderId, childIndex).update(updatefunc)
const set = (value, dataProviderId, childIndex) =>
findStore(dataProviderId, childIndex).set(value)
const getState = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].state : rootState
const getStore = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].store : rootStore
export default {
subscribe,
update,
set,
getState,
create,
contextStoreKey,
getStore,
}

View File

@ -39,7 +39,7 @@ const MAIN = {
_children: [
{
_id: "49e0e519-9e5e-4127-885a-ee6a0a49e2c1",
_component: "@budibase/standard-components/Navigation",
_component: "@budibase/standard-components/navigation",
_styles: {
normal: {
"max-width": "1400px",

View File

@ -13,7 +13,7 @@
"embed": "string"
}
},
"Navigation": {
"navigation": {
"name": "Navigation",
"description": "A basic header navigation component",
"children": true,

View File

@ -10,7 +10,7 @@ export { default as button } from "./Button.svelte"
export { default as login } from "./Login.svelte"
export { default as link } from "./Link.svelte"
export { default as image } from "./Image.svelte"
export { default as Navigation } from "./Navigation.svelte"
export { default as navigation } from "./Navigation.svelte"
export { default as datagrid } from "./grid/Component.svelte"
export { default as dataform } from "./DataForm.svelte"
export { default as dataformwide } from "./DataFormWide.svelte"