mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* fix: portal the confirmation modal so drawers cannot cover it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: log a folder acl grant under the permission it granted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a table's actions column at its right edge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: edit a folder in a drawer that saves once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: call the people on a folder or item members Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: edit a folder against the workspace the drawer targets Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: drop the now-unused sticky actions column Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * docs: correct the script editor drawer's modal placement note Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: pin the actions column without losing the row's hover tint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: show the pinned column's seam only while the table overflows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: draw the pinned column's seam as a shadow so it does not scroll away Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: fade the pinned column's tint in step with its row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: address review nits on the folder editor and pinned cell Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep the folder draft across a user-store refresh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: extract and test the folder draft's dirty check and permission diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: stop the folder editor showing state the server refused Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a folder draft that no request ever reached the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep unapplied folder edits dirty when a save partially fails * fix: block folder form edits while a save is in flight * fix: commit a typed folder label before save snapshots the draft * fix: count a typed folder label as an unsaved change * fix: keep escape in the label input from closing what encloses it * fix: capitalize folder table headers and drop a dead portal target * refactor: make the confirmation modal portal opt-in per call site * docs: name the stacking context that actually traps the discard dialog * fix: report a half-landed member removal so the baseline reconciles * feat: edit a group in a drawer that saves once * fix: freeze the group name once the group exists * fix: revoke the caller's own group acl last so the rest of the save is authorized * docs: state the group call-ordering invariant once * fix: report a failing post-save reload instead of dropping the rejection * fix: hand the folder list reload back so a failure is reported * fix: treat a rejected group create as inconclusive and catch a throwing onSaved * revert: stop inferring a group was created from its name being taken * fix: say when a failed group create may have saved the group anyway * fix: key the may-have-been-created hint on the name conflict, not the status * fix: skip the may-have-been-created hint when the group is known to exist * feat: open a folder's group member from its row * fix: stop showing the caller as an admin when the read failed * fix: give up the caller's own folder admin last, and label a create as one * fix: drop a folder member's acl before its owner entry * fix: remove a folder owner before their acl, and correct the rls rationale * docs: say the refusal is on the caller's last admin handle * fix: defer only the folder rows the caller is an admin through * docs: describe callerOwners as what the caller passes in * docs: drop the call-site restatement of the diff's own invariant * docs: record manager as a legacy group role * fix: treat a sent request as possibly committed when reconciling * fix: reconcile on any failed edit, and compare members as a set * fix: keep write access when only the reconcile read fails --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
7.8 KiB
Svelte
235 lines
7.8 KiB
Svelte
<script lang="ts">
|
|
import { classNames } from '$lib/utils'
|
|
import ConditionalPortal from '$lib/components/common/drawer/ConditionalPortal.svelte'
|
|
import { getOverlayHost, overlayHostActive } from '$lib/components/common/overlayHost.svelte'
|
|
import { createEventDispatcher, type Snippet } from 'svelte'
|
|
import { fade } from 'svelte/transition'
|
|
import Button from '../button/Button.svelte'
|
|
import { AlertTriangle, CornerDownLeft, Info, Loader2, RefreshCcw } from 'lucide-svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
type Props = {
|
|
title: string
|
|
confirmationText: string
|
|
keyListen?: boolean
|
|
loading?: boolean
|
|
/** Blocks confirming (button and Enter) while a required choice in `children` is unmade. */
|
|
confirmDisabled?: boolean
|
|
open?: boolean
|
|
type?: 'danger' | 'reload' | 'info'
|
|
showIcon?: boolean
|
|
id?: string
|
|
trashbin?: boolean
|
|
/** Tailwind z-index class for the modal root. Override to stack this modal
|
|
* above another modal that's already open (both default to `z-[9999]`). */
|
|
zIndexClass?: string
|
|
/** Render into `body` instead of where this component sits. Needed when an ancestor
|
|
* creates a stacking context the dialog has to escape — a drawer paints over the page
|
|
* whatever the dialog's z-index, and a `transform`, `filter` or `overflow` on the way
|
|
* up confines it. Off by default: it moves the dialog out of its DOM position, so opt
|
|
* in per call site rather than assuming every caller wants it. */
|
|
alwaysPortal?: boolean
|
|
children?: Snippet
|
|
onConfirmed?: () => void | Promise<void>
|
|
onCanceled?: () => void
|
|
}
|
|
|
|
const {
|
|
title,
|
|
confirmationText,
|
|
keyListen = true,
|
|
loading = false,
|
|
confirmDisabled = false,
|
|
open = false,
|
|
type: _type,
|
|
showIcon = true,
|
|
id,
|
|
trashbin = false,
|
|
zIndexClass = 'z-[9999]',
|
|
alwaysPortal = false,
|
|
children,
|
|
onConfirmed,
|
|
onCanceled
|
|
}: Props = $props()
|
|
const type = $derived(_type ?? 'danger')
|
|
|
|
// Anchored to the enclosing pane when there is one (see overlayHost): portalled into it
|
|
// and positioned against it, so the dialog covers that pane rather than the whole app.
|
|
// Both halves are required — `absolute` resolves against the nearest positioned DOM
|
|
// ancestor, which without the portal is whatever box the caller happens to sit in.
|
|
const overlayHost = getOverlayHost()
|
|
const hostEl = $derived(overlayHost?.el())
|
|
const posClass = $derived(hostEl ? 'absolute' : 'fixed')
|
|
|
|
const hostActive = overlayHostActive()
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
// Hidden hosts stay mounted and still receive window keys — see overlayHost.
|
|
if (!hostActive()) return
|
|
if (open && keyListen) {
|
|
// Only intercept Enter/Escape (without modifiers) so shortcuts like
|
|
// Cmd/Ctrl+C and Cmd/Ctrl+V keep working inside the modal.
|
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
return
|
|
}
|
|
const popover = (event.target as HTMLElement | null)?.closest?.('[data-popover]')
|
|
// Content carries no `aria-controls`; a trigger's resolves only while its content is
|
|
// mounted, which is the only reliable open/closed signal — the trigger's own aria state
|
|
// is stale because visibility is driven outside melt.
|
|
const controls = popover?.getAttribute('aria-controls')
|
|
const popoverOpen = !!popover && (!controls || !!document.getElementById(controls))
|
|
|
|
switch (event.key) {
|
|
// Both keys are gated on the same state as the button they stand for, which is why
|
|
// they swallow the event first and only then decide. Ungated, Enter re-enters an
|
|
// in-flight confirm and Escape dismisses the modal out from under one — leaving the
|
|
// action to finish against a caller that believes it was cancelled.
|
|
case 'Enter':
|
|
// A popover needs Enter both to open from its trigger and to choose from its
|
|
// content, so leave it alone whether or not it is open.
|
|
if (popover) return
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
if (loading || confirmDisabled) break
|
|
dispatch('confirmed')
|
|
onConfirmed?.()
|
|
break
|
|
case 'Escape':
|
|
// Only an open popover has something to dismiss; on a closed trigger Escape is
|
|
// still the dialog's.
|
|
if (popoverOpen) return
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
if (loading) break
|
|
dispatch('canceled')
|
|
onCanceled?.()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
function fadeFast(node: HTMLElement) {
|
|
return fade(node, { duration: 100 })
|
|
}
|
|
|
|
const theme = {
|
|
danger: {
|
|
Icon: AlertTriangle,
|
|
color: 'red',
|
|
classes: {
|
|
icon: 'text-red-500 dark:text-red-400',
|
|
iconWrapper: 'bg-red-100 dark:bg-red-800/50'
|
|
}
|
|
},
|
|
|
|
reload: {
|
|
Icon: RefreshCcw,
|
|
color: 'dark',
|
|
classes: {
|
|
icon: 'text-blue-700 dark:text-blue-300',
|
|
iconWrapper: 'bg-blue-100 dark:bg-blue-800/50'
|
|
}
|
|
},
|
|
|
|
// Neutral, affirmative confirmation (non-destructive) — e.g. proceeding with
|
|
// an import. The confirm button stays a plain accent (see `destructive` below).
|
|
info: {
|
|
Icon: Info,
|
|
color: 'blue',
|
|
classes: {
|
|
icon: 'text-blue-700 dark:text-blue-300',
|
|
iconWrapper: 'bg-blue-100 dark:bg-blue-800/50'
|
|
}
|
|
}
|
|
} satisfies { [type in typeof type]: any }
|
|
const Icon = $derived(theme[type].Icon ?? AlertTriangle)
|
|
</script>
|
|
|
|
<svelte:window onkeydowncapture={onKeyDown} />
|
|
|
|
<ConditionalPortal
|
|
condition={alwaysPortal || !!hostEl}
|
|
target={hostEl}
|
|
class={hostEl ? 'contents' : undefined}
|
|
>
|
|
{#if open}
|
|
<div
|
|
transition:fadeFast|local
|
|
class={twMerge(posClass, 'top-0 bottom-0 left-0 right-0', zIndexClass)}
|
|
role="dialog"
|
|
{id}
|
|
>
|
|
<div
|
|
class={classNames(
|
|
posClass,
|
|
'inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
|
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
|
)}
|
|
></div>
|
|
|
|
<div class="{posClass} inset-0 z-10 overflow-y-auto">
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
class={classNames(
|
|
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
|
open
|
|
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
|
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
|
)}
|
|
>
|
|
<div class="flex">
|
|
{#if showIcon}
|
|
<div
|
|
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
|
|
>
|
|
<Icon class={theme[type].classes.icon} />
|
|
</div>
|
|
{/if}
|
|
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
|
|
<h3 class="text-lg font-medium text-primary">
|
|
{title}
|
|
</h3>
|
|
<div class="mt-2 text-sm text-secondary">
|
|
{@render children?.()}
|
|
</div>
|
|
{#if trashbin}
|
|
<p class="mt-3 text-xs text-tertiary"
|
|
>This item will be moved to the trashbin and can be restored by a workspace
|
|
admin within 3 days.</p
|
|
>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
|
<Button
|
|
disabled={loading || confirmDisabled}
|
|
on:click={() => (dispatch('confirmed'), onConfirmed?.())}
|
|
color={theme[type].color}
|
|
size="sm"
|
|
shortCut={{ Icon: CornerDownLeft, hide: !keyListen, withoutModifier: true }}
|
|
variant="accent"
|
|
destructive={type === 'danger'}
|
|
>
|
|
{#if loading}
|
|
<Loader2 class="animate-spin" />
|
|
{/if}
|
|
<span class="min-w-20">{confirmationText} </span>
|
|
</Button>
|
|
<Button
|
|
disabled={loading}
|
|
on:click={() => (dispatch('canceled'), onCanceled?.())}
|
|
variant="default"
|
|
size="sm"
|
|
shortCut={{ key: 'Esc', hide: !keyListen, withoutModifier: true }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</ConditionalPortal>
|