mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 08:03:02 +00:00
* feat: mount session list pages in process instead of iframes * fix: seed filter defaults from the query, not the cached search params * fix: type trigger list rows from the generated trigger types * fix: judge hosted lists by the operating workspace's rules and keep role-gated filters * fix: keep the user folders filter key for every role, hidden where it does not apply * fix: drop the user folders filter for users it is not offered to * fix: wait for a known user before dropping the user folders filter * style: tint trigger rows for every kind and align the schedules footer wording * fix: stamp edited_at when a schedule is updated --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
287 lines
10 KiB
Svelte
287 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { Button, Drawer } from './common'
|
|
|
|
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
|
|
|
import { History, Loader2, Save } from 'lucide-svelte'
|
|
import WsSpecificVersions from './WsSpecificVersions.svelte'
|
|
import { isOwner } from '$lib/utils'
|
|
import { useActingUser } from '$lib/actingUser.svelte'
|
|
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
|
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
|
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
|
|
import {
|
|
clearPageDrawerAnchor,
|
|
pageDrawerSessionSource,
|
|
setPageDrawerAnchor
|
|
} from './sessions/pageDrawerSession'
|
|
import { RESOURCES_PATH } from './sessions/previewPaths'
|
|
import ResourceVersionHistory from './ResourceVersionHistory.svelte'
|
|
import IconedResourceType from './IconedResourceType.svelte'
|
|
import { addResourceTitle } from './resourceTypeDisplay'
|
|
import { loadResourceTypeDisplayName } from './displayNameLoaders'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
let {
|
|
workspace = undefined,
|
|
disableChatOffset = false,
|
|
inline = false,
|
|
onClose = undefined,
|
|
onRestored = undefined,
|
|
onSaved = undefined
|
|
}: {
|
|
/** Workspace this drawer acts in. Optional; the navigation workspace is substituted
|
|
* once, at `effectiveWorkspace`, and nowhere else in this file. */
|
|
workspace?: string
|
|
disableChatOffset?: boolean
|
|
/** Render in place, filling the parent, with no drawer or close button — for a host
|
|
* that gives the editor a whole pane. Saving and restoring then leave it open: the
|
|
* host remounts it on what was written. */
|
|
inline?: boolean
|
|
/** With `inline`, closes whatever hosts the editor; the header has a close button only when set. */
|
|
onClose?: () => void
|
|
onRestored?: () => void
|
|
/** Fires after Save has written, for a caller showing state derived from the
|
|
* resource — `onRestored` only covers restoring an old version. `path` is where the
|
|
* resource now lives in this drawer's workspace, undefined when the save failed. */
|
|
onSaved?: (path: string | undefined) => void
|
|
} = $props()
|
|
|
|
let drawer: Drawer | undefined = $state()
|
|
let historyDrawer: Drawer | undefined = $state()
|
|
let canSave = $state(true)
|
|
let resource_type: string | undefined = $state(undefined)
|
|
let defaultValues: Record<string, any> | undefined = $state(undefined)
|
|
|
|
let resourceEditor:
|
|
| {
|
|
save: () => Promise<boolean>
|
|
pathIn: (ws: string) => string | undefined
|
|
localDraftDeployed: () => unknown
|
|
localDraftCurrent: () => unknown
|
|
discardLocalDraft: () => void
|
|
endEditingSession: () => void
|
|
resolveDraftConflictFromBanner: (keepMine: boolean) => void
|
|
}
|
|
| undefined = $state(undefined)
|
|
let hasLocalDraft = $state(false)
|
|
let draftConflict = $state({ conflicted: false, busy: false })
|
|
let canWriteSelected = $state(true)
|
|
|
|
let path: string | undefined = $state(undefined)
|
|
let selected: string | undefined = $state(undefined)
|
|
let viewJsonSchema = $state(false)
|
|
|
|
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!)
|
|
// The editor renders whichever workspace-specific variant `selected` points at, so history has
|
|
// to follow it too — otherwise a restore would write over the variant the user is not looking at.
|
|
let historyWorkspace = $derived(selected ?? effectiveWorkspace)
|
|
// Gated on `path`: this drawer outlives every resource it opens, so there is nothing to
|
|
// answer about until one is open.
|
|
const historyUser = useActingUser(() => (path ? historyWorkspace : undefined))
|
|
// Clearing is irreversible and the backend gates it on ownership, not write access, so the
|
|
// verdict has to come from the membership `historyWorkspace` knows about. An unresolved
|
|
// user gets no Clear button rather than one computed from another workspace's rights.
|
|
let canClearSelected = $derived(isOwner(path ?? '', historyUser.current, historyWorkspace))
|
|
|
|
// A close reaches `on:close` on a later flush, by which point a caller that closed this drawer to
|
|
// open another editor has already anchored the new one. Clearing then would strip that anchor.
|
|
let keepAnchorOnClose = false
|
|
|
|
/** Shut this drawer without going through its own close button, for a caller opening the other
|
|
* editor over the same list. `keepAnchor` when that caller anchors what it opens instead. */
|
|
export function close(opts?: { keepAnchor?: boolean }): void {
|
|
keepAnchorOnClose = opts?.keepAnchor ?? false
|
|
drawer?.closeDrawer?.()
|
|
}
|
|
|
|
/** `json` opens on the JSON editor instead of the resource type's form. For a type with a
|
|
* dedicated editor elsewhere: the generic form would render its configuration field by field,
|
|
* and materialize a default into every one the value leaves out. */
|
|
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
|
|
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
|
|
// would still be standing when the next drawer session ends and would swallow that one's
|
|
// anchor clear. Every session starts having to clear its own.
|
|
keepAnchorOnClose = false
|
|
historyUser.forgetFailures()
|
|
resource_type = undefined
|
|
path = p
|
|
selected = effectiveWorkspace
|
|
viewJsonSchema = opts?.json ?? false
|
|
drawer?.openDrawer?.()
|
|
setPageDrawerAnchor(RESOURCES_PATH, p)
|
|
}
|
|
|
|
export async function initNew(
|
|
resourceType: string,
|
|
nDefaultValues?: Record<string, any>
|
|
): Promise<void> {
|
|
keepAnchorOnClose = false
|
|
historyUser.forgetFailures()
|
|
path = undefined
|
|
resource_type = resourceType
|
|
defaultValues = nDefaultValues
|
|
selected = effectiveWorkspace
|
|
// This drawer outlives what it opens on, so the view has to be set by every entry point
|
|
// rather than left where the last one put it: a new resource is a typed form, whoever was
|
|
// looking at JSON before.
|
|
viewJsonSchema = false
|
|
// The title names the type, whose row nothing else on the page may have read.
|
|
void loadResourceTypeDisplayName(effectiveWorkspace, resourceType)
|
|
drawer?.openDrawer?.()
|
|
}
|
|
|
|
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
|
|
|
|
// `selected`, not `effectiveWorkspace`: WsSpecificVersions re-points this drawer
|
|
// at another workspace's version, and the session must act on the one shown.
|
|
const sessionSource = $derived(
|
|
pageDrawerSessionSource(RESOURCES_PATH, path, selected ?? effectiveWorkspace)
|
|
)
|
|
</script>
|
|
|
|
{#if inline}
|
|
<!-- ResourceEditor reads its path once, at mount — a drawer mounts it only when opened. -->
|
|
{#if path !== undefined || resource_type !== undefined}
|
|
{@render content()}
|
|
{/if}
|
|
{:else}
|
|
<Drawer
|
|
bind:this={drawer}
|
|
size="50rem"
|
|
{disableChatOffset}
|
|
on:close={() => {
|
|
// The editor outlives this drawer, so tell it the session is over: a conflict resolution
|
|
// still in flight must not land on whatever the next opening shows.
|
|
resourceEditor?.endEditingSession?.()
|
|
if (keepAnchorOnClose) {
|
|
keepAnchorOnClose = false
|
|
return
|
|
}
|
|
clearPageDrawerAnchor(RESOURCES_PATH)
|
|
}}
|
|
>
|
|
{@render content()}
|
|
</Drawer>
|
|
{/if}
|
|
|
|
{#snippet content()}
|
|
<DrawerContent
|
|
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
|
bannerReserved={mode == 'edit'}
|
|
hideClose={inline && !onClose}
|
|
fullScreen={!inline}
|
|
on:close={() => {
|
|
// Inline has no drawer to emit a close, so the session ends here instead.
|
|
if (inline) {
|
|
resourceEditor?.endEditingSession?.()
|
|
onClose?.()
|
|
} else {
|
|
drawer?.closeDrawer()
|
|
}
|
|
}}
|
|
>
|
|
{#snippet titleExtra()}
|
|
{#if mode == 'new' && resource_type}
|
|
<IconedResourceType name={resource_type} silent width="20px" height="20px" />
|
|
{/if}
|
|
{/snippet}
|
|
{#await import('./ResourceEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
{path}
|
|
{resource_type}
|
|
{defaultValues}
|
|
workspace={effectiveWorkspace}
|
|
on:refresh
|
|
bind:this={resourceEditor}
|
|
bind:canSave
|
|
bind:selected
|
|
bind:viewJsonSchema
|
|
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
|
onDraftConflictChange={(v) => (draftConflict = v)}
|
|
onCanWriteChange={(v) => (canWriteSelected = v)}
|
|
/>
|
|
{/await}
|
|
{#snippet banner()}
|
|
{#if draftConflict.conflicted}
|
|
<DraftConflictAlert
|
|
busy={draftConflict.busy}
|
|
onReload={() => resourceEditor?.resolveDraftConflictFromBanner?.(false)}
|
|
onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)}
|
|
/>
|
|
{/if}
|
|
<LocalDraftBanner
|
|
show={hasLocalDraft}
|
|
reserveSpace={mode == 'edit'}
|
|
getDeployed={() => resourceEditor?.localDraftDeployed()}
|
|
getCurrent={() => resourceEditor?.localDraftCurrent()}
|
|
onDiscard={() => resourceEditor?.discardLocalDraft()}
|
|
disabled={!canWriteSelected}
|
|
/>
|
|
{/snippet}
|
|
{#snippet actions()}
|
|
<OpenInSessionButton source={sessionSource} />
|
|
{#if mode == 'edit' && path && effectiveWorkspace}
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
startIcon={{ icon: History }}
|
|
on:click={() => historyDrawer?.openDrawer()}
|
|
>
|
|
History
|
|
</Button>
|
|
<WsSpecificVersions
|
|
kind="resource"
|
|
workspaceId={effectiveWorkspace}
|
|
initialPath={path}
|
|
bind:selected
|
|
/>
|
|
{/if}
|
|
<Button
|
|
variant="accent"
|
|
unifiedSize="md"
|
|
startIcon={{ icon: Save }}
|
|
on:click={async () => {
|
|
// Closed before the write is awaited, the way it always was: `save()` toasts its
|
|
// own failures and never rejects, so waiting would only add visible lag to every
|
|
// caller of this drawer. `onSaved` still fires after the write lands.
|
|
const editor = resourceEditor
|
|
const saved = editor?.save()
|
|
drawer?.closeDrawer()
|
|
onSaved?.((await saved) ? editor?.pathIn(effectiveWorkspace) : undefined)
|
|
}}
|
|
disabled={!canSave}
|
|
>
|
|
Save
|
|
</Button>
|
|
{/snippet}
|
|
</DrawerContent>
|
|
{/snippet}
|
|
|
|
<Drawer bind:this={historyDrawer} size="1200px">
|
|
<DrawerContent title="Versions History" on:close={historyDrawer?.closeDrawer} noPadding>
|
|
{#if path && historyWorkspace}
|
|
<ResourceVersionHistory
|
|
{path}
|
|
workspace={historyWorkspace}
|
|
canRestore={canWriteSelected}
|
|
canClear={canClearSelected}
|
|
onRestore={() => {
|
|
historyDrawer?.closeDrawer()
|
|
// Close the editor too. It holds a baseline captured before the restore, and
|
|
// any local draft on top of it, so saving from it afterwards would write the
|
|
// pre-restore value straight back over the version just restored.
|
|
drawer?.closeDrawer()
|
|
// Its own callback rather than the `refresh` event: callers bind that to
|
|
// reopening a picker (EditorBar), which a restore should not trigger.
|
|
onRestored?.()
|
|
}}
|
|
/>
|
|
{/if}
|
|
</DrawerContent>
|
|
</Drawer>
|