Files
windmill/frontend/src/lib/groupDraft.test.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

113 lines
3.5 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { groupMemberDiff, isGroupDraftDirty, type GroupDraft, type GroupRole } from './groupDraft'
function baseline(): GroupDraft {
return {
summary: 'On-call engineers',
members: [
{ member_name: 'admin', role: 'admin' },
{ member_name: 'alice', role: 'member' }
]
}
}
describe('groupMemberDiff', () => {
// The whole transition matrix: which endpoints each role change maps to. A role is a
// membership row plus an ACL entry, so only the halves that actually change are sent —
// an extra call would log a permission-history row for something that did not move.
const transitions: Array<
[from: GroupRole | 'absent', to: GroupRole | 'absent', expected: unknown[]]
> = [
['absent', 'member', [{ kind: 'addUser', username: 'bob' }]],
['absent', 'manager', [{ kind: 'setAcl', username: 'bob' }]],
[
'absent',
'admin',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['member', 'admin', [{ kind: 'setAcl', username: 'bob' }]],
[
'member',
'manager',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['manager', 'admin', [{ kind: 'addUser', username: 'bob' }]],
[
'manager',
'member',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
],
['admin', 'member', [{ kind: 'removeAcl', username: 'bob' }]],
['admin', 'manager', [{ kind: 'removeUser', username: 'bob' }]],
['member', 'absent', [{ kind: 'removeUser', username: 'bob' }]],
['manager', 'absent', [{ kind: 'removeAcl', username: 'bob' }]],
[
'admin',
'absent',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
]
]
for (const [from, to, expected] of transitions) {
it(`${from} to ${to}`, () => {
const prev = from === 'absent' ? [] : [{ member_name: 'bob', role: from }]
const next = to === 'absent' ? [] : [{ member_name: 'bob', role: to }]
expect(groupMemberDiff(prev, next)).toEqual(expected)
})
}
it('sends nothing for an unchanged member', () => {
expect(groupMemberDiff(baseline().members, baseline().members)).toEqual([])
})
it('revokes the caller last so the rest of the save stays authorized', () => {
const prev = [{ member_name: 'admin', role: 'admin' as GroupRole }]
const next = [
{ member_name: 'admin', role: 'member' as GroupRole },
{ member_name: 'bob', role: 'admin' as GroupRole }
]
expect(groupMemberDiff(prev, next, 'admin')).toEqual([
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' },
{ kind: 'removeAcl', username: 'admin' }
])
})
})
describe('isGroupDraftDirty', () => {
it('is clean against an equal baseline and dirty on any field', () => {
expect(isGroupDraftDirty(baseline(), baseline())).toBe(false)
expect(isGroupDraftDirty({ ...baseline(), summary: 'Other' }, baseline())).toBe(true)
expect(
isGroupDraftDirty(
{ ...baseline(), members: [{ member_name: 'admin', role: 'member' }] },
baseline()
)
).toBe(true)
})
it('is clean while nothing has loaded', () => {
expect(isGroupDraftDirty(baseline(), undefined)).toBe(false)
})
// A reload rebuilds the members in the server's order, which is not the order they were
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
it('ignores the order the members are held in', () => {
const reordered = baseline()
reordered.members = [...reordered.members].reverse()
expect(isGroupDraftDirty(reordered, baseline())).toBe(false)
})
})