mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 00:03:57 +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>
206 lines
6.2 KiB
Svelte
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>
|