diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index eb58ecbc3f..beb6b00d0f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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) {#if menuOpen} diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index 67dac7b3dd..9e7375c866 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -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'} dispatch('reload')} - agent={item} + agent={row} {shareModal} {deploymentDrawer} {depth} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 36099ac1b9..c16056347f 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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 & { - 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 { 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() diff --git a/frontend/src/lib/components/home/homeSelection.dom.test.ts b/frontend/src/lib/components/home/homeSelection.dom.test.ts index 17ddad1be9..257d318d3f 100644 --- a/frontend/src/lib/components/home/homeSelection.dom.test.ts +++ b/frontend/src/lib/components/home/homeSelection.dom.test.ts @@ -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]) }) diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index 0b4cfcba7a..45523c10ca 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -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 diff --git a/frontend/src/lib/components/home/homeSelection.test.ts b/frontend/src/lib/components/home/homeSelection.test.ts index 25502d7eaf..8c74d2736f 100644 --- a/frontend/src/lib/components/home/homeSelection.test.ts +++ b/frontend/src/lib/components/home/homeSelection.test.ts @@ -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 } diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index a36fbbee90..3269017c0b 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -1,6 +1,5 @@ import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen' type TableItem = T & { - canWrite: boolean marked?: string type?: U time?: number diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte index b592d0b86c..90323c4ca2 100644 --- a/frontend/src/lib/components/resources/ResourcesList.svelte +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -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 { 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 @@ {#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} @@ -1493,7 +1485,8 @@ {#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} { - 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 { loadingSchedulesWithJobStats = true - let schedulesWithJobsByPath = new Map() + let schedulesWithJobsByPath = new Map() 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) -{#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 @@
@@ -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} diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte index 3931d9660c..649ff6412a 100644 --- a/frontend/src/lib/components/triggers/TriggerList.svelte +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -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 { 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) -{#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 @@
{/snippet} -{#snippet statusDot(row: TriggerW)} +{#snippet statusDot(row: TriggerRow)} {@const status = config.status} {#if status} {@const ping = status.pings @@ -672,7 +667,7 @@ -{#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 @@