mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 16:02:43 +00:00
feat: group home row menus and open them at their final height (#11949)
This commit is contained in:
@@ -99,7 +99,10 @@
|
||||
} = createDropdownMenu({
|
||||
portal: untrack(() => hostPortal()),
|
||||
positioning: {
|
||||
placement: untrack(() => placement)
|
||||
placement: untrack(() => placement),
|
||||
// Caps the menu at the room the viewport has left, so a list taller than that scrolls
|
||||
// instead of flipping past the screen edge. A custom menu sizes itself.
|
||||
fitViewport: untrack(() => !customMenu)
|
||||
},
|
||||
closeOnItemClick: untrack(() => closeOnItemClick),
|
||||
loop: true,
|
||||
@@ -137,13 +140,12 @@
|
||||
if (afterClose) setTimeout(afterClose, 10)
|
||||
}
|
||||
|
||||
async function computeItems(): Promise<Item[]> {
|
||||
if (typeof items === 'function') {
|
||||
const result = await items()
|
||||
return Array.isArray(result) ? result.filter((item) => !item.hide) : []
|
||||
} else {
|
||||
return items.filter((item) => !item.hide)
|
||||
}
|
||||
function visible(result: Item[] | undefined): Item[] {
|
||||
return Array.isArray(result) ? result.filter((item) => !item.hide) : []
|
||||
}
|
||||
function computeItems(): Item[] | Promise<Item[]> {
|
||||
const result = typeof items === 'function' ? items() : items
|
||||
return result instanceof Promise ? result.then(visible) : visible(result)
|
||||
}
|
||||
async function getMenuElements(): Promise<HTMLElement[]> {
|
||||
// Runs on every pointerdown anywhere, for every mounted dropdown. Skip the
|
||||
@@ -200,15 +202,15 @@
|
||||
<div
|
||||
use:melt={$menuEl}
|
||||
data-menu
|
||||
class="z-[6000] transition-all duration-100"
|
||||
class="z-[6000] transition-all duration-100 flex flex-col"
|
||||
transition:placementFly={{ duration: enableFlyTransition ? 100 : 0, placement }}
|
||||
>
|
||||
{#if customMenu}
|
||||
{@render menu?.({ item, close, builders })}
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`}
|
||||
class="bg-surface-tertiary dark:border w-56 min-h-0 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '80vh'};`}
|
||||
>
|
||||
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} {close} />
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Tooltip } from './meltComponents'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
aiId?: string
|
||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||
items?: Item[] | (() => Item[] | Promise<Item[]>)
|
||||
meltItem: MenubarMenuElements['item']
|
||||
builders?: ReturnType<typeof createDropdownMenu>['builders']
|
||||
close?: (afterClose?: () => void) => void
|
||||
@@ -18,16 +19,17 @@
|
||||
|
||||
let { aiId, items = [], meltItem, builders, close }: Props = $props()
|
||||
|
||||
let computedItems: Item[] | undefined = $state(undefined)
|
||||
async function computeItems() {
|
||||
if (typeof items === 'function') {
|
||||
computedItems = ((await items()) ?? []).filter((item) => !item.hide)
|
||||
} else {
|
||||
computedItems = items.filter((item) => !item.hide)
|
||||
}
|
||||
function visible(result: Item[] | undefined): Item[] {
|
||||
return (result ?? []).filter((item) => !item.hide)
|
||||
}
|
||||
|
||||
computeItems()
|
||||
// Items returned synchronously render on the first frame: awaiting them would show the
|
||||
// spinner first, the popover would be placed for the spinner's height, then flip.
|
||||
const initial = untrack(() => (typeof items === 'function' ? items() : items))
|
||||
let computedItems: Item[] | undefined = $state(
|
||||
initial instanceof Promise ? undefined : visible(initial)
|
||||
)
|
||||
if (initial instanceof Promise) initial.then((result) => (computedItems = visible(result)))
|
||||
</script>
|
||||
|
||||
{#snippet menuItem(item: Item)}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
|
||||
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import Row from './Row.svelte'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
warmDeployUiSettings()
|
||||
|
||||
async function remove(path: string) {
|
||||
if (await deleteAgent($workspaceStore!, path)) dispatch('change')
|
||||
}
|
||||
@@ -106,10 +108,10 @@
|
||||
{/if}
|
||||
<Dropdown
|
||||
size="sm"
|
||||
items={async () => [
|
||||
items={() => [
|
||||
...selectMenuItems(rowSelection),
|
||||
...agentMenuItems({
|
||||
deployUiSettings: await getDeployUiSettings(),
|
||||
deployUiSettings: cachedDeployUiSettings(),
|
||||
path: agent.path,
|
||||
canWrite: agent.canWrite,
|
||||
draftOnly: Boolean(agent.draft_only),
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { menuGroups } from './menuGroups'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
@@ -69,6 +70,8 @@
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
warmDeployUiSettings()
|
||||
// A compose-only app role authors full-code apps, never low-code ones.
|
||||
const canAuthorApp = $derived(roleCanAuthor(app.raw_app ? 'raw_app' : 'app', $roleCapabilities))
|
||||
const fullApps = $derived($roleCapabilities.apps === 'full')
|
||||
@@ -167,27 +170,155 @@
|
||||
size="sm"
|
||||
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
|
||||
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
|
||||
items={async () => {
|
||||
items={() => {
|
||||
let { draft_only, canWrite, summary, execution_mode, path } = app
|
||||
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
|
||||
icon: File,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', {
|
||||
storagePath: path,
|
||||
rawApp: !!app.raw_app
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: !fullApps
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: async (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path. The server
|
||||
// lets one's own draft go without authoring rights; only a legacy one needs them.
|
||||
disabled: !showEditButton,
|
||||
hide:
|
||||
!canAuthorApp &&
|
||||
app.draft_users?.length === 1 &&
|
||||
app.draft_users[0]?.username == null
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
|
||||
icon: FileJson,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Go to public page',
|
||||
icon: ExternalLink,
|
||||
action: async () => {
|
||||
let secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
let url =
|
||||
page.url.protocol +
|
||||
'//' +
|
||||
`${page.url.hostname}/public/${$workspaceStore}/${secretUrl}`
|
||||
gotoUrl(url)
|
||||
},
|
||||
hide: execution_mode != 'anonymous'
|
||||
},
|
||||
{
|
||||
displayName: 'Versions',
|
||||
icon: History,
|
||||
action: () => appDeploymentHistory?.open(),
|
||||
hide: !canAuthorApp
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: !fullApps
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', {
|
||||
storagePath: path,
|
||||
rawApp: !!app.raw_app
|
||||
})
|
||||
moveDrawer.openDrawer(path, summary, 'app')
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
disabled: !canEdit,
|
||||
hide: !fullApps
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'app')
|
||||
},
|
||||
hide: !canAuthorApp
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'app')
|
||||
},
|
||||
hide: !fullApps || !isDeployable('app', path, cachedDeployUiSettings())
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
@@ -205,134 +336,11 @@
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path. The server
|
||||
// lets one's own draft go without authoring rights; only a legacy one needs them.
|
||||
disabled: !showEditButton,
|
||||
hide:
|
||||
!canAuthorApp &&
|
||||
app.draft_users?.length === 1 &&
|
||||
app.draft_users[0]?.username == null
|
||||
},
|
||||
{
|
||||
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
|
||||
icon: File,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
disabled: !canEdit,
|
||||
hide: !canAuthorApp
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: !fullApps
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'app')
|
||||
},
|
||||
disabled: !canEdit,
|
||||
hide: !fullApps
|
||||
},
|
||||
...(isDeployable('app', path, await getDeployUiSettings())
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'app')
|
||||
},
|
||||
hide: !fullApps
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: fullApps ? 'View/Edit JSON' : 'View JSON',
|
||||
icon: FileJson,
|
||||
action: () => {
|
||||
loadAppJson()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Deployments',
|
||||
icon: History,
|
||||
action: () => appDeploymentHistory?.open(),
|
||||
hide: !canAuthorApp
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'app')
|
||||
},
|
||||
hide: !canAuthorApp
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
},
|
||||
...(execution_mode == 'anonymous'
|
||||
? [
|
||||
{
|
||||
displayName: 'Go to public page',
|
||||
icon: ExternalLink,
|
||||
action: async () => {
|
||||
let secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path
|
||||
})
|
||||
let url =
|
||||
page.url.protocol +
|
||||
'//' +
|
||||
`${page.url.hostname}/public/${$workspaceStore}/${secretUrl}`
|
||||
gotoUrl(url)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: async (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
} else {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await deleteApp(path)
|
||||
dispatch('change')
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canEdit,
|
||||
hide: !canAuthorApp
|
||||
}
|
||||
]
|
||||
)
|
||||
}}
|
||||
on:open={() => {
|
||||
menuOpen = true
|
||||
|
||||
@@ -32,13 +32,15 @@
|
||||
Archive,
|
||||
Copy,
|
||||
Eye,
|
||||
HistoryIcon
|
||||
HistoryIcon,
|
||||
Clock
|
||||
} from 'lucide-svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
|
||||
import RowLabels from './RowLabels.svelte'
|
||||
import AskAiRowButton from './AskAiRowButton.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { menuGroups, submenu } from './menuGroups'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
@@ -86,6 +88,8 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
warmDeployUiSettings()
|
||||
|
||||
async function archiveFlow(path: string, archived: boolean): Promise<void> {
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({
|
||||
@@ -199,24 +203,152 @@
|
||||
size="sm"
|
||||
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
|
||||
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
|
||||
items={async () => {
|
||||
items={() => {
|
||||
let { draft_only, path, archived } = flow
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
const canEdit = flow.canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
|
||||
storagePath: path
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: hideForOperator
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path.
|
||||
disabled: !showEditButton,
|
||||
hide: hideForOperator
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
submenu('History', Clock, [
|
||||
{
|
||||
displayName: 'Versions',
|
||||
icon: HistoryIcon,
|
||||
action: () => {
|
||||
flowHistory?.open()
|
||||
},
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `/audit_logs?resource=${path}`,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
|
||||
}
|
||||
])
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/flows/add?template=${path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'flow', path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
|
||||
storagePath: path
|
||||
})
|
||||
moveDrawer.openDrawer(path, flow.summary, 'flow')
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
disabled: !owner || archived || !canEdit,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: Calendar,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(true, path)
|
||||
},
|
||||
disabled: archived,
|
||||
hide: hideForOperator || !$roleCapabilities.manage_schedules
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
|
||||
},
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'flow')
|
||||
},
|
||||
disabled: archived,
|
||||
hide:
|
||||
$roleCapabilities.flows !== 'full' ||
|
||||
!isDeployable('flow', path, cachedDeployUiSettings())
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: archived ? 'Unarchive' : 'Archive',
|
||||
icon: Archive,
|
||||
action: () => {
|
||||
path && archiveFlow(path, !archived)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner || !canEdit,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
@@ -233,127 +365,11 @@
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path.
|
||||
disabled: !showEditButton,
|
||||
disabled: !owner || !canEdit,
|
||||
hide: hideForOperator
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/flows/add?template=${path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'flow', path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `/audit_logs?resource=${path}`,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, flow.summary, 'flow')
|
||||
},
|
||||
disabled: !owner || archived || !canEdit,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(path)
|
||||
}
|
||||
},
|
||||
...(isDeployable('flow', path, await getDeployUiSettings())
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'flow')
|
||||
},
|
||||
disabled: archived,
|
||||
hide: $roleCapabilities.flows !== 'full'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Deployments',
|
||||
icon: HistoryIcon,
|
||||
action: () => {
|
||||
flowHistory?.open()
|
||||
},
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: Calendar,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(true, path)
|
||||
},
|
||||
disabled: archived,
|
||||
hide: hideForOperator || !$roleCapabilities.manage_schedules
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
|
||||
},
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: archived ? 'Unarchive' : 'Archive',
|
||||
icon: Archive,
|
||||
action: () => {
|
||||
path && archiveFlow(path, !archived)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner || !canEdit,
|
||||
hide: hideForOperator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner || !canEdit,
|
||||
hide: hideForOperator
|
||||
}
|
||||
]
|
||||
)
|
||||
}}
|
||||
on:open={() => {
|
||||
menuOpen = true
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { Globe, Shield } from 'lucide-svelte'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
|
||||
interface Props {
|
||||
app: ListableRawApp & { canWrite: boolean }
|
||||
@@ -29,6 +29,8 @@
|
||||
menuOpen = $bindable(false),
|
||||
keyboardSelected = false
|
||||
}: Props = $props()
|
||||
|
||||
warmDeployUiSettings()
|
||||
</script>
|
||||
|
||||
<Row
|
||||
@@ -48,11 +50,11 @@
|
||||
{#snippet actions()}
|
||||
<Dropdown
|
||||
size="sm"
|
||||
items={async () => {
|
||||
items={() => {
|
||||
let { path } = app
|
||||
|
||||
return [
|
||||
...(isDeployable('app', path, await getDeployUiSettings())
|
||||
...(isDeployable('app', path, cachedDeployUiSettings())
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
Shield,
|
||||
Trash,
|
||||
History,
|
||||
FileText
|
||||
FileText,
|
||||
Clock,
|
||||
Send
|
||||
} from 'lucide-svelte'
|
||||
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
|
||||
import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte'
|
||||
@@ -55,7 +57,8 @@
|
||||
import AskAiRowButton from './AskAiRowButton.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { menuGroups, submenu } from './menuGroups'
|
||||
import {
|
||||
claimTab,
|
||||
editInForkAllowed,
|
||||
@@ -105,6 +108,8 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
warmDeployUiSettings()
|
||||
|
||||
async function archiveScript(path: string): Promise<void> {
|
||||
await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
@@ -262,12 +267,66 @@
|
||||
size="sm"
|
||||
aiId={`script-row-dropdown-${script.summary?.length > 0 ? script.summary : script.path}`}
|
||||
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
|
||||
items={async () => {
|
||||
items={() => {
|
||||
let owner = isOwner(script.path, $userStore, $workspaceStore)
|
||||
const canEdit = script.canWrite && showEditButton
|
||||
if (script.draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
action: () => {
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer(
|
||||
script.draft_path ?? script.path,
|
||||
script.summary,
|
||||
'script',
|
||||
{
|
||||
storagePath: script.path
|
||||
}
|
||||
)
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: !$roleCapabilities.code
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(script.path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteScript(script.path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path.
|
||||
disabled: !showEditButton
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
return menuGroups(
|
||||
selectMenuItems(rowSelection),
|
||||
[
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
@@ -275,25 +334,154 @@
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'View JSON/YAML',
|
||||
icon: FileText,
|
||||
action: async () => {
|
||||
const fullScript = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
})
|
||||
wacExportDrawer?.open(fullScript)
|
||||
},
|
||||
hide: script.auto_kind !== 'wac'
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `/runs/${script.path}`
|
||||
},
|
||||
submenu('History', Clock, [
|
||||
{
|
||||
displayName: 'Versions',
|
||||
icon: History,
|
||||
action: () => {
|
||||
versionsDrawerOpen = true
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `/audit_logs?resource=${script.path}`,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
|
||||
}
|
||||
])
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/scripts/add?template=${script.path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'script', script.path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(script.path)
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', {
|
||||
storagePath: script.path
|
||||
})
|
||||
moveDrawer.openDrawer(script.path, script.summary, 'script')
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
disabled: !owner || script.archived || !canEdit,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: Calendar,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(false, script.path)
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(script.path, 'script')
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
submenu('Deploy & publish', Send, [
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(script.path, 'script')
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
!isDeployable('script', script.path, cachedDeployUiSettings())
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: Globe2,
|
||||
action: async () => {
|
||||
// The row only carries metadata, so the code has to be fetched first; the tab is
|
||||
// claimed before that, since Safari won't open one after an await.
|
||||
const tab = claimTab()
|
||||
try {
|
||||
const fullScript = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
})
|
||||
const url = scriptToHubUrl(fullScript, $hubBaseUrlStore).toString()
|
||||
if (tab) {
|
||||
tab.show(url)
|
||||
} else if (!window.open(url)) {
|
||||
sendUserToast('Allow popups to publish this script to the Hub', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
tab?.discard()
|
||||
sendUserToast(
|
||||
`Could not load ${script.path}: ${e?.body ?? e?.message ?? e}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
},
|
||||
// Operators can't write scripts, so they have nothing to publish.
|
||||
hide: $disableHubStore || !$roleCapabilities.code
|
||||
}
|
||||
])
|
||||
],
|
||||
[
|
||||
{
|
||||
displayName: script.archived ? 'Unarchive' : 'Archive',
|
||||
icon: Archive,
|
||||
action: () => {
|
||||
script.archived
|
||||
? script.path && unarchiveScript(script.path)
|
||||
: script.path && archiveScript(script.path)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner || !canEdit,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(script.path)
|
||||
} else {
|
||||
@@ -303,180 +491,11 @@
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
// A draft-only row is always the authed user's own draft (the
|
||||
// list endpoint only surfaces own/legacy draft-only rows), so
|
||||
// discarding it never requires write permission on the path.
|
||||
disabled: !showEditButton
|
||||
disabled: !canEdit,
|
||||
hide: !$roleCapabilities.code || !($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'View code',
|
||||
icon: Code,
|
||||
action: () => {
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
...(script.auto_kind === 'wac'
|
||||
? [
|
||||
{
|
||||
displayName: 'View JSON/YAML',
|
||||
icon: FileText,
|
||||
action: async () => {
|
||||
const fullScript = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
})
|
||||
wacExportDrawer?.open(fullScript)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `/scripts/add?template=${script.path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'script', script.path),
|
||||
hide:
|
||||
!$roleCapabilities.code ||
|
||||
isCloudHosted() ||
|
||||
!editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(script.path, script.summary, 'script')
|
||||
},
|
||||
disabled: !owner || script.archived || !canEdit,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
...(isDeployable('script', script.path, await getDeployUiSettings())
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to staging/prod',
|
||||
icon: ChevronUpSquare,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(script.path, 'script')
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide: !$roleCapabilities.code
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
href: `/runs/${script.path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Versions',
|
||||
icon: History,
|
||||
action: () => {
|
||||
versionsDrawerOpen = true
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
href: `/audit_logs?resource=${script.path}`,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: Calendar,
|
||||
action: () => {
|
||||
scheduleEditor?.openNew(false, script.path)
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(script.path, 'script')
|
||||
},
|
||||
disabled: script.archived,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: 'Copy path',
|
||||
icon: Copy,
|
||||
action: () => {
|
||||
copyToClipboard(script.path)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: Globe2,
|
||||
action: async () => {
|
||||
// The row only carries metadata, so the code has to be fetched first; the tab is
|
||||
// claimed before that, since Safari won't open one after an await.
|
||||
const tab = claimTab()
|
||||
try {
|
||||
const fullScript = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
})
|
||||
const url = scriptToHubUrl(fullScript, $hubBaseUrlStore).toString()
|
||||
if (tab) {
|
||||
tab.show(url)
|
||||
} else if (!window.open(url)) {
|
||||
sendUserToast('Allow popups to publish this script to the Hub', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
tab?.discard()
|
||||
sendUserToast(`Could not load ${script.path}: ${e?.body ?? e?.message ?? e}`, true)
|
||||
}
|
||||
},
|
||||
// Operators can't write scripts, so they have nothing to publish.
|
||||
hide: $disableHubStore || !$roleCapabilities.code
|
||||
},
|
||||
{
|
||||
displayName: script.archived ? 'Unarchive' : 'Archive',
|
||||
icon: Archive,
|
||||
action: () => {
|
||||
script.archived
|
||||
? script.path && unarchiveScript(script.path)
|
||||
: script.path && archiveScript(script.path)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner || !canEdit,
|
||||
hide: !$roleCapabilities.code
|
||||
},
|
||||
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(script.path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteScript(script.path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !canEdit,
|
||||
hide: !$roleCapabilities.code
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
)
|
||||
}}
|
||||
on:open={() => {
|
||||
menuOpen = true
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
/**
|
||||
* Joins a row menu's groups into one list with a divider above each group. The divider rides on
|
||||
* the group's first visible item, so a group whose items are all hidden leaves no stray line.
|
||||
*/
|
||||
export function menuGroups(...groups: Item[][]): Item[] {
|
||||
const out: Item[] = []
|
||||
for (const group of groups) {
|
||||
const visible = group.filter((item) => !item.hide)
|
||||
if (visible.length === 0) continue
|
||||
out.push({ ...visible[0], separatorTop: out.length > 0 }, ...visible.slice(1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A submenu for actions that share a heading. With a single visible child the child is shown
|
||||
* in place, since a submenu of one is an extra hover for nothing.
|
||||
*/
|
||||
export function submenu(displayName: string, icon: any, children: Item[]): Item {
|
||||
const visible = children.filter((item) => !item.hide)
|
||||
if (visible.length === 1) return visible[0]
|
||||
return { displayName, icon, submenuItems: visible, hide: visible.length === 0 }
|
||||
}
|
||||
@@ -4,14 +4,55 @@ import { ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
import { get, writable } from 'svelte/store'
|
||||
|
||||
const deployUiStore = writable<Record<string, WorkspaceDeployUISettings>>({})
|
||||
let pending: Record<string, Promise<WorkspaceDeployUISettings>> = {}
|
||||
let generation = 0
|
||||
|
||||
// Until the license loads, a load reads as CE and resolves to ALL_DEPLOYABLE. Rows warm the cache
|
||||
// on mount, often before then, so a license arriving drops whatever was loaded without it.
|
||||
enterpriseLicense.subscribe((license) => {
|
||||
if (!license) return
|
||||
const hadSettings = Object.keys(get(deployUiStore)).length > 0 || Object.keys(pending).length > 0
|
||||
generation++
|
||||
pending = {}
|
||||
deployUiStore.set({})
|
||||
if (hadSettings) warmDeployUiSettings()
|
||||
})
|
||||
|
||||
export async function getDeployUiSettings(): Promise<WorkspaceDeployUISettings> {
|
||||
let deployUiSettings = get(deployUiStore)
|
||||
let workspace = get(workspaceStore)
|
||||
if (!deployUiSettings[workspace!]) {
|
||||
deployUiSettings[workspace!] = await getDeployUiSettingsInner()
|
||||
deployUiStore.set(deployUiSettings)
|
||||
const workspace = get(workspaceStore)!
|
||||
const cached = get(deployUiStore)[workspace]
|
||||
if (cached) return cached
|
||||
const gen = generation
|
||||
const load = (pending[workspace] ??= getDeployUiSettingsInner())
|
||||
let settings: WorkspaceDeployUISettings
|
||||
try {
|
||||
settings = await load
|
||||
} finally {
|
||||
if (pending[workspace] === load) delete pending[workspace]
|
||||
}
|
||||
return deployUiSettings[workspace!]
|
||||
if (gen !== generation) return getDeployUiSettings()
|
||||
deployUiStore.update((s) => ({ ...s, [workspace]: settings }))
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading the settings without waiting for them. Every row on a page calls this, so a
|
||||
* failed fetch must not reject: the root layout toasts each unhandled rejection, once per row.
|
||||
*/
|
||||
export function warmDeployUiSettings(): void {
|
||||
getDeployUiSettings().catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* The current workspace's settings if a load has already resolved. Row menus read this so they
|
||||
* can build their items synchronously: a menu whose items arrive after it opens is positioned for
|
||||
* its loading spinner and then flips once the real list is taller. A miss starts a new load, so a
|
||||
* fetch that failed is retried on the next open.
|
||||
*/
|
||||
export function cachedDeployUiSettings(): WorkspaceDeployUISettings | undefined {
|
||||
const settings = get(deployUiStore)[get(workspaceStore)!]
|
||||
if (!settings) warmDeployUiSettings()
|
||||
return settings
|
||||
}
|
||||
|
||||
async function getDeployUiSettingsInner(): Promise<WorkspaceDeployUISettings> {
|
||||
|
||||
Reference in New Issue
Block a user