Add client support for nav item updates
This commit is contained in:
parent
917a387468
commit
284f7fe3cc
|
@ -2,9 +2,8 @@
|
||||||
import { getContext, setContext } from "svelte"
|
import { getContext, setContext } from "svelte"
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { Heading, Icon, clickOutside } from "@budibase/bbui"
|
import { Heading, Icon, clickOutside } from "@budibase/bbui"
|
||||||
import { FieldTypes } from "constants"
|
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import active from "svelte-spa-router/active"
|
import NavItem from "./NavItem.svelte"
|
||||||
|
|
||||||
const sdk = getContext("sdk")
|
const sdk = getContext("sdk")
|
||||||
const {
|
const {
|
||||||
|
@ -18,6 +17,7 @@
|
||||||
} = sdk
|
} = sdk
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
const context = getContext("context")
|
const context = getContext("context")
|
||||||
|
const navStateStore = writable({})
|
||||||
|
|
||||||
// Legacy props which must remain unchanged for backwards compatibility
|
// Legacy props which must remain unchanged for backwards compatibility
|
||||||
export let title
|
export let title
|
||||||
|
@ -65,7 +65,7 @@
|
||||||
})
|
})
|
||||||
setContext("layout", store)
|
setContext("layout", store)
|
||||||
|
|
||||||
$: validLinks = getValidLinks(links, $roleStore)
|
$: enrichedNavItems = enrichNavItems(links, $roleStore)
|
||||||
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
|
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
|
||||||
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
|
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
|
||||||
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
|
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
|
||||||
|
@ -103,28 +103,52 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getValidLinks = (allLinks, userRoleHierarchy) => {
|
const enrichNavItem = navItem => {
|
||||||
// Strip links missing required info
|
const internalLink = isInternal(navItem.url)
|
||||||
let validLinks = (allLinks || []).filter(link => link.text)
|
return {
|
||||||
// Filter to only links allowed by the current role
|
...navItem,
|
||||||
return validLinks.filter(link => {
|
internalLink,
|
||||||
const role = link.roleId || Constants.Roles.BASIC
|
url: internalLink ? navItem.url : ensureExternal(navItem.url),
|
||||||
return userRoleHierarchy?.find(roleId => roleId === role)
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const enrichNavItems = (navItems, userRoleHierarchy) => {
|
||||||
|
if (!navItems?.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return navItems
|
||||||
|
.filter(navitem => {
|
||||||
|
// Strip nav items without text
|
||||||
|
if (!navitem.text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only links allowed by the current role
|
||||||
|
const role = navitem.roleId || Constants.Roles.BASIC
|
||||||
|
return userRoleHierarchy?.find(roleId => roleId === role)
|
||||||
|
})
|
||||||
|
.map(navItem => {
|
||||||
|
const enrichedNavItem = enrichNavItem(navItem)
|
||||||
|
if (navItem.type === "sublinks" && navItem.subLinks?.length) {
|
||||||
|
enrichedNavItem.subLinks = navItem.subLinks
|
||||||
|
.filter(subLink => subLink.text)
|
||||||
|
.map(enrichNavItem)
|
||||||
|
}
|
||||||
|
return enrichedNavItem
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInternal = url => {
|
const isInternal = url => {
|
||||||
return url.startsWith("/")
|
return url?.startsWith("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureExternal = url => {
|
const ensureExternal = url => {
|
||||||
|
if (!url?.length) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
return !url.startsWith("http") ? `http://${url}` : url
|
return !url.startsWith("http") ? `http://${url}` : url
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
mobileOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToPortal = () => {
|
const navigateToPortal = () => {
|
||||||
if ($builderStore.inBuilder) return
|
if ($builderStore.inBuilder) return
|
||||||
window.location.href = "/builder/apps"
|
window.location.href = "/builder/apps"
|
||||||
|
@ -197,7 +221,7 @@
|
||||||
>
|
>
|
||||||
<div class="nav nav--{typeClass} size--{navWidthClass}">
|
<div class="nav nav--{typeClass} size--{navWidthClass}">
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
{#if validLinks?.length}
|
{#if enrichedNavItems.length}
|
||||||
<div class="burger">
|
<div class="burger">
|
||||||
<Icon
|
<Icon
|
||||||
hoverable
|
hoverable
|
||||||
|
@ -246,28 +270,19 @@
|
||||||
class:visible={mobileOpen}
|
class:visible={mobileOpen}
|
||||||
on:click={() => (mobileOpen = false)}
|
on:click={() => (mobileOpen = false)}
|
||||||
/>
|
/>
|
||||||
{#if validLinks?.length}
|
{#if enrichedNavItems.length}
|
||||||
<div class="links" class:visible={mobileOpen}>
|
<div class="links" class:visible={mobileOpen}>
|
||||||
{#each validLinks as { text, url }}
|
{#each enrichedNavItems as navItem}
|
||||||
{#if isInternal(url)}
|
<NavItem
|
||||||
<a
|
type={navItem.type}
|
||||||
class={FieldTypes.LINK}
|
text={navItem.text}
|
||||||
href={url}
|
url={navItem.url}
|
||||||
use:linkable
|
subLinks={navItem.subLinks}
|
||||||
on:click={close}
|
internalLink={navItem.internalLink}
|
||||||
use:active={url}
|
on:clickLink={() => (mobileOpen = false)}
|
||||||
>
|
leftNav={navigation === "Left"}
|
||||||
{text}
|
{navStateStore}
|
||||||
</a>
|
/>
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
class={FieldTypes.LINK}
|
|
||||||
href={ensureExternal(url)}
|
|
||||||
on:click={close}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
<div class="close">
|
<div class="close">
|
||||||
<Icon
|
<Icon
|
||||||
|
@ -505,21 +520,6 @@
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
margin-top: var(--spacing-xl);
|
margin-top: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
.link {
|
|
||||||
opacity: 0.75;
|
|
||||||
color: var(--navTextColor);
|
|
||||||
font-size: var(--spectrum-global-dimension-font-size-200);
|
|
||||||
font-weight: 600;
|
|
||||||
transition: color 130ms ease-out;
|
|
||||||
}
|
|
||||||
.link.active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-position: under;
|
|
||||||
}
|
|
||||||
.close {
|
.close {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher, getContext } from "svelte"
|
||||||
|
import active from "svelte-spa-router/active"
|
||||||
|
import { Icon } from "@budibase/bbui"
|
||||||
|
|
||||||
|
export let type
|
||||||
|
export let url
|
||||||
|
export let text
|
||||||
|
export let subLinks
|
||||||
|
export let internalLink
|
||||||
|
export let leftNav = false
|
||||||
|
export let navStateStore
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
const sdk = getContext("sdk")
|
||||||
|
const { linkable } = sdk
|
||||||
|
|
||||||
|
$: expanded = !!$navStateStore[text]
|
||||||
|
$: icon = !leftNav || expanded ? "ChevronDown" : "ChevronRight"
|
||||||
|
|
||||||
|
const onClickLink = () => {
|
||||||
|
dispatch("clickLink")
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickDropdown = () => {
|
||||||
|
if (!leftNav) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navStateStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
[text]: !state[text],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !type || type === "link"}
|
||||||
|
{#if internalLink}
|
||||||
|
<!--
|
||||||
|
It's stupid that we have to add class:active={false} here, but if we don't
|
||||||
|
then svelte will strip out the CSS selector and active links won't be
|
||||||
|
styled
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
on:click={onClickLink}
|
||||||
|
use:active={url}
|
||||||
|
use:linkable
|
||||||
|
class:active={false}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href={url} on:click={onClickLink}>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div class="dropdown" class:left={leftNav} class:expanded>
|
||||||
|
<div class="text" on:click={onClickDropdown}>
|
||||||
|
{text}
|
||||||
|
<Icon name={icon} />
|
||||||
|
</div>
|
||||||
|
<div class="sublinks-wrapper">
|
||||||
|
<div class="sublinks">
|
||||||
|
{#each subLinks as subLink}
|
||||||
|
{#if subLink.internalLink}
|
||||||
|
<a
|
||||||
|
href={subLink.url}
|
||||||
|
on:click={onClickLink}
|
||||||
|
use:active={subLink.url}
|
||||||
|
use:linkable
|
||||||
|
>
|
||||||
|
{subLink.text}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href={subLink.url} on:click={onClickLink}>
|
||||||
|
{subLink.text}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Generic styles */
|
||||||
|
a,
|
||||||
|
.text {
|
||||||
|
opacity: 0.75;
|
||||||
|
color: var(--navTextColor);
|
||||||
|
font-size: var(--spectrum-global-dimension-font-size-200);
|
||||||
|
transition: opacity 130ms ease-out;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
a.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
a:hover,
|
||||||
|
.dropdown:not(.left.expanded):hover .text,
|
||||||
|
.text:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top dropdowns */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.sublinks-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
display: none;
|
||||||
|
padding-top: var(--spacing-s);
|
||||||
|
}
|
||||||
|
.dropdown:hover .sublinks-wrapper {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sublinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--spectrum-global-color-gray-50);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 250px;
|
||||||
|
padding: 10px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sublinks a {
|
||||||
|
padding: 6px var(--spacing-l);
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left dropdowns */
|
||||||
|
.dropdown.left .sublinks-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.dropdown.left,
|
||||||
|
.dropdown.left.expanded .sublinks-wrapper,
|
||||||
|
.dropdown.dropdown.left.expanded .sublinks {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.dropdown.left a {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Reference in New Issue