feat: group home row menus and open them at their final height (#11949)

This commit is contained in:
Guilhem
2026-10-23 14:50:01 +02:00
committed by GitHub
parent 725b15f546
commit 91bf9b67a7
9 changed files with 600 additions and 483 deletions
+13 -11
View File
@@ -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 }
}
+47 -6
View File
@@ -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> {