mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
feat: keep secondary detail actions in menus and the star beside the summary
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
539ad9ef2e
commit
66195ce9dc
@@ -17,14 +17,13 @@
|
||||
|
||||
type MainButton = {
|
||||
label: string
|
||||
/** Shown under the label once the button has collapsed into a menu. */
|
||||
/** Shown under the label once the button is a row of 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 }
|
||||
/** A secondary action, kept out of the bar at every width: in the ellipsis menu, or in the
|
||||
* dropdown of the enabled bar button labelled `dropdownOf` (the menu when there is none).
|
||||
* Unset makes it a bar button, which folds into the menu only when the bar is short. */
|
||||
placement?: 'menu' | { dropdownOf: string }
|
||||
}
|
||||
|
||||
type ButtonProps = any
|
||||
@@ -70,6 +69,9 @@
|
||||
wide?: boolean
|
||||
/** Controls ahead of the menu, such as the way an agent's page runs it. */
|
||||
leading_actions?: import('svelte').Snippet
|
||||
/** Marks that belong to the item's name, such as its favorite star: drawn right after the
|
||||
* summary rather than among the badges and buttons at the far end. */
|
||||
name_marks?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -89,7 +91,8 @@
|
||||
trigger_badges,
|
||||
ownsPageHeader = false,
|
||||
wide = true,
|
||||
leading_actions
|
||||
leading_actions,
|
||||
name_marks
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -98,21 +101,38 @@
|
||||
// no error handler and says so itself.
|
||||
const renameKind = $derived(itemKind ?? errorHandlerKind)
|
||||
|
||||
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
|
||||
// own box is narrow while the window is not — a session's preview panel — and says so through
|
||||
// `wide`. The band knows what its breadcrumb leaves, which the host cannot see, and folds the
|
||||
// row once the breadcrumb has given all it can (`actionFolds` below).
|
||||
const roomInBar = $derived(!ownsPageHeader || pageHeader.actionFold === 0)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
// The band folds the bar buttons once its breadcrumb has given all it can (`actionFolds` below):
|
||||
// first every one but the last, which is what the page is for (Edit), then that one too. The
|
||||
// host knows when its own box is narrow while the window is not — a session's preview panel —
|
||||
// and says so through `wide`, which only sends the error handler's toggle to the menu.
|
||||
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0)
|
||||
const wideRow = $derived(wide && fold === 0)
|
||||
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
|
||||
const barButtons = $derived(fold >= 2 ? [] : fold === 1 ? rowButtons.slice(-1) : rowButtons)
|
||||
|
||||
function dropdownHost(btn: MainButton): MainButton | undefined {
|
||||
if (typeof btn.narrow !== 'object') return undefined
|
||||
const label = btn.narrow.dropdownOf
|
||||
if (typeof btn.placement !== 'object') return undefined
|
||||
const label = btn.placement.dropdownOf
|
||||
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
|
||||
}
|
||||
|
||||
/** What the menu holds of the buttons: the folded bar buttons, each followed by what its
|
||||
* dropdown would have held, then the secondary actions with no bar button to hang from. */
|
||||
const menuButtons = $derived.by(() => {
|
||||
const folded = rowButtons.filter((b) => !barButtons.includes(b))
|
||||
const homeless = mainButtons.filter((b) => b.placement && !dropdownHost(b))
|
||||
const childOf = (host: MainButton) =>
|
||||
homeless.filter(
|
||||
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
|
||||
)
|
||||
const placed = new Set(folded.flatMap(childOf))
|
||||
return [
|
||||
...folded.flatMap((host) => [host, ...childOf(host)]),
|
||||
...homeless.filter((b) => !placed.has(b))
|
||||
]
|
||||
})
|
||||
|
||||
async function toggleErrorHandler() {
|
||||
if (!errorHandlerKind || !scriptOrFlowPath) return
|
||||
const next = await toggleWorkspaceErrorHandler(
|
||||
@@ -125,7 +145,7 @@
|
||||
}
|
||||
|
||||
const allMenuItems: Item[] = $derived([
|
||||
...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
|
||||
...menuButtons.map((b) => ({
|
||||
displayName: b.label,
|
||||
description: b.description,
|
||||
icon: b.buttonProps.startIcon,
|
||||
@@ -150,12 +170,11 @@
|
||||
action: item.onclick,
|
||||
disabled: item.disabled,
|
||||
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
|
||||
separatorTop: i === 0 && !wideRow
|
||||
separatorTop: i === 0 && (menuButtons.length > 0 || (!wideRow && !!errorHandlerKind))
|
||||
}))
|
||||
])
|
||||
|
||||
function dropdownItemsOf(host: MainButton) {
|
||||
if (wideRow) return undefined
|
||||
const items = mainButtons
|
||||
.filter((b) => dropdownHost(b) === host)
|
||||
.map((b) => ({
|
||||
@@ -171,15 +190,18 @@
|
||||
</script>
|
||||
|
||||
{#snippet summaryContent()}
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={renameKind}
|
||||
compact
|
||||
/>
|
||||
{#if name_marks}<span class="flex items-center shrink-0">{@render name_marks()}</span>{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- The same editor as the summary's, hung off the band's path segment so it opens under the path.
|
||||
@@ -273,7 +295,7 @@
|
||||
pathTrigger: onSaved ? pathTrigger : undefined
|
||||
}}
|
||||
{actions}
|
||||
actionFolds={1}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
@@ -297,6 +319,7 @@
|
||||
kind={renameKind}
|
||||
/>
|
||||
</div>
|
||||
{@render name_marks?.()}
|
||||
{@render badges('md')}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new flow from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/flows/add?template=${flow.path}`),
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
@@ -390,7 +390,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('flow', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, flow.path),
|
||||
@@ -418,7 +418,7 @@
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
@@ -435,7 +435,7 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
@@ -682,6 +682,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && flow && workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -698,12 +704,6 @@
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
description: `Start a new script from a copy of this one`,
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: operatingHref(`/scripts/add?template=${script.path}`),
|
||||
onClick: interceptNav(onNavigate, `/scripts/add?template=${script.path}`),
|
||||
@@ -553,7 +553,7 @@
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
description: editInForkDescription('script', workspace, $userWorkspaces),
|
||||
narrow: { dropdownOf: 'Edit' },
|
||||
placement: { dropdownOf: 'Edit' },
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('script', script.path, workspace),
|
||||
onClick: (e: Event | undefined) => editInFork(e, script.path),
|
||||
@@ -586,7 +586,7 @@
|
||||
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: () => {
|
||||
versionsDrawerOpen = !versionsDrawerOpen
|
||||
@@ -602,7 +602,7 @@
|
||||
if (!actingUser?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
narrow: 'menu',
|
||||
placement: 'menu',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromScript(script.path, script.summary, script.schema)
|
||||
@@ -870,6 +870,12 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#snippet name_marks()}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace && script && workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trigger_badges()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
@@ -887,12 +893,6 @@
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && script}
|
||||
<!-- Favorites are the sidebar's, which lists the navigation workspace only. -->
|
||||
{#if workspace === $workspaceStore}
|
||||
<Star kind="script" path={script.path} summary={script.summary} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if script?.auto_kind === 'wac'}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
|
||||
@@ -37,16 +37,16 @@ describe('fitBar', () => {
|
||||
it("takes the squeeze from the path's name before the summary", () => {
|
||||
const fit = fitBar({
|
||||
...base,
|
||||
barWidth: 400,
|
||||
barWidth: 360,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
trail: trail.map((w) => w - 400),
|
||||
summary: BAR_STAGES.map(() => 100),
|
||||
natural: { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
})
|
||||
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 204, so 244
|
||||
// has to go: all 140 the name has above its floor, then 104 from the summary.
|
||||
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 196 }, scopesGive: false })
|
||||
// Stage 0 at its floors is 300px; whole, the name and the summary add 140 and 100, so 180
|
||||
// has to go: all 140 the name has above its floor, then 40 from the summary.
|
||||
expect(fit).toMatchObject({ stage: 0, caps: { name: 160, summary: 260 }, scopesGive: false })
|
||||
})
|
||||
|
||||
it('keeps the summary and drops the path when only one of them fits', () => {
|
||||
|
||||
@@ -80,8 +80,9 @@ export type Part = 'family' | 'fork' | 'summary' | 'name'
|
||||
/** The trail at each stage with its names at their floors, and each name's own width. */
|
||||
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
|
||||
|
||||
/** The width a part may truncate to before the next stage drops something whole. */
|
||||
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 96, name: 160 }
|
||||
/** The width a part may truncate to before the next stage drops something whole. The summary's
|
||||
* is wide: below it the path goes rather than the summary losing most of what it says. */
|
||||
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 200, name: 160 }
|
||||
/** Past the last stage and fold, the item's name and then its summary keep this much before the
|
||||
* folders give any. */
|
||||
export const SQUEEZED_FLOOR = 40
|
||||
|
||||
Reference in New Issue
Block a user