mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
feat(frontend): app navbar (#3992)
* feat(frontend): app navbar * feat(frontend): app navbar * fix(frontend): add icons + highlight + image * fix(frontend): improve style * fix(frontend): use a proper select component * fix(frontend): fix select * feat(frontend): Navbar component * feat(frontend): add path indicator + fix disabled navbar item * feat(frontend): add path indicator + fix disabled navbar item * feat(frontend): wip * feat(frontend): wip * feat(frontend): add local item * feat(frontend): introduced currentPath * feat(frontend): fix popups * feat(frontend): initial current path * feat(frontend): initial current path * feat(frontend): fix interactions * feat(frontend): improve code * feat(frontend): improve code * feat(frontend): wip * feat(frontend): open windmill apps in the same tab * feat(frontend): added support for the goto * feat(frontend): avoid loaded the app multiple times * feat(frontend): add support for oneOf * feat(frontend): done * feat(frontend): add missing tooltips * feat(frontend): improve alert message * feat(frontend): fix typo * feat(frontend): add missing reference to the ctx.query * fix(frontend): improve code * feat(frontend): navbar done * feat(frontend): fix navbar wizard wording * feat(frontend): only select the current app if the selected value is not defined + correctly clear
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { initConfig, initOutput } from '../../editor/appUtils'
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import { initCss } from '../../utils'
|
||||
import { components, type NavbarItem } from '../../editor/component'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import AppNavbarItem from './AppNavbarItem.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
export let customCss: ComponentCustomCSS<'navbarcomponent'> | undefined = undefined
|
||||
export let render: boolean
|
||||
export let navbarItems: NavbarItem[] = []
|
||||
|
||||
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
let resolvedConfig = initConfig(
|
||||
components['navbarcomponent'].initialData.configuration,
|
||||
configuration
|
||||
)
|
||||
|
||||
let output = initOutput($worldStore, id, {
|
||||
result: {
|
||||
currentPath: undefined as string | undefined
|
||||
}
|
||||
})
|
||||
|
||||
let css = initCss($app.css?.navbarcomponent, customCss)
|
||||
</script>
|
||||
|
||||
{#each Object.keys(components['navbarcomponent'].initialData.configuration) as key (key)}
|
||||
<ResolveConfig
|
||||
{id}
|
||||
{key}
|
||||
bind:resolvedConfig={resolvedConfig[key]}
|
||||
configuration={configuration[key]}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each Object.keys(css ?? {}) as key (key)}
|
||||
<ResolveStyle
|
||||
{id}
|
||||
{customCss}
|
||||
{key}
|
||||
bind:css={css[key]}
|
||||
componentStyle={$app.css?.navbarcomponent}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<InitializeComponent {id} />
|
||||
{#if render}
|
||||
<div class="flex flex-row w-full items-center border-b px-4 gap-4 h-12">
|
||||
{#if resolvedConfig.logo?.selected === 'yes'}
|
||||
<img
|
||||
on:pointerdown|preventDefault
|
||||
src={resolvedConfig.logo?.configuration?.yes?.sourceKind == 'png encoded as base64'
|
||||
? 'data:image/png;base64,' + resolvedConfig.logo?.configuration?.yes?.source
|
||||
: resolvedConfig.logo?.configuration?.yes?.sourceKind == 'jpeg encoded as base64'
|
||||
? 'data:image/jpeg;base64,' + resolvedConfig.logo?.configuration?.yes?.source
|
||||
: resolvedConfig.logo?.configuration?.yes?.sourceKind == 'svg encoded as base64'
|
||||
? 'data:image/svg+xml;base64,' + resolvedConfig.logo?.configuration?.yes?.source
|
||||
: resolvedConfig.logo?.configuration?.yes?.source}
|
||||
alt={resolvedConfig.logo?.configuration?.yes?.altText}
|
||||
style={css?.image?.style ?? ''}
|
||||
class={twMerge(`w-auto h-8`, css?.image?.class, 'wm-image')}
|
||||
/>
|
||||
{/if}
|
||||
<div class="font-semibold">
|
||||
{resolvedConfig?.title ?? 'No Title'}
|
||||
</div>
|
||||
<div class="flex flex-row gap-4 overflow-x-auto">
|
||||
{#each navbarItems ?? [] as navbarItem, index (index)}
|
||||
<Popover notClickable disablePopup={!Boolean(navbarItem.caption)}>
|
||||
<svelte:fragment slot="text">{navbarItem.caption}</svelte:fragment>
|
||||
<AppNavbarItem
|
||||
{navbarItem}
|
||||
{id}
|
||||
borderColor={resolvedConfig?.borderColor}
|
||||
{index}
|
||||
bind:output
|
||||
/>
|
||||
</Popover>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts" context="module">
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
let selected: Writable<string | undefined> = writable(undefined)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import type { AppViewerContext } from '../../types'
|
||||
import { type NavbarItem } from '../../editor/component'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { loadIcon } from '../icon'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Output } from '../../rx'
|
||||
import ResolveNavbarItemPath from './ResolveNavbarItemPath.svelte'
|
||||
|
||||
export let navbarItem: NavbarItem
|
||||
export let id: string
|
||||
export let borderColor: string | undefined = undefined
|
||||
export let index: number
|
||||
export let output: {
|
||||
result: Output<{
|
||||
currentPath: string
|
||||
}>
|
||||
}
|
||||
|
||||
let icon: any
|
||||
|
||||
$: navbarItem.icon && icon && handleIcon()
|
||||
|
||||
async function handleIcon() {
|
||||
if (navbarItem.icon) {
|
||||
icon = await loadIcon(navbarItem.icon, icon, 14, undefined, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const { appPath, replaceStateFn, gotoFn, isEditor, worldStore } =
|
||||
getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
let resolvedPath: string | undefined = undefined
|
||||
let resolvedLabel: string | undefined = undefined
|
||||
let resolvedDisabled: boolean | undefined = undefined
|
||||
let resolvedHidden: boolean | undefined = undefined
|
||||
|
||||
function extractPathDetails() {
|
||||
const url = window.location.pathname + window.location.search + window.location.hash
|
||||
const processedUrl = url.replace('/apps/edit/', '').replace('/apps/get/', '')
|
||||
return processedUrl
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
$selected = resolvedPath === extractPathDetails() ? resolvedPath : undefined
|
||||
})
|
||||
|
||||
let initialized: boolean = false
|
||||
|
||||
function initSelection() {
|
||||
initialized = true
|
||||
|
||||
if ($selected) return
|
||||
|
||||
$selected = resolvedPath === extractPathDetails() ? resolvedPath : undefined
|
||||
}
|
||||
|
||||
$: !initialized && resolvedPath && initSelection()
|
||||
|
||||
function getButtonProps(resolvedPath: string | undefined) {
|
||||
if (resolvedPath?.includes(appPath)) {
|
||||
return {
|
||||
onClick: () => {
|
||||
output.result.set({ currentPath: resolvedPath ?? '' })
|
||||
if (!resolvedPath) return
|
||||
const url = new URL(resolvedPath, window.location.origin)
|
||||
const queryParams = url.search
|
||||
const hash = url.hash
|
||||
replaceStateFn?.(`${window.location.pathname}${queryParams}${hash}`)
|
||||
|
||||
$worldStore.outputsById['ctx'].query.set(
|
||||
Object.fromEntries(new URLSearchParams(queryParams).entries())
|
||||
)
|
||||
$worldStore.outputsById['ctx'].hash.set(url.hash)
|
||||
|
||||
$selected = resolvedPath === extractPathDetails() ? resolvedPath : undefined
|
||||
},
|
||||
href: undefined,
|
||||
target: undefined
|
||||
}
|
||||
} else if (navbarItem.path.selected === 'app') {
|
||||
if (isEditor) {
|
||||
return {
|
||||
href: `/apps/get/${resolvedPath}`,
|
||||
target: '_blank' as const,
|
||||
onClick: undefined
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
onClick: () => {
|
||||
if (resolvedPath) {
|
||||
gotoFn?.(`/apps/get/${resolvedPath}`)
|
||||
}
|
||||
},
|
||||
href: undefined,
|
||||
target: undefined
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
href: resolvedPath,
|
||||
target: '_blank' as const,
|
||||
onClick: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: buttonProps = getButtonProps(resolvedPath)
|
||||
</script>
|
||||
|
||||
<ResolveNavbarItemPath {navbarItem} {id} {index} bind:resolvedPath />
|
||||
|
||||
<ResolveConfig
|
||||
{id}
|
||||
key={'label'}
|
||||
extraKey={String(index)}
|
||||
bind:resolvedConfig={resolvedLabel}
|
||||
configuration={navbarItem.label}
|
||||
/>
|
||||
|
||||
<ResolveConfig
|
||||
{id}
|
||||
key={'disabled'}
|
||||
extraKey={String(index)}
|
||||
bind:resolvedConfig={resolvedDisabled}
|
||||
configuration={navbarItem.disabled}
|
||||
/>
|
||||
|
||||
<ResolveConfig
|
||||
{id}
|
||||
key={'hidden'}
|
||||
extraKey={String(index)}
|
||||
bind:resolvedConfig={resolvedHidden}
|
||||
configuration={navbarItem.hidden}
|
||||
/>
|
||||
|
||||
{#if !resolvedHidden}
|
||||
<div
|
||||
class={twMerge('py-2', $selected === resolvedPath ? 'border-b-2 border-gray-500' : '')}
|
||||
style={`border-color: ${borderColor ?? 'transparent'}`}
|
||||
>
|
||||
<Button
|
||||
on:click={buttonProps.onClick}
|
||||
href={buttonProps.href}
|
||||
target={buttonProps.target ?? '_self'}
|
||||
color="light"
|
||||
size="xs"
|
||||
disabled={resolvedDisabled}
|
||||
>
|
||||
{#if navbarItem.icon}
|
||||
{#key navbarItem.icon}
|
||||
<div class="min-w-4" bind:this={icon} />
|
||||
{/key}
|
||||
{/if}
|
||||
{resolvedLabel ?? 'No Label'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { type NavbarItem } from '../../editor/component'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import { initConfig } from '../../editor/appUtils'
|
||||
|
||||
export let navbarItem: NavbarItem
|
||||
export let id: string
|
||||
export let index: number
|
||||
|
||||
export let resolvedPath: string | undefined = undefined
|
||||
|
||||
let resolvedConfig = initConfig({ path: navbarItem.path }, { path: navbarItem.path })
|
||||
|
||||
$: resolvedPath = (
|
||||
resolvedConfig?.path?.selected === 'href'
|
||||
? resolvedConfig?.path?.configuration?.href?.href
|
||||
: resolvedConfig?.path?.configuration?.app?.path +
|
||||
(resolvedConfig?.path?.configuration?.app?.queryParamsOrHash ?? '')
|
||||
) as string | undefined
|
||||
</script>
|
||||
|
||||
<ResolveConfig
|
||||
{id}
|
||||
key={'path'}
|
||||
extraKey={String(index)}
|
||||
bind:resolvedConfig={resolvedConfig.path}
|
||||
configuration={navbarItem.path}
|
||||
/>
|
||||
@@ -55,6 +55,7 @@
|
||||
import StylePanel from './settingsPanel/StylePanel.svelte'
|
||||
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import RunnableJobPanel from './RunnableJobPanel.svelte'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
|
||||
export let app: App
|
||||
export let path: string
|
||||
@@ -155,7 +156,8 @@
|
||||
darkMode,
|
||||
cssEditorOpen,
|
||||
previewTheme,
|
||||
debuggingComponents: writable({})
|
||||
debuggingComponents: writable({}),
|
||||
replaceStateFn: (path) => replaceState(path, $page.state)
|
||||
})
|
||||
|
||||
let scale = writable(100)
|
||||
@@ -551,6 +553,8 @@
|
||||
isEditor
|
||||
{context}
|
||||
noBackend={false}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
</div>
|
||||
</SplitPanesWrapper>
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
export let noBackend: boolean = false
|
||||
export let isLocked = false
|
||||
export let hideRefreshBar = false
|
||||
export let replaceStateFn: (path: string) => void = (path: string) =>
|
||||
window.history.replaceState(null, '', path)
|
||||
export let gotoFn: (path: string, opt?: Record<string, any> | undefined) => void = (
|
||||
path: string,
|
||||
opt?: Record<string, any>
|
||||
) => window.history.pushState(null, '', path)
|
||||
|
||||
migrateApp(app)
|
||||
|
||||
@@ -103,7 +109,9 @@
|
||||
darkMode,
|
||||
cssEditorOpen: writable(false),
|
||||
previewTheme: writable(undefined),
|
||||
debuggingComponents: writable({})
|
||||
debuggingComponents: writable({}),
|
||||
replaceStateFn,
|
||||
gotoFn
|
||||
})
|
||||
|
||||
let previousSelectedIds: string[] | undefined = undefined
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
import AppCurrencyInput from '../../components/inputs/currency/AppCurrencyInput.svelte'
|
||||
import AppSliderInputs from '../../components/inputs/AppSliderInputs.svelte'
|
||||
import AppNumberInput from '../../components/inputs/AppNumberInput.svelte'
|
||||
import AppNavbar from '../../components/display/AppNavbar.svelte'
|
||||
|
||||
export let component: AppComponent
|
||||
export let selected: boolean
|
||||
@@ -823,6 +824,14 @@
|
||||
verticalAlignment={component.verticalAlignment}
|
||||
{render}
|
||||
/>
|
||||
{:else if component.type === 'navbarcomponent'}
|
||||
<AppNavbar
|
||||
id={component.id}
|
||||
configuration={component.configuration}
|
||||
customCss={component.customCss}
|
||||
navbarItems={component.navbarItems}
|
||||
{render}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -843,4 +852,3 @@
|
||||
class="absolute inset-0 center-center flex-col bg- border animate-skeleton"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -48,13 +48,15 @@ import {
|
||||
UploadCloud,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CalendarClock
|
||||
CalendarClock,
|
||||
AppWindow
|
||||
} from 'lucide-svelte'
|
||||
import type {
|
||||
Aligned,
|
||||
BaseAppComponent,
|
||||
ComponentCustomCSS,
|
||||
GridItem,
|
||||
OneOfConfiguration,
|
||||
RichConfiguration,
|
||||
RichConfigurations,
|
||||
StaticRichConfigurations
|
||||
@@ -261,6 +263,19 @@ export type DecisionTreeComponent = BaseComponent<'decisiontreecomponent'> & {
|
||||
|
||||
export type AlertComponent = BaseComponent<'alertcomponent'>
|
||||
|
||||
export type NavbarItem = {
|
||||
path: OneOfConfiguration
|
||||
label: RichConfiguration
|
||||
caption?: string
|
||||
disabled: RichConfiguration
|
||||
hidden: RichConfiguration
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export type NavBarComponent = BaseComponent<'navbarcomponent'> & {
|
||||
navbarItems: NavbarItem[]
|
||||
}
|
||||
|
||||
export type TypedComponent =
|
||||
| DBExplorerComponent
|
||||
| DisplayComponent
|
||||
@@ -335,6 +350,7 @@ export type TypedComponent =
|
||||
| AggridInfiniteComponent
|
||||
| AggridInfiniteComponentEe
|
||||
| MultiSelectComponentV2
|
||||
| NavBarComponent
|
||||
|
||||
export type AppComponent = BaseAppComponent & TypedComponent
|
||||
|
||||
@@ -3835,6 +3851,66 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navbarcomponent: {
|
||||
name: 'Navbar',
|
||||
icon: AppWindow,
|
||||
documentationLink: `${documentationBaseUrl}/navbar`,
|
||||
dims: '12:1-12:2' as AppComponentDimensions,
|
||||
customCss: {
|
||||
container: { class: '', style: '' },
|
||||
image: { class: '', style: '' }
|
||||
},
|
||||
initialData: {
|
||||
...defaultAlignement,
|
||||
componentInput: undefined,
|
||||
configuration: {
|
||||
title: {
|
||||
type: 'static',
|
||||
fieldType: 'text',
|
||||
value: 'Title'
|
||||
},
|
||||
borderColor: {
|
||||
type: 'static',
|
||||
value: '#555',
|
||||
fieldType: 'color'
|
||||
},
|
||||
logo: {
|
||||
type: 'oneOf',
|
||||
selected: 'no',
|
||||
labels: {
|
||||
yes: 'Use logo',
|
||||
no: 'No logo'
|
||||
},
|
||||
configuration: {
|
||||
yes: {
|
||||
source: {
|
||||
type: 'static',
|
||||
value: '/logo.svg',
|
||||
fieldType: 'text',
|
||||
fileUpload: {
|
||||
accept: 'image/*',
|
||||
convertTo: 'base64'
|
||||
}
|
||||
},
|
||||
sourceKind: {
|
||||
fieldType: 'select',
|
||||
type: 'static',
|
||||
selectOptions: selectOptions.imageSourceKind,
|
||||
value: 'url' as (typeof selectOptions.imageSourceKind)[number]
|
||||
},
|
||||
altText: {
|
||||
type: 'static',
|
||||
value: '',
|
||||
fieldType: 'text',
|
||||
tooltip: "This text will appear if the image can't be loaded for any reason"
|
||||
}
|
||||
},
|
||||
no: {}
|
||||
}
|
||||
} as const
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const layout: ComponentSet = {
|
||||
'modalcomponent',
|
||||
'steppercomponent',
|
||||
'carousellistcomponent',
|
||||
'decisiontreecomponent'
|
||||
'decisiontreecomponent',
|
||||
'navbarcomponent'
|
||||
]
|
||||
} as const
|
||||
|
||||
|
||||
@@ -786,5 +786,8 @@ export const quickStyleProperties: Record<
|
||||
icon: containerDefaultProps,
|
||||
title: containerDefaultProps,
|
||||
description: containerDefaultProps
|
||||
},
|
||||
navbarcomponent: {
|
||||
container: containerDefaultProps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
const name = getComponentNameById(gridItem.id)
|
||||
|
||||
$: nameOverrides =
|
||||
gridItem.data.type === 'decisiontreecomponent'
|
||||
gridItem?.data?.type === 'decisiontreecomponent'
|
||||
? gridItem.data.nodes.map((n, i) => `${n.label} (Tab index ${i})`)
|
||||
: undefined
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: subGrids = Array.from({ length: gridItem.data.numberOfSubgrids ?? 0 }).map(
|
||||
$: subGrids = Array.from({ length: gridItem.data?.numberOfSubgrids ?? 0 }).map(
|
||||
(_, i) => `${gridItem.id}-${i}`
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ContextVariables from './ContextVariables.svelte'
|
||||
import EventHandlers from './EventHandlers.svelte'
|
||||
import GridNavbar from './GridNavbar.svelte'
|
||||
|
||||
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
|
||||
undefined
|
||||
@@ -329,6 +330,9 @@
|
||||
|
||||
<ComponentControl type={component.type} />
|
||||
|
||||
{#if componentSettings.item.data.type === 'navbarcomponent'}
|
||||
<GridNavbar bind:navbarItems={componentSettings.item.data.navbarItems} id={component.id} />
|
||||
{/if}
|
||||
{#if componentSettings.item.data.type === 'tabscomponent'}
|
||||
<GridTab
|
||||
bind:tabs={componentSettings.item.data.tabs}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import CloseButton from '$lib/components/common/CloseButton.svelte'
|
||||
import PanelSection from './common/PanelSection.svelte'
|
||||
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
|
||||
import { generateRandomString } from '$lib/utils'
|
||||
import { GripVertical, Plus, Settings } from 'lucide-svelte'
|
||||
import type { NavbarItem } from '../component'
|
||||
import NavbarWizard from '$lib/components/wizards/NavbarWizard.svelte'
|
||||
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import ResolveConfig from '../../components/helpers/ResolveConfig.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { AppViewerContext } from '../../types'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { StaticAppInput } from '../../inputType'
|
||||
import ResolveNavbarItemPath from '../../components/display/ResolveNavbarItemPath.svelte'
|
||||
|
||||
export let navbarItems: NavbarItem[] = []
|
||||
export let id: string
|
||||
|
||||
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
let items = navbarItems.map((tab, index) => {
|
||||
return { value: tab, id: generateRandomString(), originalIndex: index }
|
||||
})
|
||||
|
||||
$: navbarItems = items.map((item) => item.value)
|
||||
|
||||
function addPath() {
|
||||
const emptyAppPath: NavbarItem = {
|
||||
disabled: {
|
||||
type: 'static',
|
||||
value: false,
|
||||
fieldType: 'boolean'
|
||||
},
|
||||
label: {
|
||||
type: 'static',
|
||||
value: undefined,
|
||||
fieldType: 'text'
|
||||
},
|
||||
|
||||
path: {
|
||||
type: 'oneOf',
|
||||
selected: 'app',
|
||||
labels: {
|
||||
href: 'Navigate to an external URL',
|
||||
app: 'Navigate to an app'
|
||||
},
|
||||
configuration: {
|
||||
href: {
|
||||
href: {
|
||||
type: 'static',
|
||||
value: undefined,
|
||||
fieldType: 'text',
|
||||
tooltip:
|
||||
"The URL to navigate to when the item is clicked. Will be opened in a new tab. If you want to navigate to an other app, use the 'App' option."
|
||||
}
|
||||
},
|
||||
app: {
|
||||
path: {
|
||||
type: 'static',
|
||||
value: '',
|
||||
fieldType: 'app-path',
|
||||
allowTypeChange: false,
|
||||
tooltip:
|
||||
'The app to navigate to when the item is clicked. Will be opened in the same tab. If you want to navigate to an external URL, use the "Href" option.'
|
||||
} as StaticAppInput,
|
||||
queryParamsOrHash: {
|
||||
type: 'static',
|
||||
value: undefined,
|
||||
fieldType: 'text',
|
||||
tooltip:
|
||||
'Query parameters or hash to append to the URL. For example, `?key=value` or `#hash`.',
|
||||
placeholder: '?key=value#hash'
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const,
|
||||
hidden: {
|
||||
type: 'static',
|
||||
value: false,
|
||||
fieldType: 'boolean'
|
||||
}
|
||||
}
|
||||
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
value: emptyAppPath,
|
||||
id: generateRandomString(),
|
||||
originalIndex: items.length
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent): void {
|
||||
const { items: newItems } = e.detail
|
||||
items = newItems
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent) {
|
||||
const { items: newItems } = e.detail
|
||||
|
||||
items = newItems
|
||||
}
|
||||
|
||||
let resolvedPaths: string[] = []
|
||||
let resolvedLabels: string[] = []
|
||||
</script>
|
||||
|
||||
<PanelSection
|
||||
title={`Items ${navbarItems && navbarItems.length > 0 ? `(${navbarItems.length})` : ''}`}
|
||||
>
|
||||
{#if !navbarItems || navbarItems.length == 0}
|
||||
<span class="text-xs text-tertiary">No items</span>
|
||||
{/if}
|
||||
<div class="w-full flex gap-2 flex-col mt-2">
|
||||
<section
|
||||
use:dragHandleZone={{
|
||||
items,
|
||||
flipDurationMs: 200,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
on:consider={handleConsider}
|
||||
on:finalize={handleFinalize}
|
||||
>
|
||||
{#each items as item, index (item.id)}
|
||||
{#key item.id}
|
||||
<div class="border rounded-md p-2 mb-2 bg-surface">
|
||||
<ResolveConfig
|
||||
{id}
|
||||
key={'label'}
|
||||
extraKey={item.id}
|
||||
bind:resolvedConfig={resolvedLabels[item.originalIndex]}
|
||||
configuration={item.value.label}
|
||||
/>
|
||||
|
||||
<div class="w-full flex flex-row gap-2 items-center relative my-1">
|
||||
<div
|
||||
class="text-xs px-2 border-y flex flex-row items-center border rounded-md h-8 w-full"
|
||||
>
|
||||
{resolvedLabels[item.originalIndex] ?? 'No label'}
|
||||
</div>
|
||||
|
||||
<div class="absolute right-[4.5rem]">
|
||||
<CloseButton
|
||||
noBg
|
||||
small
|
||||
on:close={() => {
|
||||
items = items.filter((_, i) => i !== index)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<NavbarWizard bind:value={items[index].value}>
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button color="light" size="xs2" nonCaptureEvent={true}>
|
||||
<div class="flex flex-row items-center gap-2 text-xs font-normal">
|
||||
<Settings size={16} />
|
||||
</div>
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</NavbarWizard>
|
||||
|
||||
<div class="flex flex-col justify-center gap-2">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div use:dragHandle class="handle w-4 h-4" aria-label="drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResolveNavbarItemPath
|
||||
navbarItem={item.value}
|
||||
{id}
|
||||
{index}
|
||||
bind:resolvedPath={resolvedPaths[item.originalIndex]}
|
||||
/>
|
||||
|
||||
{#if resolvedPaths[item.originalIndex]}
|
||||
<div class="text-xs text-tertiary flex gap-2 flex-row flex-wrap">
|
||||
Path: <Badge small>{resolvedPaths[item.originalIndex]}</Badge>
|
||||
{#if appPath && resolvedPaths[item.originalIndex]?.includes(appPath)}
|
||||
<Badge small color="blue"
|
||||
>Current app
|
||||
|
||||
<Tooltip class="ml-2 !text-blue-900">
|
||||
Clicking on those items will keep you in the current tab and change the output
|
||||
of the component.
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-red-500">No app path or url selected</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</section>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={addPath}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
+13
-12
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { StaticInput } from '../../../inputType'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { ClearableInput, Popup } from '../../../../common'
|
||||
import { AllIcons } from './icons'
|
||||
export let componentInput: StaticInput<string>
|
||||
import type { ComputeConfig } from 'svelte-floating-ui'
|
||||
|
||||
export let value: string | undefined = ''
|
||||
|
||||
let loading = false
|
||||
let items: string[]
|
||||
@@ -27,28 +28,28 @@
|
||||
}
|
||||
|
||||
function select(label: string) {
|
||||
componentInput.value = label
|
||||
value = label
|
||||
|
||||
const elem = document.activeElement as HTMLElement
|
||||
if (elem.blur) {
|
||||
elem.blur()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popup
|
||||
let:close
|
||||
floatingConfig={{
|
||||
export let floatingConfig: ComputeConfig = {
|
||||
strategy: 'absolute',
|
||||
placement: 'bottom-end'
|
||||
}}
|
||||
>
|
||||
}
|
||||
export let shouldUsePortal: boolean = true
|
||||
</script>
|
||||
|
||||
<Popup let:close {floatingConfig} {shouldUsePortal}>
|
||||
<svelte:fragment slot="button">
|
||||
<div class="relative">
|
||||
<ClearableInput
|
||||
readonly
|
||||
value={componentInput.value}
|
||||
on:change={({ detail }) => (componentInput.value = detail)}
|
||||
{value}
|
||||
on:change={({ detail }) => (value = detail)}
|
||||
on:focus={getData}
|
||||
class="!pr-6"
|
||||
/>
|
||||
@@ -84,7 +85,7 @@
|
||||
}}
|
||||
class="w-full center-center flex-col font-normal p-1
|
||||
hover:bg-gray-100 focus:bg-gray-100 rounded duration-200 dark:hover:bg-frost-900 dark:focus:bg-frost-900
|
||||
{label === componentInput.value ? 'text-blue-600 bg-blue-50 pointer-events-none' : ''}"
|
||||
{label === value ? 'text-blue-600 bg-blue-50 pointer-events-none' : ''}"
|
||||
>
|
||||
<img
|
||||
class="dark:invert"
|
||||
|
||||
+4
-1
@@ -22,6 +22,7 @@
|
||||
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
|
||||
import DBTableSelect from './DBTableSelect.svelte'
|
||||
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
|
||||
import AppPicker from '$lib/components/wizards/AppPicker.svelte'
|
||||
|
||||
export let componentInput: StaticInput<any> | undefined
|
||||
export let fieldType: InputType | undefined = undefined
|
||||
@@ -69,7 +70,7 @@
|
||||
</select>
|
||||
{/if}
|
||||
{:else if fieldType === 'icon-select'}
|
||||
<IconSelectInput bind:componentInput />
|
||||
<IconSelectInput bind:value={componentInput.value} />
|
||||
{:else if fieldType === 'tab-select'}
|
||||
<TabSelectInput bind:componentInput />
|
||||
{:else if fieldType === 'resource' && subFieldType && ['mysql', 'postgres', 'ms_sql_server', 'snowflake', 'bigquery'].includes(subFieldType)}
|
||||
@@ -313,6 +314,8 @@
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{:else if fieldType === 'app-path'}
|
||||
<AppPicker bind:value={componentInput.value} />
|
||||
{:else}
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<textarea
|
||||
|
||||
@@ -39,6 +39,7 @@ export type InputType =
|
||||
| 'ms_sql_server'
|
||||
| 'snowflake'
|
||||
| 'bigquery'
|
||||
| 'app-path'
|
||||
|
||||
// Connection to an output of another component
|
||||
// defined by the id of the component and the path of the output
|
||||
@@ -225,6 +226,7 @@ export type AppInput =
|
||||
| AppInputSpec<'resource', string, 'snowflake'>
|
||||
| AppInputSpec<'resource', string, 'bigquery'>
|
||||
| AppInputSpec<'array', object[], 'number-tuple'>
|
||||
| AppInputSpec<'app-path', string>
|
||||
|
||||
export type RowAppInput = Extract<AppInput, { type: 'row' }>
|
||||
export type StaticAppInput = Extract<AppInput, { type: 'static' }>
|
||||
|
||||
@@ -269,6 +269,8 @@ export type AppViewerContext = {
|
||||
cssEditorOpen: Writable<boolean>
|
||||
previewTheme: Writable<string | undefined>
|
||||
debuggingComponents: Writable<Record<string, number>>
|
||||
replaceStateFn?: ((url: string) => void) | undefined
|
||||
gotoFn?: ((url: string, opt?: Record<string, any> | undefined) => void) | undefined
|
||||
}
|
||||
|
||||
export type AppEditorContext = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@rgossiaux/svelte-headlessui'
|
||||
import Portal from 'svelte-portal'
|
||||
import ConditionalPortal from '../drawer/ConditionalPortal.svelte'
|
||||
import { createFloatingActions, type ComputeConfig } from 'svelte-floating-ui'
|
||||
|
||||
export let floatingConfig: ComputeConfig = {
|
||||
@@ -13,6 +13,7 @@
|
||||
const [floatingRef, floatingContent] = createFloatingActions(floatingConfig)
|
||||
|
||||
export let blockOpen = false
|
||||
export let shouldUsePortal: boolean = true
|
||||
</script>
|
||||
|
||||
<Popover on:close class="leading-none">
|
||||
@@ -21,7 +22,7 @@
|
||||
<slot name="button" />
|
||||
</div>
|
||||
</PopoverButton>
|
||||
<Portal>
|
||||
<ConditionalPortal condition={shouldUsePortal}>
|
||||
<div use:floatingContent class="z5000">
|
||||
<Transition
|
||||
show={blockOpen || undefined}
|
||||
@@ -39,5 +40,5 @@
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</div>
|
||||
</Portal>
|
||||
</ConditionalPortal>
|
||||
</Popover>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import Select from '../apps/svelte-select/lib/Select.svelte'
|
||||
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
|
||||
import DarkModeObserver from '../DarkModeObserver.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { AppService, type ListableApp } from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import type { AppViewerContext } from '../apps/types'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
|
||||
export let value = ''
|
||||
export let selecteValue = value
|
||||
let darkMode = false
|
||||
|
||||
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
let apps: ListableApp[] = []
|
||||
|
||||
async function loadApps(): Promise<void> {
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
|
||||
(app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadApps()
|
||||
|
||||
if (selecteValue === '') {
|
||||
selecteValue = appPath
|
||||
value = appPath
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<Select
|
||||
class="grow shrink max-w-full"
|
||||
on:change={(e) => {
|
||||
value = e.detail.value
|
||||
}}
|
||||
on:clear={() => {
|
||||
value = ''
|
||||
}}
|
||||
bind:value={selecteValue}
|
||||
items={apps.map((app) => {
|
||||
return {
|
||||
value: app.path,
|
||||
label: app.path === appPath ? `${app.path} (current app)` : app.path
|
||||
}
|
||||
})}
|
||||
placeholder="Pick an app"
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
portal={false}
|
||||
/>
|
||||
{#if !appPath}
|
||||
<Alert title="Current app not selectable" size="xs" type="warning" collapsible>
|
||||
Current app is not selectable until you have deployed this app at least once.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if appPath && appPath === value}
|
||||
<div class="text-2xs">
|
||||
The current app is selected. If the path changes, the path needs to be updated manually.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { Popup } from '../common'
|
||||
import { offset, flip, shift } from 'svelte-floating-ui/dom'
|
||||
import type { NavbarItem } from '../apps/editor/component'
|
||||
import Label from '../Label.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import Section from '../Section.svelte'
|
||||
import IconSelectInput from '../apps/editor/settingsPanel/inputEditor/IconSelectInput.svelte'
|
||||
import InputsSpecEditor from '../apps/editor/settingsPanel/InputsSpecEditor.svelte'
|
||||
import type { AppViewerContext } from '../apps/types'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import OneOfInputSpecsEditor from '../apps/editor/settingsPanel/OneOfInputSpecsEditor.svelte'
|
||||
|
||||
export let value: NavbarItem
|
||||
|
||||
const { selectedComponent } = getContext<AppViewerContext>('AppViewerContext')
|
||||
</script>
|
||||
|
||||
<Popup
|
||||
floatingConfig={{
|
||||
strategy: 'fixed',
|
||||
placement: 'left-end',
|
||||
middleware: [offset(8), flip(), shift()]
|
||||
}}
|
||||
containerClasses="border rounded-lg shadow-lg bg-surface p-4"
|
||||
>
|
||||
<svelte:fragment slot="button">
|
||||
<slot name="trigger" />
|
||||
</svelte:fragment>
|
||||
|
||||
{#if value}
|
||||
<Section label="Navbar item" class="flex flex-col gap-2 w-80 overflow-y-auto max-h-screen">
|
||||
<InputsSpecEditor
|
||||
key={'Label'}
|
||||
bind:componentInput={value.label}
|
||||
id={$selectedComponent?.[0] ?? ''}
|
||||
userInputEnabled={false}
|
||||
shouldCapitalize={true}
|
||||
resourceOnly={false}
|
||||
fieldType={value.label?.['fieldType']}
|
||||
subFieldType={value.label?.['subFieldType']}
|
||||
format={value.label?.['format']}
|
||||
selectOptions={value.label?.['selectOptions']}
|
||||
fileUpload={value.label?.['fileUpload']}
|
||||
placeholder={value.label?.['placeholder']}
|
||||
customTitle={value.label?.['customTitle']}
|
||||
displayType={false}
|
||||
/>
|
||||
|
||||
<OneOfInputSpecsEditor
|
||||
key={'Link'}
|
||||
bind:oneOf={value.path}
|
||||
id={$selectedComponent?.[0] ?? ''}
|
||||
shouldCapitalize={true}
|
||||
resourceOnly={false}
|
||||
inputSpecsConfiguration={value.path?.['configuration']}
|
||||
labels={value.path?.['labels']}
|
||||
tooltip={value.path?.['tooltip']}
|
||||
/>
|
||||
|
||||
<Alert size="xs" title="Link Behavior" collapsible>
|
||||
<ul class="list-disc">
|
||||
<li>
|
||||
If you select an app, there are two cases:
|
||||
<div class="ml-2">
|
||||
<ul class="list-disc">
|
||||
<li>
|
||||
You selected the current app itself: Clicking on the link will highlight the item,
|
||||
and set the app in the output. Note that adding query params or an hash lets you
|
||||
distinguish between different items. Also note that query params can be retrieved
|
||||
from the context: `ctx.query`.
|
||||
</li>
|
||||
<li>
|
||||
You selected another app: Clicking on the link navigates to the selected app
|
||||
without reloading the page. In the editor, it will open in a new tab.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
If you select an external link, clicking on the link will navigate to the selected link
|
||||
in a new tab.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
|
||||
<InputsSpecEditor
|
||||
key={'Disabled'}
|
||||
bind:componentInput={value.disabled}
|
||||
id={$selectedComponent?.[0] ?? ''}
|
||||
userInputEnabled={false}
|
||||
shouldCapitalize={true}
|
||||
resourceOnly={false}
|
||||
fieldType={value.disabled?.['fieldType']}
|
||||
subFieldType={value.disabled?.['subFieldType']}
|
||||
format={value.disabled?.['format']}
|
||||
selectOptions={value.disabled?.['selectOptions']}
|
||||
fileUpload={value.disabled?.['fileUpload']}
|
||||
placeholder={value.disabled?.['placeholder']}
|
||||
customTitle={value.disabled?.['customTitle']}
|
||||
displayType={false}
|
||||
/>
|
||||
<InputsSpecEditor
|
||||
key={'Hidden'}
|
||||
bind:componentInput={value.hidden}
|
||||
id={$selectedComponent?.[0] ?? ''}
|
||||
userInputEnabled={false}
|
||||
shouldCapitalize={true}
|
||||
resourceOnly={false}
|
||||
fieldType={value.hidden?.['fieldType']}
|
||||
subFieldType={value.hidden?.['subFieldType']}
|
||||
format={value.hidden?.['format']}
|
||||
selectOptions={value.hidden?.['selectOptions']}
|
||||
fileUpload={value.hidden?.['fileUpload']}
|
||||
placeholder={value.hidden?.['placeholder']}
|
||||
customTitle={value.hidden?.['customTitle']}
|
||||
displayType={false}
|
||||
/>
|
||||
|
||||
<Label label="Icon" class="w-full">
|
||||
<IconSelectInput
|
||||
bind:value={value.icon}
|
||||
floatingConfig={{
|
||||
strategy: 'fixed',
|
||||
placement: 'left-end',
|
||||
middleware: [offset(8), flip(), shift()]
|
||||
}}
|
||||
shouldUsePortal={false}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Caption">
|
||||
<input type="text" bind:value={value.caption} />
|
||||
</Label>
|
||||
</Section>
|
||||
{/if}
|
||||
</Popup>
|
||||
@@ -23,6 +23,7 @@
|
||||
import { HOME_SHOW_HUB, HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
|
||||
type Tab = 'hub' | 'workspace'
|
||||
|
||||
@@ -197,6 +198,8 @@
|
||||
}}
|
||||
summary={appViewerApp?.app.summary ?? ''}
|
||||
noBackend
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
@@ -20,7 +21,11 @@
|
||||
}
|
||||
|
||||
$: if ($workspaceStore && $page.params.path) {
|
||||
loadApp()
|
||||
if (app && $page.params.path === app.path) {
|
||||
console.log('App already loaded')
|
||||
} else {
|
||||
loadApp()
|
||||
}
|
||||
}
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
@@ -56,6 +61,8 @@
|
||||
isEditor={false}
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
{#if can_write && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { User, UserRoundX } from 'lucide-svelte'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
|
||||
let app: (AppWithLastVersion & { value: any }) | undefined = undefined
|
||||
let notExists = false
|
||||
@@ -121,6 +122,8 @@
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
Reference in New Issue
Block a user