Files
windmill/frontend/src/lib/components/FolderPicker.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

206 lines
6.2 KiB
Svelte

<script lang="ts">
import { FolderService, UserService, type User } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
import { Button } from './common'
import FolderEditorDrawer from './FolderEditorDrawer.svelte'
import Select from './select/Select.svelte'
import { sendUserToast } from '$lib/toast'
let folders: { name: string; write: boolean }[] = $state([])
let filterText: string = $state('')
let selectOpen: boolean = $state(false)
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let loadingFolders: boolean = $state(true)
type Props = {
folderName: string
initialPath?: string
disabled?: boolean
disableEditing?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
selectInputClass?: string
/** List and create folders in this workspace instead of the active one. For a
* screen that targets a workspace it has not switched to — the project import
* wizard picks a destination and only enters it when the import runs. */
workspace?: string
}
let {
folderName = $bindable(''),
initialPath = $bindable(undefined),
disabled = $bindable(undefined),
disableEditing = $bindable(undefined),
size = 'md',
drawerOffset = 0,
selectInputClass,
workspace
}: Props = $props()
const targetWorkspace = $derived(workspace ?? $workspaceStore ?? '')
// `$userStore` describes the workspace the app is *in*. When this picker is aimed
// somewhere else, those memberships answer the wrong question — and since a folder
// without write access renders disabled, a stale answer makes the real folders
// unpickable. Resolve the membership for the workspace actually being listed.
let targetUser: User | undefined = $state(undefined)
const aimedElsewhere = $derived(!!workspace && workspace !== $workspaceStore)
const membership = $derived(aimedElsewhere ? targetUser : ($userStore ?? undefined))
const restricted = $derived(
isDemoWorkspaceRestricted(targetWorkspace, membership?.is_admin, membership?.is_super_admin)
)
async function loadFolders(): Promise<void> {
loadingFolders = true
try {
let initialFolders: { name: string; write: boolean }[] = []
let initialFolder = ''
if (initialPath?.split('/')?.[0] == 'f') {
initialFolder = initialPath?.split('/')?.[1]
initialFolders.push({ name: initialFolder, write: true })
}
const excludedFolders = [initialFolder, 'app_groups', 'app_custom', 'app_themes']
folders = initialFolders.concat(
(
await FolderService.listFolderNames({
workspace: targetWorkspace
})
)
.filter((x) => !excludedFolders.includes(x))
.map((x) => ({
name: x,
write:
membership?.folders?.includes(x) == true ||
(membership?.is_admin ?? false) ||
(membership?.is_super_admin ?? false)
}))
)
} catch (e) {
sendUserToast(`Could not load folders: ${e}`, true)
} finally {
loadingFolders = false
}
}
async function onFolderSaved(saved: string, created: boolean) {
if (created) {
// The creator owns what they just created. Recorded on whichever membership
// this picker is reading, and *before* reloading, so the new folder comes
// back selectable rather than `(read-only)` — `loadFolders` derives `write`
// from exactly this.
if (aimedElsewhere) {
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), saved]
} else if ($userStore) {
// Writing $userStore.folders = [...] would call userStore.set(),
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
// switching the owner toggle from "Folder" back to "User".
if (!$userStore.folders) $userStore.folders = []
$userStore.folders.push(saved)
}
}
await loadFolders()
if (created) folderName = saved
}
let selectItems = $derived(
folders.map((f) => ({
value: f.name,
label: f.name + (f.write ? '' : ' (read-only)'),
disabled: !f.write
}))
)
let noMatchingItems = $derived(
filterText &&
!selectItems.some((item) => item.label.toLowerCase().includes(filterText.toLowerCase()))
)
function handleSelectKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && selectOpen && noMatchingItems && !restricted) {
e.preventDefault()
selectOpen = false
folderEditorDrawer?.initNew(filterText)
}
}
async function loadTargetUser(): Promise<void> {
if (!workspace || workspace === $workspaceStore) return
try {
targetUser = await UserService.whoami({ workspace })
} catch {
// Not a member, or the call failed: every folder stays read-only, which is
// the safe reading — the import would be refused anyway.
targetUser = undefined
}
}
loadTargetUser().then(loadFolders)
</script>
<FolderEditorDrawer
bind:this={folderEditorDrawer}
offset={drawerOffset}
workspace={targetWorkspace}
onSaved={onFolderSaved}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="flex group flex-row w-full items-center relative"
role="group"
onkeydown={handleSelectKeydown}
>
<Select
useContentEditable
bind:value={folderName}
bind:filterText
bind:open={selectOpen}
items={selectItems}
disabled={disabled || disableEditing}
loading={loadingFolders}
{size}
placeholder="Select folder"
class="grow min-w-0"
inputClass={selectInputClass}
RightIcon={ChevronDown}
>
{#snippet endSnippet({ item, close })}
<Button
disabled={disabled || disableEditing}
variant="subtle"
unifiedSize="xs"
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
onClick={() => {
folderEditorDrawer?.initEdit(item.value ?? '')
close()
}}
startIcon={{ icon: Pen }}
iconOnly
/>
{/snippet}
{#snippet bottomSnippet({ close })}
{#if !restricted}
<button
class="sticky py-2 px-4 w-full text-left text-xs font-medium hover:bg-surface-hover flex items-center justify-center gap-2 border-t border-border-light {noMatchingItems
? 'bg-surface-hover'
: ''}"
onclick={() => {
close()
folderEditorDrawer?.initNew(filterText)
}}
>
<PlusIcon class="inline" size={16} />
Create folder
</button>
{/if}
{/snippet}
</Select>
</div>