fix: compute list row rights when drawn instead of reloading (#12023)

* fix: compute list row rights when drawn instead of reloading

* refactor: tick home rows by key so the selection stores the row's own entry
This commit is contained in:
AlexRV12
2026-10-26 23:15:19 +01:00
committed by GitHub
parent e2e2e7d7c4
commit b813c1b35d
14 changed files with 137 additions and 146 deletions
@@ -8,7 +8,6 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
@@ -45,8 +44,6 @@
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
const editRights = useEditRights()
interface Props {
flow: Flow & {
draft_only?: boolean
@@ -67,6 +64,8 @@
depth?: number
menuOpen?: boolean
showEditButton?: boolean
/** The acting role may author flows; an operator's row hides the authoring actions. */
canAuthor: boolean
keyboardSelected?: boolean
rowSelection?: RowSelection
}
@@ -82,6 +81,7 @@
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true),
canAuthor,
keyboardSelected = false,
rowSelection = undefined
}: Props = $props()
@@ -129,7 +129,7 @@
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let hideForOperator = $derived(!editRights.roleCanAuthor('flow'))
let hideForOperator = $derived(!canAuthor)
</script>
{#if menuOpen}
+24 -7
View File
@@ -13,6 +13,7 @@
import { ArrowBigUp } from 'lucide-svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { getHomeSelection, toBulkItem } from './homeSelection.svelte'
import { getSharedEditRights } from '$lib/operatorWriteRights'
import type { RowSelection } from '../common/table/rowSelection'
const dispatch = createEventDispatcher()
@@ -39,6 +40,20 @@
keyboardSelected = false
}: Props = $props()
const editRights = getSharedEditRights()
// Worked out per row rather than when the listing loads, so the rights follow the acting
// user as it resolves and the listing never has to reload for it.
let row = $derived({
...item,
canWrite:
editRights.canEditItem(
item.type === 'app' && item.raw_app ? 'raw_app' : item.type,
item.path,
item.extra_perms ?? {}
) &&
(item.type === 'script' || item.workspace_id == $workspaceStore)
})
// Read from context rather than threaded down: the tree renders items through
// several nested levels that have no other reason to know about selection.
const homeSelection = getHomeSelection()
@@ -48,7 +63,7 @@
// so it must not enter the selection either.
let bulkItem = $derived(
homeSelection?.available && item.type !== 'raw_app'
? toBulkItem(item, $userStore, $workspaceStore)
? toBulkItem(row, $userStore, $workspaceStore)
: undefined
)
$effect(() => {
@@ -63,7 +78,8 @@
key: bulkItem.key,
selected: homeSelection.has(bulkItem.key),
active: homeSelection.active,
onToggle: (e) => bulkItem && homeSelection.toggle(bulkItem, 'shiftKey' in e && e.shiftKey)
onToggle: (e) =>
bulkItem && homeSelection.toggle(bulkItem.key, 'shiftKey' in e && e.shiftKey)
}
: undefined
)
@@ -74,7 +90,7 @@
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('scriptChanged')}
script={item}
script={row}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
@@ -94,7 +110,8 @@
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('flowChanged')}
flow={item}
flow={row}
canAuthor={editRights.roleCanAuthor('flow')}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
@@ -113,7 +130,7 @@
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('appChanged')}
app={item}
app={row}
{moveDrawer}
{shareModal}
{deploymentDrawer}
@@ -126,7 +143,7 @@
{:else if item.type == 'raw_app'}
<RawAppRow
marked={item.marked}
app={item}
app={row}
{shareModal}
{deploymentDrawer}
{depth}
@@ -138,7 +155,7 @@
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('reload')}
agent={item}
agent={row}
{shareModal}
{deploymentDrawer}
{depth}
@@ -17,7 +17,7 @@
import { resource } from 'runed'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
import { disableHubStore, roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { shareEditRights, useEditRights } from '$lib/operatorWriteRights'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
Bot,
@@ -66,10 +66,10 @@
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
import { bulkKey, HomeSelection, setHomeSelection } from './homeSelection.svelte'
import HomeQuickAccess from './HomeQuickAccess.svelte'
const editRights = useEditRights()
const editRights = shareEditRights(useEditRights())
interface Props {
subtab?: 'flow' | 'script' | 'app' | 'agent'
@@ -238,7 +238,6 @@
})
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
time?: number
@@ -359,22 +358,12 @@
let loadGen = 0
function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent {
const base = {
...it,
canWrite:
editRights.canEditItem(
it.type === 'app' && it.raw_app ? 'raw_app' : it.type,
it.path,
(it.extra_perms ?? {}) as any
) &&
(it.type === 'script' || it.workspace_id == $workspaceStore)
}
// combinedItems reads a script's time from `created_at`; the endpoint's
// unified `edited_at` holds exactly that for scripts.
if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript
if (it.type === 'script') return { ...it, created_at: it.edited_at } as unknown as TableScript
// An agent row reads its resource's description, which the endpoint reports as `summary`.
if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent
return base as unknown as TableFlow | TableApp
if (it.type === 'agent') return { ...it, description: it.summary } as unknown as TableAgent
return it as unknown as TableFlow | TableApp
}
// The merged, server-ordered, keyset-paginated source. `reset` reloads from
@@ -383,7 +372,7 @@
// the existing per-kind arrays so the downstream pipeline is unchanged.
async function loadRunnables(reset: boolean): Promise<void> {
const ws = $workspaceStore
if (!ws || !$userStore) return
if (!ws) return
// Only the very first load shows the skeleton; filter reloads keep the toolbar
// and current items visible (they're replaced on arrival) so the filter control
// itself doesn't flicker out when you use it.
@@ -569,7 +558,7 @@
const force = opts?.force ?? false
const all = opts?.all ?? false
const ws = $workspaceStore
if (!ws || !$userStore) return
if (!ws) return
// Track the prefix as open first — even a no-op call (re-expanding a cached node)
// means it's on screen, so later reloads must refresh it.
openOwners.add(owner)
@@ -1309,7 +1298,7 @@
// folder paginates within itself; users past the window are reached via their owner chip.
let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false)
$effect(() => {
if ($userStore && $workspaceStore) {
if ($workspaceStore) {
;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind]
// reloadItems (not a bare loadRunnables) so an in-place change — sort, archive,
// library, kind — reloads the global stream AND re-fetches the currently-open owners
@@ -1340,7 +1329,7 @@
const kind = itemKind
// Any term/scope change restarts search paging.
searchCursor = undefined
if (term === '' || !ws || !$userStore) return
if (term === '' || !ws) return
const handle = setTimeout(async () => {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
@@ -1850,10 +1839,7 @@
displayedItems[selectedIndex].type !== 'raw_app'
) {
e.preventDefault()
homeSelection.toggle(
toBulkItem(displayedItems[selectedIndex], $userStore, $workspaceStore),
e.shiftKey
)
homeSelection.toggle(bulkKey(displayedItems[selectedIndex]), e.shiftKey)
} else if (selectedIndex === loadMoreIndex && hasMore) {
e.preventDefault()
loadMoreAndPreselectFirstNew()
@@ -41,19 +41,19 @@ describe('HomeSelection shift-range', () => {
it('selects the span between the anchor and the clicked row', () => {
const s = rendered(a, b, c)
s.toggle(a)
s.toggle(a.key)
s.toggle(c, true)
s.toggle(c.key, true)
expect(s.items.map((i) => i.key)).toEqual([a.key, b.key, c.key])
})
it('does not reach back into a selection the user already emptied', () => {
const s = rendered(a, b, c)
s.toggle(a)
s.toggle(a)
s.toggle(a.key)
s.toggle(a.key)
s.toggle(c, true)
s.toggle(c.key, true)
expect(s.items.map((i) => i.key)).toEqual([c.key])
})
@@ -150,16 +150,22 @@ export class HomeSelection {
else this.anchor = undefined
}
toggle(item: BulkItem, range = false): void {
if (range && this.anchor != undefined && this.anchor !== item.key) {
if (this.selectRange(this.anchor, item.key)) return
/** Ticks a rendered row by key. The entry stored is the one the row registered, so a click
* and a keyboard tick can never record different rights for the same row. */
toggle(key: string, range = false): void {
if (range && this.anchor != undefined && this.anchor !== key) {
if (this.selectRange(this.anchor, key)) return
}
if (this.selected.has(key)) this.selected.delete(key)
else {
const item = this.registry.get(key)
if (!item) return
this.selected.set(key, item)
}
if (this.selected.has(item.key)) this.selected.delete(item.key)
else this.selected.set(item.key, item)
// Unticking the last row ends selection mode, so the row it was on is not an
// anchor any more: the next shift-click would otherwise reach back and select
// everything between it and the row the user just started from.
this.anchor = this.selected.size === 0 ? undefined : item.key
this.anchor = this.selected.size === 0 ? undefined : key
}
/** Visual order is read back from the DOM: the tree nests rows and pages them
@@ -24,7 +24,7 @@ function selectionOf(...items: BulkItem[]): HomeSelection {
s.available = true
for (const i of items) {
s.register(i)
s.toggle(i)
s.toggle(i.key)
}
return s
}
@@ -1,6 +1,5 @@
import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
time?: number
@@ -99,14 +99,13 @@
const editRights = useEditRights()
const hosted = useHostedPage()
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
type ResourceTypeW = ResourceType & { canWrite: boolean }
type ResourceW = ListableResource & { marked?: string }
let cacheResources: ResourceW[] | undefined = $state()
let stateResources: ResourceW[] | undefined = $state()
let themeResources: ResourceW[] | undefined = $state()
let resources: ResourceW[] | undefined = $state()
let resourceTypes: ResourceTypeW[] | undefined = $state()
let resourceTypes: ResourceType[] | undefined = $state()
// Collect unique paths, types, owners for filter autocomplete
let allPaths: string[] = $state([])
@@ -332,14 +331,7 @@
apiParams.label = currentFilters.label
}
const result = (await ResourceService.listResource(apiParams)).map((x) => {
return {
canWrite:
editRights.canEditItem('resource', x.path, x.extra_perms) &&
$operatingWorkspace! == x.workspace_id,
...x
}
})
const result = await ResourceService.listResource(apiParams)
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
@@ -357,12 +349,7 @@
async function loadResourceTypes(): Promise<void> {
const rows = await ResourceService.listResourceType({ workspace: $operatingWorkspace! })
setResourceTypeDisplayNames(rows)
resourceTypes = rows.map((x) => {
return {
canWrite: $operatingWorkspace! == x.workspace_id,
...x
}
})
resourceTypes = rows
loading.types = false
}
@@ -686,9 +673,11 @@
}
})
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
if ($operatingWorkspace) {
untrack(() => {
loadResources()
// The effect above loads the open tab only; the workspace list must not keep the
// previous workspace's rows behind another tab.
if (tab !== 'workspace') loadResources()
loadResourceTypes()
})
}
@@ -1194,9 +1183,12 @@
</Head>
<tbody class="divide-y bg-surface">
{#if filteredItems}
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{#each filteredItems as { path, description, resource_type, extra_perms, workspace_id, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft}
{@const canWrite =
editRights.canEditItem('resource', path, extra_perms) &&
workspace_id == $operatingWorkspace}
<Row>
<Cell first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
@@ -1493,7 +1485,8 @@
</Head>
<tbody class="divide-y bg-surface">
{#if filteredResourceTypes}
{#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }}
{#each filteredResourceTypes as { name, description, schema, workspace_id, format_extension, is_fileset }}
{@const canWrite = workspace_id == $operatingWorkspace}
<Row>
<Cell first>
<a
@@ -78,11 +78,9 @@
else scheduleEditor?.openEdit(path, isFlow)
}
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
const MISSED_VISIBLE_MS = 7 * 24 * 60 * 60 * 1000
let schedules: ScheduleW[] = $state([])
let schedules: ScheduleWJobs[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let loadingSchedulesWithJobStats = $state(true)
@@ -144,9 +142,7 @@
apiParams.label = currentFilters.label
}
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current), ...x }
})
const result: ScheduleWJobs[] = await ScheduleService.listSchedules(apiParams)
if (generation !== loadGeneration) return
// Extract unique values for autocomplete
@@ -164,20 +160,16 @@
loadSchedulesWithJobStats(generation)
}
// One effect for every reason to reload (filters, workspace, user), so that mounting
// issues a single load rather than one per reason. Waits for the user lookup to settle,
// not for a user: a failed lookup still lists, read-only.
$effect(() => {
filters.val
operatingUser.current
if ($operatingWorkspace && operatingUser.resolved($operatingWorkspace)) {
if ($operatingWorkspace) {
untrack(() => loadSchedules())
}
})
async function loadSchedulesWithJobStats(generation: number): Promise<void> {
loadingSchedulesWithJobStats = true
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
let schedulesWithJobsByPath = new Map<string, ScheduleWJobs>()
let schedulesWithJobsList = await ScheduleService.listSchedulesWithJobs({
workspace: $operatingWorkspace!
})
@@ -343,7 +335,7 @@
})
function filterItemsPathsBaseOnUserFilters(
item: ScheduleW,
item: ScheduleWJobs,
selectedFilterKind: 'schedule' | 'script_flow',
userFoldersOnly: boolean
) {
@@ -365,7 +357,7 @@
}
function filterItemsBasedOnEnabledDisabled(
item: ScheduleW,
item: ScheduleWJobs,
filterEnabledDisabled: 'all' | 'enabled' | 'disabled'
) {
// Draft-only rows have nothing deployed, so they read as disabled here
@@ -391,7 +383,7 @@
let items = $derived(filteredItems)
</script>
{#snippet scheduleRow(s: ScheduleW, inTree: boolean, depth: number)}
{#snippet scheduleRow(s: ScheduleWJobs, inTree: boolean, depth: number)}
{@const {
path,
error,
@@ -404,7 +396,6 @@
script_path,
is_flow,
extra_perms,
canWrite,
jobs,
late_run_streak,
missed_occurrences,
@@ -493,7 +484,10 @@
</div>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
<SharedBadge
canWrite={canWrite(path, extra_perms!, operatingUser.current)}
extraPerms={extra_perms}
/>
</div>
<div class="w-10">
@@ -708,7 +702,7 @@
{/snippet}
{#snippet treeLeaf(item: ItemType, depth: number)}
{@render scheduleRow(item as unknown as ScheduleW, true, depth)}
{@render scheduleRow(item as unknown as ScheduleWJobs, true, depth)}
{/snippet}
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
@@ -129,9 +129,7 @@
}
const Icon = $derived(ICONS[triggerKind])
type TriggerW = TriggerRow & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let triggers: TriggerRow[] = $state([])
let loading = $state(true)
let editor: EditorHandle | undefined = $state()
let shareModal: ShareModal | undefined = $state()
@@ -171,10 +169,7 @@
async function loadTriggers(): Promise<void> {
const workspace = $operatingWorkspace!
const listed = await config.list({ workspace, includeDraftOnly: true })
triggers = listed.map((x) => ({
canWrite: canWrite(x.path, x.extra_perms!, operatingUser.current),
...x
}))
triggers = listed
// The nav rail lists the trigger kinds of the navigation workspace only.
if (workspace === $workspaceStore) {
$usedTriggerKinds = removeTriggerKindIfUnused(
@@ -188,7 +183,7 @@
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
if ($operatingWorkspace) {
untrack(() => loadTriggers())
}
})
@@ -253,7 +248,7 @@
return committed
}
async function deleteTrigger(row: TriggerW) {
async function deleteTrigger(row: TriggerRow) {
const workspace = $operatingWorkspace ?? ''
if (triggerKind === 'postgres' || triggerKind === 'gcp' || triggerKind === 'azure') {
isDeleting = false
@@ -280,7 +275,7 @@
// Kinds whose trigger owns an external subscription (a replication slot and publication, a
// cloud subscription) confirm the delete, offering to drop those too.
let pendingDelete: TriggerW | undefined = $state(undefined)
let pendingDelete: TriggerRow | undefined = $state(undefined)
let isDeleting = $state(false)
let deleteA = $state(false)
let deleteB = $state(false)
@@ -370,7 +365,7 @@
}
})
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let filteredItems: (TriggerRow & { marked?: any })[] | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
@@ -428,7 +423,7 @@
})
})
function filterItemsPathsBaseOnUserFilters(item: TriggerW) {
function filterItemsPathsBaseOnUserFilters(item: TriggerRow) {
if ($operatingWorkspace == 'admins') return true
if (!filterUserFolders) return true
const path = selectedFilterKind === 'trigger' ? item.path : item.script_path
@@ -524,7 +519,7 @@
const canCreate = $derived(!config.adminOnly || isAdmin)
</script>
{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)}
{#snippet rowTitle(row: TriggerRow & { marked?: any }, hasDraft: boolean | undefined)}
{@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)
? row.marked
: undefined}
@@ -576,7 +571,7 @@
</div>
{/snippet}
{#snippet statusDot(row: TriggerW)}
{#snippet statusDot(row: TriggerRow)}
{@const status = config.status}
{#if status}
{@const ping = status.pings
@@ -672,7 +667,7 @@
</div>
</ConfirmationModal>
{#snippet triggerRow(row: TriggerW & { marked?: any }, inTree: boolean, depth: number)}
{#snippet triggerRow(row: TriggerRow & { marked?: any }, inTree: boolean, depth: number)}
{@const {
path,
edited_by,
@@ -680,7 +675,6 @@
script_path,
is_flow,
extra_perms,
canWrite,
mode,
retry,
error_handler_path,
@@ -720,7 +714,10 @@
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
<SharedBadge
canWrite={canWrite(path, extra_perms!, operatingUser.current)}
extraPerms={extra_perms}
/>
{#if triggerKind !== 'http' && triggerKind !== 'azure' && labels?.length}
{#each labels as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
@@ -854,7 +851,7 @@
{/snippet}
{#snippet treeLeaf(item: ItemType, depth: number)}
{@render triggerRow(item as unknown as TriggerW & { marked?: any }, true, depth)}
{@render triggerRow(item as unknown as TriggerRow & { marked?: any }, true, depth)}
{/snippet}
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
@@ -76,9 +76,7 @@
else variableEditor?.editVariable(path)
}
type ListableVariableW = ListableVariable & { canWrite: boolean }
let variables = $state(undefined) as ListableVariableW[] | undefined
let variables = $state(undefined) as ListableVariable[] | undefined
let showCreateButtons = $state(false)
// Collect unique values for filter autocomplete
@@ -207,14 +205,7 @@
apiParams.label = currentFilters.label
}
const result = (await VariableService.listVariable(apiParams)).map((x) => {
return {
canWrite:
editRights.canEditItem('variable', x.path, x.extra_perms) &&
x.workspace_id == $operatingWorkspace,
...x
}
})
const result = await VariableService.listVariable(apiParams)
// Extract unique values for autocomplete
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
@@ -265,11 +256,8 @@
}
$effect(() => {
if ($operatingWorkspace && operatingUser.current) {
untrack(() => {
loadVariables()
loadContextualVariables()
})
if ($operatingWorkspace) {
untrack(() => loadContextualVariables())
}
})
let tab: 'workspace' | 'contextual' = $state('workspace')
@@ -442,9 +430,12 @@
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_valid_until }}
{#each filteredItems as { path, value, is_secret, description, extra_perms, workspace_id, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_valid_until }}
{@const hasDraft =
getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft}
{@const canWrite =
editRights.canEditItem('variable', path, extra_perms) &&
workspace_id == $operatingWorkspace}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
+17
View File
@@ -1,3 +1,4 @@
import { getContext, setContext } from 'svelte'
import { derived, fromStore, get, readable, toStore, type Readable } from 'svelte/store'
import type { RoleCapabilities } from '$lib/gen'
import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
@@ -49,6 +50,22 @@ export function useEditRights(workspace?: () => string | undefined) {
}
}
type EditRights = ReturnType<typeof useEditRights>
const EDIT_RIGHTS_KEY = Symbol('editRights')
/** A list's rows all act as the same user in the same workspace, so they read the list's
* `useEditRights` instance rather than each setting up their own. */
export function shareEditRights(rights: EditRights): EditRights {
setContext(EDIT_RIGHTS_KEY, rights)
return rights
}
/** Only under a component that called `shareEditRights`: elsewhere there is no instance and the
* first check throws. */
export function getSharedEditRights(): EditRights {
return getContext<EditRights>(EDIT_RIGHTS_KEY)
}
/**
* The role's capabilities in `workspace`, from the workspace list alone: `$userStore` describes
* only the navigation workspace, so elsewhere only superadmin carries over. Admins get full
@@ -21,28 +21,17 @@
import { untrack } from 'svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
type FolderW = Folder & { canWrite: boolean }
let restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let folders: FolderW[] | undefined = $state(undefined)
let folders: Folder[] | undefined = $state(undefined)
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let hubDrawer: Drawer | undefined = $state()
let publishFolderName: string = $state('')
async function loadFolders(): Promise<void> {
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
return {
canWrite:
$userStore != undefined &&
($userStore?.is_admin ||
$userStore?.is_super_admin ||
$userStore?.folders_owners.includes(x.name)),
...x
}
})
folders = await FolderService.listFolders({ workspace: $workspaceStore! })
}
function onFolderSaved(name: string, created: boolean) {
@@ -53,7 +42,7 @@
}
$effect(() => {
if ($workspaceStore && $userStore) {
if ($workspaceStore) {
untrack(() => {
loadFolders()
})
@@ -164,7 +153,12 @@
</tr>
{/each}
{:else}
{#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)}
{#each folders as { name, extra_perms, owners, summary, labels } (name)}
{@const canWrite =
$userStore != undefined &&
($userStore.is_admin ||
$userStore.is_super_admin ||
$userStore.folders_owners.includes(name))}
<Row hoverable on:click={() => folderEditorDrawer?.initEdit(name)}>
<Cell first>
<span class="text-emphasis text-xs font-semibold">{name}</span>
@@ -22,22 +22,18 @@
import { Tooltip } from '$lib/components/meltComponents'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
type GroupW = Group & { canWrite: boolean }
const editRights = useEditRights()
let restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let groups: GroupW[] | undefined = $state(undefined)
let groups: Group[] | undefined = $state(undefined)
let instanceGroups: InstanceGroupWithWorkspaces[] | undefined = $state(undefined)
let groupEditorDrawer: GroupEditorDrawer | undefined = $state()
async function loadGroups(): Promise<void> {
groups = (await GroupService.listGroups({ workspace: $workspaceStore! })).map((x) => {
return { canWrite: editRights.canEditItem('group', x.name, x.extra_perms), ...x }
})
groups = await GroupService.listGroups({ workspace: $workspaceStore! })
}
async function loadInstanceGroups(): Promise<void> {
@@ -50,7 +46,7 @@
$effect(() => {
untrack(() => loadInstanceGroups())
if ($workspaceStore && $userStore) {
if ($workspaceStore) {
untrack(() => loadGroups())
}
})
@@ -127,7 +123,8 @@
</tr>
{/each}
{:else}
{#each groups as { name, summary, extra_perms, canWrite } (name)}
{#each groups as { name, summary, extra_perms } (name)}
{@const canWrite = editRights.canEditItem('group', name, extra_perms)}
<Row hoverable on:click={() => groupEditorDrawer?.initEdit(name)}>
<Cell first>
<div class="flex flex-row gap-2 justify-between">