Merge pull request #13375 from Budibase/nested-nav-links
Nested nav links
This commit is contained in:
commit
fcd1113494
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
export let key
|
||||
export let nested
|
||||
export let max
|
||||
export let context
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue