Files
windmill/frontend/src/lib/folderDraft.ts
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

97 lines
4.4 KiB
TypeScript

import { deepEqual } from 'fast-equals'
import type { FolderDefaultPermissionedAs } from '$lib/gen'
/** What a member may hold on a folder. `admin` is the `owners` array server-side; `writer`
* and `viewer` are the `true`/`false` entries of `extra_perms`. */
export type FolderRole = 'viewer' | 'writer' | 'admin'
export type FolderMember = { owner_name: string; role: FolderRole }
/** Everything the folder editor can change, held as one value so the whole edit is one
* comparison against the loaded folder and one Save. */
export type FolderDraft = {
summary: string
labels: string[]
defaultPermissionedAs: FolderDefaultPermissionedAs
perms: FolderMember[]
}
/** Whether the draft still matches the folder it was loaded from. Every field of
* `FolderDraft` participates, so a field added to the type is covered by construction —
* which is what the discard guard depends on: an edit this misses is an edit the drawer
* throws away without asking. No baseline means nothing has loaded yet, so nothing to lose. */
export function isFolderDraftDirty(draft: FolderDraft, baseline: FolderDraft | undefined): boolean {
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
}
/** Members are a set, but a reload rebuilds them in the server's `extra_perms` key order while
* the draft keeps the order they were added in. Compared as-is, a change that has already been
* applied still reads as dirty. Labels and rules keep their order, which is meaningful. */
function sortedMembers(value: FolderDraft): FolderDraft {
return {
...value,
perms: [...value.perms].sort((a, b) => a.owner_name.localeCompare(b.owner_name))
}
}
/** One backend call the folder's members need. Kept as data so the mapping from role
* transitions to endpoints can be read — and tested — without a server. */
export type FolderPermissionCall =
/** `addowner`: appends to `owners` and sets `extra_perms[owner] = true`. */
| { kind: 'grantAdmin'; owner: string }
/** `removeowner` with a write flag: takes the member out of `owners` and sets their
* level. The only way down from admin. */
| { kind: 'demoteAdmin'; owner: string; write: boolean }
/** `acls/add`: sets `extra_perms[owner]`, for a member who is not an admin. */
| { kind: 'setAcl'; owner: string; write: boolean }
/** Both removals. `removeowner` without a write only drops the member from `owners`,
* leaving their `extra_perms` entry — alone it demotes an admin rather than removing
* them, so the ACL delete is not optional. */
| { kind: 'remove'; owner: string }
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none.
*
* `callerOwners` is the caller's own `u/name` plus every group they belong to. Giving up the
* last of those that is in `owners` goes last: the write policy checks the row the update
* would produce, so that call is refused for anyone but a workspace admin, and sent early it
* takes the rest of the save with it. */
export function folderPermissionDiff(
prev: FolderMember[],
next: FolderMember[],
callerOwners?: string[]
): FolderPermissionCall[] {
const previousRole = new Map(prev.map((p) => [p.owner_name, p.role]))
const calls: FolderPermissionCall[] = []
for (const member of next) {
const before = previousRole.get(member.owner_name)
if (before === member.role) continue
if (member.role === 'admin') {
calls.push({ kind: 'grantAdmin', owner: member.owner_name })
} else if (before === 'admin') {
calls.push({
kind: 'demoteAdmin',
owner: member.owner_name,
write: member.role === 'writer'
})
} else {
calls.push({ kind: 'setAcl', owner: member.owner_name, write: member.role === 'writer' })
}
}
const kept = new Set(next.map((n) => n.owner_name))
for (const member of prev) {
if (kept.has(member.owner_name)) continue
calls.push({ kind: 'remove', owner: member.owner_name })
}
// `previousRole === 'admin'` is what makes it a handle: `callerOwners` lists every group
// the caller belongs to, and one holding only a viewer or writer row is not in `owners`,
// so removing it is an ordinary call that should not queue behind the fatal one.
const revokesCaller = (call: FolderPermissionCall) =>
(call.kind === 'demoteAdmin' || call.kind === 'remove') &&
(callerOwners?.includes(call.owner) ?? false) &&
previousRole.get(call.owner) === 'admin'
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
}