Files
windmill/frontend/src/lib/components/FolderEditorDrawer.svelte
T
GuilhemandClaude Opus 5 5d5ad4e897 feat: edit folders and groups in a drawer that saves once (#10873)
* 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>
2026-09-01 23:22:55 +02:00

170 lines
5.6 KiB
Svelte

<script lang="ts">
import { Button, Drawer, DrawerContent } from './common'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import FolderEditor from './FolderEditor.svelte'
import { Save } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
let {
offset = 0,
disableChatOffset = false,
onSaved = undefined,
workspace = undefined
}: {
offset?: number
disableChatOffset?: boolean
onSaved?: (name: string, created: boolean) => void | Promise<void>
/** Edit a folder of this workspace rather than the active one. */
workspace?: string
} = $props()
let drawer: Drawer | undefined = $state()
let mode: 'edit' | 'new' = $state('edit')
let name: string = $state('')
let canSave = $state(false)
let unsaved = $state(false)
// An `edit` drawer whose folder turns out not to exist saves by creating it. Calling that
// Save would promise an update the click does not perform.
let exists = $state(false)
let saving = $state(false)
let confirmDiscardOpen = $state(false)
let discarding = $state(false)
let editor: { save: () => Promise<{ name: string; created: boolean } | undefined> } | undefined =
$state()
// Bumped per open so the editor reloads its draft from the folder it is now
// pointed at. Keying on `name` instead would remount on every keystroke of the
// name field in `new` mode.
let instance = $state(0)
function open(nextMode: 'edit' | 'new', folderName: string): void {
mode = nextMode
name = folderName
discarding = false
confirmDiscardOpen = false
exists = nextMode === 'edit'
// The remounted editor reports these on its first effect, which is a tick away. Until
// then the header would carry the last folder's answers.
canSave = false
unsaved = false
instance++
drawer?.openDrawer()
}
export function initEdit(folderName: string): void {
open('edit', folderName)
}
export function initNew(initialName: string = ''): void {
open('new', initialName)
}
/** The editor keeps its draft in memory only, so closing throws it away. */
function requestClose() {
// A save is already writing. `unsaved` only clears once it reloads, so closing here
// would offer to discard changes the in-flight requests are busy persisting — and
// confirming would close on that lie. Saving is the shorter wait; ignore the close.
if (saving) return
if (discarding || !unsaved) {
drawer?.closeDrawer()
return
}
confirmDiscardOpen = true
}
async function save() {
saving = true
try {
// `created` comes from the editor, not from `mode`: a folder whose row turned out
// not to exist is created from an `initEdit` drawer.
const saved = await editor?.save()
if (saved) {
// Callers reload a list here. Called from inside the chain, not before it, so a
// synchronous throw is caught too — thrown out of `save()` it would skip the
// close below and strand the drawer open on a folder that did save.
void Promise.resolve()
.then(() => onSaved?.(saved.name, saved.created))
.catch((e) => sendUserToast(e?.body ?? String(e), true))
// The editor reloads its baseline after saving, but that lands a tick
// later; close on our own authority rather than racing it.
discarding = true
// Belt and braces with the `saving` guard on the close paths: nothing that
// asked to discard may outlive a save that then succeeded.
confirmDiscardOpen = false
drawer?.closeDrawer()
}
} finally {
saving = false
}
}
</script>
<Drawer
bind:this={drawer}
{offset}
{disableChatOffset}
on:close={() => {
// Escape and click-away close the drawer before asking. Reopening in the same
// tick is how the flow's script editor drawer handles this too: the close
// transition has not started, so nothing flickers.
if (saving) {
drawer?.openDrawer()
return
}
if (!discarding && unsaved) {
drawer?.openDrawer()
confirmDiscardOpen = true
}
}}
>
<DrawerContent title={exists ? `Folder ${name}` : 'Create folder'} on:close={requestClose}>
<!-- `save()` snapshots the draft and then awaits several requests. An edit landing in
that window would not be in the snapshot, and the drawer closes on success — so
it would be lost without ever being offered as unsaved. `inert` keeps the form
from taking one. -->
<div inert={saving} class={saving ? 'opacity-60 transition-opacity' : 'transition-opacity'}>
{#key instance}
<FolderEditor
bind:this={editor}
bind:name
{mode}
{workspace}
onCanSaveChange={(v) => (canSave = v)}
onUnsavedChange={(v) => (unsaved = v)}
onExistsChange={(v) => (exists = v)}
/>
{/key}
</div>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
disabled={!canSave}
loading={saving}
on:click={save}
>
{exists ? 'Save' : 'Create'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- `alwaysPortal`: this drawer is opened from inside other drawers (the folder picker of a
resource or variable form), and that outer drawer is a stacking context this dialog
cannot climb out of on z-index alone. Left in place it paints under the drawer whose
unsaved changes it is asking about, which leaves that drawer impossible to close. -->
<ConfirmationModal
alwaysPortal
open={confirmDiscardOpen}
title="Unsaved changes detected"
confirmationText="Discard changes"
onCanceled={() => (confirmDiscardOpen = false)}
onConfirmed={() => {
confirmDiscardOpen = false
discarding = true
drawer?.closeDrawer()
}}
>
<span> Are you sure you want to discard the changes you have made to this folder? </span>
</ConfirmationModal>