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:
Faton Ramadani
2024-07-04 19:50:00 +02:00
committed by GitHub
parent 0d85593644
commit a2b707f2cb
22 changed files with 860 additions and 24 deletions
@@ -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>
@@ -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"
@@ -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}