mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
collapse detail page header actions into menus below lg (#11163)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
49d0310ecc
commit
6885226c26
@@ -53,12 +53,17 @@
|
||||
{#if item.icon}
|
||||
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
|
||||
{/if}
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate grow min-w-0 whitespace-nowrap text-left"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
<div class="grow min-w-0 text-left">
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate whitespace-nowrap"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
{#if item.description}
|
||||
<p class="text-2xs text-secondary">{item.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{@render item.extra?.()}
|
||||
{#if item.shortcut || item.selected || item.toggle !== undefined}
|
||||
<!-- Single trailing group so `shortcut` and `selected` can coexist:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
type MenuItem = {
|
||||
label: string
|
||||
description?: string
|
||||
onClick?: (e?: Event) => void
|
||||
href?: string
|
||||
icon?: any
|
||||
@@ -146,6 +147,7 @@
|
||||
const items = typeof menuItems === 'function' ? menuItems() : menuItems
|
||||
return items.map((item) => ({
|
||||
displayName: item.label,
|
||||
description: item.description,
|
||||
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled ?? false,
|
||||
|
||||
@@ -6,14 +6,23 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { createEventDispatcher, getContext, tick } from 'svelte'
|
||||
import { MediaQuery } from 'svelte/reactivity'
|
||||
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
|
||||
import type { TriggerContext } from '../triggers'
|
||||
import { Calendar } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Bell, BellOff, Calendar } from 'lucide-svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
href: string
|
||||
/** Shown under the label once the button has collapsed into a menu. */
|
||||
description?: string
|
||||
buttonProps: ButtonProps
|
||||
/** Where the button lands below the `lg` breakpoint, where the bar cannot hold every
|
||||
* button beside the summary: the ellipsis menu, or the dropdown of the enabled bar button
|
||||
* labelled `dropdownOf` (the menu when there is none). Unset keeps it in the bar at every
|
||||
* width. */
|
||||
narrow?: 'menu' | { dropdownOf: string }
|
||||
}
|
||||
|
||||
type ButtonProps = any
|
||||
@@ -59,6 +68,70 @@
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Tailwind's `lg`, matched in JS so the one ellipsis menu can carry the collapsed buttons.
|
||||
const wide = new MediaQuery('(min-width: 1024px)')
|
||||
|
||||
const barButtons = $derived(wide.current ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
const label = btn.narrow.dropdownOf
|
||||
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
|
||||
}
|
||||
|
||||
async function toggleErrorHandler() {
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
errorHandlerKind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted
|
||||
)
|
||||
if (next !== undefined) errorHandlerMuted = next
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wide.current ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
href: b.buttonProps.href,
|
||||
action: b.buttonProps.onClick,
|
||||
disabled: b.buttonProps.disabled,
|
||||
type: 'action' as const
|
||||
})),
|
||||
...(wide.current
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: errorHandlerMuted ? 'Unmute error handler' : 'Mute error handler',
|
||||
icon: errorHandlerMuted ? BellOff : Bell,
|
||||
action: toggleErrorHandler,
|
||||
type: 'action' as const
|
||||
}
|
||||
]),
|
||||
...menuItems.map((item, i) => ({
|
||||
displayName: item.label,
|
||||
icon: item.Icon,
|
||||
action: item.onclick,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wide.current
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wide.current) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
label: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
href: b.buttonProps.href,
|
||||
onClick: b.buttonProps.onClick,
|
||||
disabled: b.buttonProps.disabled
|
||||
}))
|
||||
return items.length > 0 ? items : undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b">
|
||||
@@ -103,38 +176,26 @@
|
||||
{@render trigger_badges?.()}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
{#if menuItems.length > 0}
|
||||
{#key menuItems}
|
||||
<DropdownV2
|
||||
items={menuItems.map((item) => ({
|
||||
displayName: item.label,
|
||||
icon: item.Icon,
|
||||
action: item.onclick,
|
||||
type: item.color === 'red' ? 'delete' : 'action'
|
||||
}))}
|
||||
placement="bottom-end"
|
||||
size="md"
|
||||
/>
|
||||
{#if allMenuItems.length > 0}
|
||||
{#key allMenuItems}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
|
||||
{/key}
|
||||
{/if}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
/>
|
||||
{#each mainButtons as btn}
|
||||
{#if wide.current}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
btnClasses="hidden md:flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
iconOnly
|
||||
btnClasses="flex md:hidden items-center gap-1 whitespace-nowrap"
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
|
||||
@@ -2,58 +2,21 @@
|
||||
import { Bell, BellOff } from 'lucide-svelte'
|
||||
|
||||
import { Button } from '$lib/components/common'
|
||||
import { FlowService, ScriptService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
|
||||
|
||||
interface Props {
|
||||
kind: 'script' | 'flow';
|
||||
scriptOrFlowPath: string;
|
||||
errorHandlerMuted: boolean | undefined;
|
||||
iconOnly?: boolean;
|
||||
kind: 'script' | 'flow'
|
||||
scriptOrFlowPath: string
|
||||
errorHandlerMuted: boolean | undefined
|
||||
iconOnly?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
kind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted = $bindable(),
|
||||
iconOnly = true
|
||||
}: Props = $props();
|
||||
let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props()
|
||||
|
||||
async function toggleErrorHandler(): Promise<void> {
|
||||
if ($workspaceStore !== undefined) {
|
||||
try {
|
||||
if (kind === 'flow') {
|
||||
await FlowService.toggleWorkspaceErrorHandlerForFlow({
|
||||
workspace: $workspaceStore,
|
||||
path: scriptOrFlowPath,
|
||||
requestBody: {
|
||||
muted: !errorHandlerMuted
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await ScriptService.toggleWorkspaceErrorHandlerForScript({
|
||||
workspace: $workspaceStore,
|
||||
path: scriptOrFlowPath,
|
||||
requestBody: {
|
||||
muted: !errorHandlerMuted
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast(
|
||||
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
errorHandlerMuted = !errorHandlerMuted
|
||||
sendUserToast(
|
||||
errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active',
|
||||
false
|
||||
)
|
||||
}
|
||||
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
|
||||
if (next !== undefined) errorHandlerMuted = next
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { FlowService, ScriptService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
/** Flips the workspace error handler for one script or flow and reports the outcome in a
|
||||
* toast. Returns the new muted state, or undefined when nothing changed. */
|
||||
export async function toggleWorkspaceErrorHandler(
|
||||
kind: 'script' | 'flow',
|
||||
path: string,
|
||||
muted: boolean | undefined
|
||||
): Promise<boolean | undefined> {
|
||||
const workspace = get(workspaceStore)
|
||||
if (workspace === undefined) return undefined
|
||||
const next = !muted
|
||||
try {
|
||||
if (kind === 'flow') {
|
||||
await FlowService.toggleWorkspaceErrorHandlerForFlow({
|
||||
workspace,
|
||||
path,
|
||||
requestBody: { muted: next }
|
||||
})
|
||||
} else {
|
||||
await ScriptService.toggleWorkspaceErrorHandlerForScript({
|
||||
workspace,
|
||||
path,
|
||||
requestBody: { muted: next }
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast(
|
||||
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
|
||||
true
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
sendUserToast(next ? 'Workspace error handler muted' : 'Workspace error handler active', false)
|
||||
return next
|
||||
}
|
||||
@@ -1669,6 +1669,8 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
|
||||
|
||||
export type Item = {
|
||||
displayName: string
|
||||
/** Second line under the label, for an action whose name alone does not say what it does. */
|
||||
description?: string
|
||||
action?: (e: MouseEvent) => void
|
||||
icon?: any
|
||||
iconColor?: string
|
||||
|
||||
@@ -43,6 +43,19 @@ export function editInForkLabel(
|
||||
return dev ? `Edit in ${dev.name}` : 'Edit in fork'
|
||||
}
|
||||
|
||||
/** One line under `editInForkLabel` for a menu entry, saying where the edit happens. */
|
||||
export function editInForkDescription(
|
||||
itemType: ItemType,
|
||||
currentWorkspaceId: string | undefined,
|
||||
allWorkspaces: UserWorkspace[]
|
||||
): string {
|
||||
const kind = itemType === 'raw_app' ? 'app' : itemType
|
||||
const dev = findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
|
||||
return dev
|
||||
? `Open this ${kind} in the ${dev.name} dev workspace`
|
||||
: `Edit this ${kind} in a forked workspace, then deploy the changes back`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user may CREATE a new fork of the current workspace: forking not disabled, or the user
|
||||
* can bypass the rule (workspace admins). Keeps the "Fork workspace" entry available to admins as the
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkDescription,
|
||||
editInForkLabel,
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
@@ -301,6 +302,8 @@
|
||||
if (flow && !$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: `${base}/flows/add?template=${flow.path}`,
|
||||
variant: 'subtle',
|
||||
@@ -319,6 +322,8 @@
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
description: editInForkDescription('flow', $workspaceStore, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path),
|
||||
onClick: (e: Event | undefined) =>
|
||||
@@ -346,6 +351,7 @@
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
@@ -361,6 +367,7 @@
|
||||
if (!$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkDescription,
|
||||
editInForkLabel,
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
@@ -459,6 +460,8 @@
|
||||
if (!topHash && script && !$userStore?.operator && !script.codebase) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new script from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: `${base}/scripts/add?template=${script.path}`,
|
||||
unifiedSize: 'md',
|
||||
@@ -477,6 +480,8 @@
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
description: editInForkDescription('script', $workspaceStore, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('script', script.path),
|
||||
onClick: (e: Event | undefined) =>
|
||||
@@ -509,6 +514,7 @@
|
||||
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => {
|
||||
versionsDrawerOpen = !versionsDrawerOpen
|
||||
@@ -524,6 +530,7 @@
|
||||
if (!$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromScript(script.path, script.summary, script.schema)
|
||||
|
||||
Reference in New Issue
Block a user