From 91bf9b67a74afb419659719949484f1cfd5338c0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 23 Oct 2026 14:50:01 +0200 Subject: [PATCH] feat: group home row menus and open them at their final height (#11949) --- frontend/src/lib/components/DropdownV2.svelte | 24 +- .../src/lib/components/DropdownV2Inner.svelte | 20 +- .../components/common/table/AgentRow.svelte | 8 +- .../lib/components/common/table/AppRow.svelte | 282 +++++++------ .../components/common/table/FlowRow.svelte | 274 ++++++------ .../components/common/table/RawAppRow.svelte | 8 +- .../components/common/table/ScriptRow.svelte | 389 +++++++++--------- .../lib/components/common/table/menuGroups.ts | 25 ++ frontend/src/lib/components/home/deploy_ui.ts | 53 ++- 9 files changed, 600 insertions(+), 483 deletions(-) create mode 100644 frontend/src/lib/components/common/table/menuGroups.ts diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index 1d86519293..e9fbf71ab8 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -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 { - 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 { + const result = typeof items === 'function' ? items() : items + return result instanceof Promise ? result.then(visible) : visible(result) } async function getMenuElements(): Promise { // Runs on every pointerdown anywhere, for every mounted dropdown. Skip the @@ -200,15 +202,15 @@
{#if customMenu} {@render menu?.({ item, close, builders })} {:else}
diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 5a18a09157..30b6865da8 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -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) + items?: Item[] | (() => Item[] | Promise) meltItem: MenubarMenuElements['item'] builders?: ReturnType['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))) {#snippet menuItem(item: Item)} diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 20421ac514..061112dec4 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -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} [ + items={() => [ ...selectMenuItems(rowSelection), ...agentMenuItems({ - deployUiSettings: await getDeployUiSettings(), + deployUiSettings: cachedDeployUiSettings(), path: agent.path, canWrite: agent.canWrite, draftOnly: Boolean(agent.draft_only), diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 812c06284b..7553b61870 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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 diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ccbdb9de3e..eb58ecbc3f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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 { 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 diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index 1740566bdc..c1feaa2a38 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -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() { + items={() => { let { path } = app return [ - ...(isDeployable('app', path, await getDeployUiSettings()) + ...(isDeployable('app', path, cachedDeployUiSettings()) ? [ { displayName: 'Deploy to prod/staging', diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 9c300b9477..5044cf4339 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -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 { 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 diff --git a/frontend/src/lib/components/common/table/menuGroups.ts b/frontend/src/lib/components/common/table/menuGroups.ts new file mode 100644 index 0000000000..7071b6f11e --- /dev/null +++ b/frontend/src/lib/components/common/table/menuGroups.ts @@ -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 } +} diff --git a/frontend/src/lib/components/home/deploy_ui.ts b/frontend/src/lib/components/home/deploy_ui.ts index 1134789e76..ea51129347 100644 --- a/frontend/src/lib/components/home/deploy_ui.ts +++ b/frontend/src/lib/components/home/deploy_ui.ts @@ -4,14 +4,55 @@ import { ALL_DEPLOYABLE } from '$lib/utils_deployable' import { get, writable } from 'svelte/store' const deployUiStore = writable>({}) +let pending: Record> = {} +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 { - 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 {