Merge pull request #13375 from Budibase/nested-nav-links

Nested nav links
This commit is contained in:
Andrew Kingston 2024-04-10 16:24:30 +01:00 committed by GitHub
commit fcd1113494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 955 additions and 501 deletions

View File

@ -4,6 +4,7 @@
import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte"
export let value = null
export let id = null
@ -15,8 +16,10 @@
export let getOptionValue = option => option
const dispatch = createEventDispatcher()
let open = false
let focus = false
let anchor
const selectOption = value => {
dispatch("change", value)
@ -35,11 +38,11 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="spectrum-InputGroup"
class:is-focused={open || focus}
class:is-disabled={disabled}
bind:this={anchor}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
@ -67,7 +70,7 @@
tabindex="-1"
aria-haspopup="true"
{disabled}
on:click={() => (open = true)}
on:click={() => (open = !open)}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon"
@ -77,13 +80,17 @@
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
{#if open}
<div
class="spectrum-Popover spectrum-Popover--bottom is-open"
use:clickOutside={() => {
open = false
}}
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover
{anchor}
{open}
align="left"
on:close={() => (open = false)}
useAnchorWidth
>
<div class="popover-content" use:clickOutside={() => (open = false)}>
<ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)}
{#each options as option}
@ -95,8 +102,7 @@
tabindex="0"
on:click={() => onPick(getOptionValue(option))}
>
<span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option)}</span
<span class="spectrum-Menu-itemLabel">{getOptionLabel(option)}</span
>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
@ -110,8 +116,7 @@
{/if}
</ul>
</div>
{/if}
</div>
</Popover>
<style>
.spectrum-InputGroup {
@ -124,10 +129,13 @@
.spectrum-Textfield-input {
width: 0;
}
.spectrum-Popover {
max-height: 240px;
width: 100%;
z-index: 999;
top: 100%;
/* Popover */
.popover-content {
display: contents;
}
.popover-content:not(.auto-width) .spectrum-Menu-itemLabel {
width: 0;
flex: 1 1 auto;
}
</style>

View File

@ -45,7 +45,6 @@
const dispatch = createEventDispatcher()
let button
let popover
let component
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
@ -146,11 +145,11 @@
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover
anchor={customAnchor ? customAnchor : button}
align={align || "left"}
bind:this={popover}
{open}
on:close={() => (open = false)}
useAnchorWidth={!autoWidth}
@ -266,16 +265,6 @@
width: 100%;
box-shadow: none;
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
.spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs);
}
@ -356,11 +345,9 @@
.option-extra.icon.field-icon {
display: flex;
}
.option-tag {
margin: 0 var(--spacing-m) 0 var(--spacing-m);
}
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px;
}
@ -374,4 +361,13 @@
.loading--withAutocomplete {
top: calc(34px + var(--spacing-m));
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
</style>

View File

@ -99,10 +99,10 @@
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class:customZindex
class:hide-popover={open && !showPopover}
class:hidden={!showPopover}
role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }}
on:mouseenter
on:mouseleave
>
@ -112,16 +112,17 @@
{/if}
<style>
.hide-popover {
display: contents;
}
.spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300);
overflow: auto;
transition: opacity 260ms ease-out, transform 260ms ease-out;
}
.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-20px);
}
.customZindex {
z-index: var(--customZindex) !important;
}

View File

@ -131,7 +131,7 @@
if (bindings?.length) {
options.push(SidePanels.Bindings)
}
if (context) {
if (context && Object.keys(context).length > 0) {
options.push(SidePanels.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {

View File

@ -78,7 +78,12 @@
{/if}
</div>
<Drawer bind:this={bindingDrawer} title={title ?? placeholder ?? "Bindings"}>
<Drawer
bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"}
on:drawerHide
on:drawerShow
>
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
<svelte:component
this={panel}

View File

@ -14,7 +14,6 @@
export let key
export let nested
export let max
export let context
const dispatch = createEventDispatcher()

View File

@ -126,7 +126,7 @@
<div class="right-content">
<svelte:component
this={listType}
anchor={anchors[draggableItem.item._id]}
anchor={anchors[draggableItem.id]}
item={draggableItem.item}
{...listTypeProps}
on:change={onItemChanged}

View File

@ -0,0 +1,141 @@
<script>
import { Icon, Popover, RadioGroup } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import SubLinksDrawer from "./SubLinksDrawer.svelte"
import { screenStore } from "stores/builder"
export let anchor
export let navItem
export let bindings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
const typeOptions = [
{ label: "Inline link", value: "link" },
{ label: "Open sub links", value: "sublinks" },
]
let popover
let open = false
let drawerCount = 0
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
// Auto hide the component when another item is selected
$: if (open && $draggable.selected !== navItem.id) {
popover.hide()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === navItem.id && popover) {
popover.show()
open = true
}
const update = setting => async value => {
dispatch("change", {
...navItem,
[setting]: value,
})
}
</script>
<Icon name={navItem.type === "sublinks" ? "Dropdown" : "Link"} size="S" />
<Popover
bind:this={popover}
on:open={() => {
open = true
$draggable.actions.select(navItem.id)
}}
on:close={() => {
open = false
if ($draggable.selected === navItem.id) {
$draggable.actions.select()
}
}}
{anchor}
align="left-outside"
showPopover={drawerCount === 0}
clickOutsideOverride={drawerCount > 0}
maxHeight={600}
offset={18}
>
<div class="settings">
<PropertyControl
label="Nav item"
control={RadioGroup}
value={navItem.type}
onChange={update("type")}
props={{
options: typeOptions,
}}
/>
<PropertyControl
label="Label"
control={DrawerBindableInput}
value={navItem.text}
onChange={update("text")}
{bindings}
props={{
updateOnChange: false,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{#if navItem.type === "sublinks"}
<PropertyControl
label="Sub links"
control={SubLinksDrawer}
value={navItem.subLinks}
onChange={update("subLinks")}
{bindings}
props={{
navItem,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{:else}
<PropertyControl
label="Link"
control={DrawerBindableCombobox}
value={navItem.url}
onChange={update("url")}
{bindings}
props={{
options: urlOptions,
appendBindingsAsOptions: false,
placeholder: null,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{/if}
<PropertyControl
label="Access"
control={RoleSelect}
value={navItem.roleId}
onChange={update("roleId")}
/>
</div>
</Popover>
<style>
.settings {
background: var(--spectrum-alias-background-color-primary);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 8px;
padding: var(--spacing-xl);
}
</style>

View File

@ -1,131 +0,0 @@
<script>
import {
Button,
Icon,
DrawerContent,
Layout,
Input,
Combobox,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
export let links = []
const flipDurationMs = 150
let dragDisabled = true
$: links.forEach(link => {
if (!link.id) {
link.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
const addLink = () => {
links = [...links, {}]
}
const removeLink = id => {
links = links.filter(link => link.id !== id)
}
const updateLinks = e => {
links = e.detail.items
}
const handleFinalize = e => {
updateLinks(e)
dragDisabled = true
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<DrawerContent>
<div class="container">
<Layout noPadding gap="S">
{#if links?.length}
<div
class="links"
use:dndzone={{
items: links,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled,
}}
on:finalize={handleFinalize}
on:consider={updateLinks}
>
{#each links as link (link.id)}
<div class="link" animate:flip={{ duration: flipDurationMs }}>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Input bind:value={link.text} placeholder="Text" />
<Combobox
bind:value={link.url}
placeholder="URL"
options={urlOptions}
/>
<RoleSelect bind:value={link.roleId} placeholder="Minimum role" />
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeLink(link.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<Button secondary icon="Add" on:click={addLink}>Add Link</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.links {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.link {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.link:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.link > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
.handle {
display: grid;
place-items: center;
}
</style>

View File

@ -1,30 +0,0 @@
<script>
import { Button, Drawer } from "@budibase/bbui"
import NavigationLinksDrawer from "./LinksDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { navigationStore } from "stores/builder"
let drawer
let links
const openDrawer = () => {
links = cloneDeep($navigationStore.links || [])
drawer.show()
}
const save = async () => {
let navigation = $navigationStore
navigation.links = links
await navigationStore.save(navigation)
drawer.hide()
}
</script>
<Button cta on:click={openDrawer}>Configure Links</Button>
<Drawer bind:this={drawer} title={"Navigation Links"}>
<svelte:fragment slot="description">
Configure the links in your navigation bar.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<NavigationLinksDrawer slot="body" bind:links />
</Drawer>

View File

@ -0,0 +1,55 @@
<script>
import { runtimeToReadableBinding } from "dataBinding"
import EditNavItemPopover from "./EditNavItemPopover.svelte"
import { Icon } from "@budibase/bbui"
export let item
export let removeNavItem
export let anchor
export let bindings
$: text = runtimeToReadableBinding(bindings, item.text)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditNavItemPopover {anchor} {bindings} navItem={item} on:change />
<div class="field-label">{text}</div>
</div>
<div class="list-item-right">
<Icon
size="S"
name="Close"
hoverable
on:click={e => {
e.stopPropagation()
removeNavItem(item.id)
}}
/>
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-body {
margin-top: 8px;
margin-bottom: 8px;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,118 @@
<script>
import { navigationStore } from "stores/builder"
import DraggableList from "components/design/settings/controls/DraggableList/DraggableList.svelte"
import NavItem from "./NavItem.svelte"
import { generate } from "shortid"
import { getSequentialName } from "helpers/duplicate"
import { Constants } from "@budibase/frontend-core"
export let bindings
$: navItems = enrichNavItems($navigationStore.links)
$: navItemProps = {
removeNavItem,
bindings,
}
const enrichNavItems = links => {
return (links || []).map(link => ({
...link,
id: link.id || generate(),
}))
}
const save = async links => {
await navigationStore.save({ ...$navigationStore, links })
}
const handleNavItemUpdate = async e => {
const newNavItem = e.detail
const newLinks = [...navItems]
const idx = newLinks.findIndex(link => {
return link.id === newNavItem?.id
})
if (idx === -1) {
newLinks.push(newNavItem)
} else {
newLinks[idx] = newNavItem
}
await save(newLinks)
}
const handleListUpdate = async e => {
await save([...e.detail])
}
const addNavItem = async () => {
await save([
...navItems,
{
id: generate(),
text: getSequentialName(navItems, "Nav Item ", x => x.text),
url: "",
roleId: Constants.Roles.BASIC,
type: "link",
},
])
}
const removeNavItem = async id => {
await save(navItems.filter(navItem => navItem.id !== id))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="link-configuration">
{#if navItems.length}
<DraggableList
on:change={handleListUpdate}
on:itemChange={handleNavItemUpdate}
items={navItems}
listItemKey="id"
listType={NavItem}
listTypeProps={navItemProps}
draggable={navItems.length > 1}
/>
{/if}
<div class="list-footer" on:click={addNavItem} class:empty={!navItems.length}>
<div class="add-button">Add nav item</div>
</div>
</div>
<style>
.link-configuration :global(.list-wrap > li:last-child),
.link-configuration :global(.list-wrap) {
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
border-bottom: none;
}
.list-footer {
width: 100%;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
justify-content: center;
border: 1px solid var(--spectrum-alias-border-color-mid);
cursor: pointer;
}
.list-footer.empty {
border-radius: 4px;
}
.list-footer:hover {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
.add-button {
margin: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,142 @@
<script>
import {
ActionButton,
Button,
Icon,
DrawerContent,
Layout,
Input,
Drawer,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let value = []
export let onChange
export let navItem
export let bindings
const flipDurationMs = 150
let drawer
let subLinks = value?.slice() || []
$: count = value?.length ?? 0
$: buttonText = `${count || "No"} sub link${count === 1 ? "" : "s"}`
$: drawerTitle = navItem.text ? `${navItem.text} sub links` : "Sub links"
$: subLinks.forEach(subLink => {
if (!subLink.id) {
subLink.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
const addSubLink = () => {
subLinks = [...subLinks, {}]
}
const removeSubLink = id => {
subLinks = subLinks.filter(link => link.id !== id)
}
const saveSubLinks = () => {
onChange(subLinks)
drawer.hide()
}
const updateSubLinks = e => {
subLinks = e.detail.items
}
</script>
<Drawer bind:this={drawer} title={drawerTitle} on:drawerShow on:drawerHide>
<Button cta slot="buttons" on:click={saveSubLinks}>Save</Button>
<DrawerContent slot="body">
<div class="container">
<Layout noPadding gap="S">
{#if subLinks?.length}
<div
class="subLinks"
use:dndzone={{
items: subLinks,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={updateSubLinks}
on:finalize={updateSubLinks}
>
{#each subLinks as subLink (subLink.id)}
<div class="subLink" animate:flip={{ duration: flipDurationMs }}>
<Icon name="DragHandle" size="XL" />
<Input bind:value={subLink.text} placeholder="Text" />
<DrawerBindableCombobox
value={subLink.url}
on:change={e => (subLink.url = e.detail)}
placeholder="Link"
options={urlOptions}
{bindings}
appendBindingsAsOptions={false}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeSubLink(subLink.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<ActionButton quiet icon="Add" on:click={addSubLink}>
Add link
</ActionButton>
</div>
</Layout>
</div>
</DrawerContent>
</Drawer>
<div class="button">
<ActionButton on:click={drawer.show}>{buttonText}</ActionButton>
</div>
<style>
.button :global(.spectrum-ActionButton) {
width: 100%;
}
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.subLinks {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.subLink {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.subLink:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.subLink > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
</style>

View File

@ -1,62 +1,61 @@
<script>
import LinksEditor from "./LinksEditor.svelte"
import NavItemConfiguration from "./NavItemConfiguration.svelte"
import { get } from "svelte/store"
import Panel from "components/design/Panel.svelte"
import {
Detail,
Toggle,
Body,
Icon,
ColorPicker,
Input,
Label,
ActionGroup,
ActionButton,
DetailSummary,
Checkbox,
notifications,
Select,
Combobox,
} from "@budibase/bbui"
import {
themeStore,
selectedScreen,
screenStore,
navigationStore,
componentStore,
navigationStore as nav,
} from "stores/builder"
import { DefaultAppTheme } from "constants"
import BarButtonList from "/src/components/design/settings/controls/BarButtonList.svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import BarButtonList from "components/design/settings/controls/BarButtonList.svelte"
import ColorPicker from "components/design/settings/controls/ColorPicker.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import { getBindableProperties } from "dataBinding"
$: alignmentOptions = [
const positionOptions = [
{ value: "Top", barIcon: "PaddingTop" },
{ value: "Left", barIcon: "PaddingLeft" },
]
const alignmentOptions = [
{ value: "Left", barIcon: "TextAlignLeft" },
{ value: "Center", barIcon: "TextAlignCenter" },
{ value: "Right", barIcon: "TextAlignRight" },
]
const widthOptions = ["Max", "Large", "Medium", "Small"]
$: bindings = getBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
$: screenRouteOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
const updateShowNavigation = async e => {
await screenStore.updateSetting(
get(selectedScreen),
"showNavigation",
e.detail
)
const updateShowNavigation = async show => {
await screenStore.updateSetting(get(selectedScreen), "showNavigation", show)
}
const update = async (key, value) => {
try {
let navigation = $navigationStore
let navigation = $nav
navigation[key] = value
await navigationStore.save(navigation)
await nav.save(navigation)
} catch (error) {
notifications.error("Error updating navigation settings")
}
}
const updateTextAlign = textAlignValue => {
navigationStore.syncAppNavigation({ textAlign: textAlignValue })
}
</script>
<Panel
@ -65,215 +64,142 @@
borderLeft
wide
>
<div class="generalSection">
<div class="subheading">
<Detail>General</Detail>
</div>
<div class="toggle">
<Toggle
on:change={updateShowNavigation}
<DetailSummary name="General" initiallyShow collapsible={false}>
<PropertyControl
control={Toggle}
props={{ text: "Show nav on this screen" }}
onChange={updateShowNavigation}
value={$selectedScreen?.showNavigation}
/>
<Body size="S">Show nav on this screen</Body>
</div>
</div>
</DetailSummary>
{#if $selectedScreen?.showNavigation}
<div class="divider" />
<div class="customizeSection">
<div class="subheading">
<Detail>Customize</Detail>
</div>
<div class="info">
<Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body>
</div>
<div class="configureLinks">
<LinksEditor />
</div>
<div class="controls">
<div class="label">
<Label size="M">Position</Label>
</div>
<ActionGroup quiet>
<ActionButton
selected={$navigationStore.navigation === "Top"}
quiet={$navigationStore.navigation !== "Top"}
icon="PaddingTop"
on:click={() => update("navigation", "Top")}
<DetailSummary name="Customize" initiallyShow collapsible={false}>
<NavItemConfiguration {bindings} />
<div class="settings">
<PropertyControl
label="Position"
control={BarButtonList}
onChange={position => update("navigation", position)}
value={$nav.navigation}
props={{
options: positionOptions,
}}
/>
<ActionButton
selected={$navigationStore.navigation === "Left"}
quiet={$navigationStore.navigation !== "Left"}
icon="PaddingLeft"
on:click={() => update("navigation", "Left")}
{#if $nav.navigation === "Top"}
<PropertyControl
label="Sticky header"
control={Checkbox}
value={$nav.sticky}
onChange={sticky => update("sticky", sticky)}
/>
</ActionGroup>
{#if $navigationStore.navigation === "Top"}
<div class="label">
<Label size="M">Sticky header</Label>
</div>
<Checkbox
value={$navigationStore.sticky}
on:change={e => update("sticky", e.detail)}
/>
<div class="label">
<Label size="M">Width</Label>
</div>
<Select
options={["Max", "Large", "Medium", "Small"]}
plaveholder={null}
value={$navigationStore.navWidth}
on:change={e => update("navWidth", e.detail)}
<PropertyControl
label="Width"
control={Select}
onChange={position => update("navWidth", position)}
value={$nav.navWidth}
props={{
placeholder: null,
options: widthOptions,
}}
/>
{/if}
<div class="label">
<Label size="M">Show title</Label>
</div>
<Checkbox
value={!$navigationStore.hideTitle}
on:change={e => update("hideTitle", !e.detail)}
<PropertyControl
label="Show title"
control={Checkbox}
value={!$nav.hideTitle}
onChange={show => update("hideTitle", !show)}
/>
{#if !$navigationStore.hideTitle}
<div class="label">
<Label size="M">Title</Label>
</div>
<Input
value={$navigationStore.title}
on:change={e => update("title", e.detail)}
updateOnChange={false}
{#if !$nav.hideTitle}
<PropertyControl
label="Title"
control={DrawerBindableInput}
value={$nav.title}
onChange={title => update("title", title)}
{bindings}
props={{
updateOnChange: false,
}}
/>
<div class="label">
<Label size="M">Text align</Label>
</div>
<BarButtonList
options={alignmentOptions}
value={$navigationStore.textAlign}
onChange={updateTextAlign}
<PropertyControl
label="Text align"
control={BarButtonList}
onChange={align => nav.syncAppNavigation({ textAlign: align })}
value={$nav.textAlign}
props={{
options: alignmentOptions,
}}
/>
{/if}
<div class="label">
<Label>Background</Label>
</div>
<ColorPicker
spectrumTheme={$themeStore.theme}
value={$navigationStore.navBackground ||
DefaultAppTheme.navBackground}
on:change={e => update("navBackground", e.detail)}
<PropertyControl
label="Background"
control={ColorPicker}
onChange={color => update("navBackground", color)}
value={$nav.navBackground || DefaultAppTheme.navBackground}
props={{
spectrumTheme: $themeStore.theme,
}}
/>
<div class="label">
<Label>Text</Label>
</div>
<ColorPicker
spectrumTheme={$themeStore.theme}
value={$navigationStore.navTextColor || DefaultAppTheme.navTextColor}
on:change={e => update("navTextColor", e.detail)}
<PropertyControl
label="Text"
control={ColorPicker}
onChange={color => update("navTextColor", color)}
value={$nav.navTextColor || DefaultAppTheme.navTextColor}
props={{
spectrumTheme: $themeStore.theme,
}}
/>
</div>
</div>
</DetailSummary>
<div class="divider" />
<div class="customizeSection">
<div class="subheading">
<Detail>Logo</Detail>
</div>
<div class="controls">
<div class="label">
<Label size="M">Show logo</Label>
</div>
<Checkbox
value={!$navigationStore.hideLogo}
on:change={e => update("hideLogo", !e.detail)}
<DetailSummary name="Logo" initiallyShow collapsible={false}>
<div class="settings">
<PropertyControl
label="Show logo"
control={Checkbox}
value={!$nav.hideLogo}
onChange={show => update("hideLogo", !show)}
/>
{#if !$navigationStore.hideLogo}
<div class="label">
<Label size="M">Logo image URL</Label>
</div>
<Input
value={$navigationStore.logoUrl}
on:change={e => update("logoUrl", e.detail)}
updateOnChange={false}
{#if !$nav.hideLogo}
<PropertyControl
label="Logo image URL"
control={DrawerBindableInput}
value={$nav.logoUrl}
onChange={url => update("logoUrl", url)}
{bindings}
props={{
updateOnChange: false,
}}
/>
<div class="label">
<Label size="M">Logo link URL</Label>
</div>
<Combobox
value={$navigationStore.logoLinkUrl}
on:change={e => update("logoLinkUrl", e.detail)}
options={screenRouteOptions}
<PropertyControl
label="Logo link URL"
control={DrawerBindableCombobox}
value={$nav.logoLinkUrl}
onChange={url => update("logoLinkUrl", url)}
{bindings}
props={{
appendBindingsAsOptions: false,
options: screenRouteOptions,
}}
/>
<div class="label">
<Label size="M">New tab</Label>
</div>
<Checkbox
value={!!$navigationStore.openLogoLinkInNewTab}
on:change={e => update("openLogoLinkInNewTab", !!e.detail)}
<PropertyControl
label="New tab"
control={Checkbox}
value={$nav.openLogoLinkInNewTab}
onChange={show => update("openLogoLinkInNewTab", show)}
/>
{/if}
</div>
</div>
</DetailSummary>
{/if}
</Panel>
<style>
.generalSection {
padding: 13px 13px 25px;
}
.customizeSection {
padding: 13px 13px 25px;
}
.subheading {
margin-bottom: 10px;
}
.subheading :global(p) {
color: var(--grey-6);
}
.toggle {
.settings {
display: flex;
align-items: center;
}
.divider {
border-top: 1px solid var(--grey-3);
}
.controls {
position: relative;
display: grid;
grid-template-columns: 90px 1fr;
align-items: start;
transition: background 130ms ease-out, border-color 130ms ease-out;
border-left: 4px solid transparent;
margin: 0 calc(-1 * var(--spacing-xl));
padding: 0 var(--spacing-xl) 0 calc(var(--spacing-xl) - 4px);
gap: 12px;
}
.label {
margin-top: 16px;
transform: translateY(-50%);
}
.info {
background-color: var(--background-alt);
padding: 12px;
display: flex;
border-radius: 4px;
gap: 4px;
margin-bottom: 16px;
}
.info :global(svg) {
margin-right: 5px;
color: var(--spectrum-global-color-gray-600);
}
.configureLinks :global(button) {
margin-bottom: 20px;
width: 100%;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 8px;
}
</style>

View File

@ -79,7 +79,8 @@
// for autoscreens, so it's always safe to do this.
await navigationStore.saveLink(
screen.routing.route,
capitalise(screen.routing.route.split("/")[1])
capitalise(screen.routing.route.split("/")[1]),
screenAccessRole
)
}

View File

@ -42,7 +42,7 @@ export class NavigationStore extends BudiStore {
this.syncAppNavigation(app.navigation)
}
async saveLink(url, title) {
async saveLink(url, title, roleId) {
const navigation = get(this.store)
let links = [...(navigation?.links ?? [])]
@ -54,6 +54,8 @@ export class NavigationStore extends BudiStore {
links.push({
text: title,
url,
type: "link",
roleId,
})
await this.save({
...navigation,
@ -67,11 +69,20 @@ export class NavigationStore extends BudiStore {
if (!links?.length) {
return
}
// Filter out the URLs to delete
urls = Array.isArray(urls) ? urls : [urls]
// Filter out top level links pointing to these URLs
links = links.filter(link => !urls.includes(link.url))
// Filter out nested links pointing to these URLs
links.forEach(link => {
if (link.type === "sublinks" && link.subLinks?.length) {
link.subLinks = link.subLinks.filter(
subLink => !urls.includes(subLink.url)
)
}
})
await this.save({
...navigation,
links,

View File

@ -50,10 +50,18 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
},
]
@ -66,7 +74,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/test-url", "Testing")
await ctx.test.navigationStore.saveLink("/test-url", "Testing", "BASIC")
expect(saveSpy).toBeCalledWith({
...INITIAL_NAVIGATION_STATE,
@ -75,6 +83,8 @@ describe("Navigation store", () => {
{
url: "/test-url",
text: "Testing",
type: "link",
roleId: "BASIC",
},
],
})
@ -87,6 +97,7 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
],
}))
@ -94,7 +105,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/home", "Home")
await ctx.test.navigationStore.saveLink("/home", "Home", "BASIC")
expect(saveSpy).not.toHaveBeenCalled()
})
@ -106,14 +117,23 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/home",
},
],
},
],
}))
@ -130,6 +150,8 @@ describe("Navigation store", () => {
{
text: "Last Link",
url: "/last",
type: "sublinks",
subLinks: [],
},
],
})
@ -140,14 +162,17 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "link",
},
]
@ -168,10 +193,12 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "link",
},
],
})
@ -180,10 +207,7 @@ describe("Navigation store", () => {
it("Should ignore a request to delete if there are no links", async ctx => {
const saveSpy = vi.spyOn(ctx.test.navigationStore, "save")
await ctx.test.navigationStore.deleteLink({
url: "/some-link",
text: "Some Link",
})
await ctx.test.navigationStore.deleteLink("/some-link")
expect(saveSpy).not.toBeCalled()
})
@ -201,10 +225,18 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
},
],
}))
@ -217,6 +249,7 @@ describe("Navigation store", () => {
{
url: "/new-link",
text: "New Link",
type: "link",
},
],
}

View File

@ -2,9 +2,8 @@
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants"
import { Constants } from "@budibase/frontend-core"
import active from "svelte-spa-router/active"
import NavItem from "./NavItem.svelte"
const sdk = getContext("sdk")
const {
@ -16,6 +15,7 @@
appStore,
} = sdk
const context = getContext("context")
const navStateStore = writable({})
// Legacy props which must remain unchanged for backwards compatibility
export let title
@ -63,7 +63,7 @@
})
setContext("layout", store)
$: validLinks = getValidLinks(links, $roleStore)
$: enrichedNavItems = enrichNavItems(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
@ -101,26 +101,55 @@
}
}
const getValidLinks = (allLinks, userRoleHierarchy) => {
// Strip links missing required info
let validLinks = (allLinks || []).filter(link => link.text && link.url)
const enrichNavItem = navItem => {
const internalLink = isInternal(navItem.url)
return {
...navItem,
internalLink,
url: internalLink ? navItem.url : ensureExternal(navItem.url),
}
}
const enrichNavItems = (navItems, userRoleHierarchy) => {
if (!navItems?.length) {
return []
}
return navItems
.filter(navItem => {
// Strip nav items without text
if (!navItem.text) {
return false
}
// Strip out links without URLs
if (navItem.type !== "sublinks" && !navItem.url) {
return false
}
// Filter to only links allowed by the current role
return validLinks.filter(link => {
const role = link.roleId || Constants.Roles.BASIC
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 && subLink.url)
.map(enrichNavItem)
}
return enrichedNavItem
})
}
const isInternal = url => {
return url.startsWith("/")
return url?.startsWith("/")
}
const ensureExternal = url => {
return !url.startsWith("http") ? `http://${url}` : url
if (!url?.length) {
return url
}
const close = () => {
mobileOpen = false
return !url.startsWith("http") ? `http://${url}` : url
}
const navigateToPortal = () => {
@ -194,7 +223,7 @@
>
<div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header">
{#if validLinks?.length}
{#if enrichedNavItems.length}
<div class="burger">
<Icon
hoverable
@ -243,28 +272,20 @@
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if validLinks?.length}
{#if enrichedNavItems.length}
<div class="links" class:visible={mobileOpen}>
{#each validLinks as { text, url }}
{#if isInternal(url)}
<a
class={FieldTypes.LINK}
href={url}
use:linkable
on:click={close}
use:active={url}
>
{text}
</a>
{:else}
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{#each enrichedNavItems as navItem}
<NavItem
type={navItem.type}
text={navItem.text}
url={navItem.url}
subLinks={navItem.subLinks}
internalLink={navItem.internalLink}
on:clickLink={() => (mobileOpen = false)}
leftNav={navigation === "Left"}
{mobile}
{navStateStore}
/>
{/each}
<div class="close">
<Icon
@ -509,21 +530,6 @@
gap: 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 {
display: none;
position: absolute;

View File

@ -0,0 +1,173 @@
<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 mobile = false
export let navStateStore
const dispatch = createEventDispatcher()
const sdk = getContext("sdk")
const { linkable } = sdk
let renderKey
$: expanded = !!$navStateStore[text]
$: renderLeftNav = leftNav || mobile
$: icon = !renderLeftNav || expanded ? "ChevronDown" : "ChevronRight"
const onClickLink = () => {
dispatch("clickLink")
renderKey = Math.random()
}
const onClickDropdown = () => {
if (!renderLeftNav) {
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 -->
{#key renderKey}
<div class="dropdown" class:left={renderLeftNav} class:expanded>
<div class="text" on:click={onClickDropdown}>
<span>{text}</span>
<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>
{/key}
{/if}
<style>
/* Generic styles */
a,
.text span {
opacity: 0.75;
color: var(--navTextColor);
font-size: var(--spectrum-global-dimension-font-size-200);
transition: opacity 130ms ease-out;
font-weight: 600;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
}
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);
}
.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>