mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat(drafts): autosave-indicator popover with Reset-to-deployed action
Click the cloud icon → popover with "All changes are saved as a draft on
the server. The draft is per-user — your teammates' editors keep their
own." When the editor isn't on a draft-only path AND the user has a
draft (UserDraft.has returns true), a "Reset to deployed" button
mirrors the load-time toast action — stops sync, POSTs `value: null`,
runs the route's reload-without-draft callback, restarts sync past two
ticks so the deployed-seed write doesn't resurrect the draft.
Threaded `onResetToDeployed` from each route down to its builder
(ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader)
and into the indicator. `draftOnly` is wired from `savedScript.no_deployed`
/ `newFlow` / `newApp` so the action hides where there's nothing to fall
back to. The indicator's trigger now has a hover affordance + matches
Portal's default target ('body') via Modal2's earlier fix.
This commit is contained in:
@@ -1,14 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { CloudCheck, RefreshCcw } from 'lucide-svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
itemKind,
|
||||
path
|
||||
}: { workspace: string; itemKind: UserDraftItemKind; path: string } = $props()
|
||||
path,
|
||||
// Reactive — when true, the indicator's popover hides "Reset to
|
||||
// deployed" because there's nothing to fall back to (the editor
|
||||
// is on a per-user draft at a path with no deployed row). Routes
|
||||
// thread their own `isNewX` / `savedX.no_deployed` here.
|
||||
draftOnly = false,
|
||||
// Route-specific reset logic. The popover button stops sync,
|
||||
// fires `value: null` at the syncer, awaits this, then restarts
|
||||
// sync after two ticks — mirrors `notifyDraftLoaded`'s "Reset to
|
||||
// deployed" toast action so the discard sticks.
|
||||
onResetToDeployed
|
||||
}: {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
draftOnly?: boolean
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
// `UserDraft.has` reads `entry.state.val` (a $state), so the $derived
|
||||
// re-runs when the in-memory draft appears / disappears — flips on
|
||||
// after the first edit and back off after a successful reset.
|
||||
const hasDraft = $derived(UserDraft.has(itemKind, path, { workspace }))
|
||||
|
||||
// Recompute the handle when the target draft changes; its `.state` getter
|
||||
// is itself reactive to the autosave pipeline, so `syncState` tracks both.
|
||||
@@ -55,15 +80,77 @@
|
||||
const label = $derived(
|
||||
syncState === 'saving' || syncState === 'pending' ? 'Saving...' : savedVisible ? 'Saved' : ''
|
||||
)
|
||||
|
||||
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
|
||||
|
||||
let popoverOpen = $state(false)
|
||||
let resetting = $state(false)
|
||||
|
||||
async function resetToDeployed() {
|
||||
if (!onResetToDeployed || resetting) return
|
||||
resetting = true
|
||||
// Mirror the `notifyDraftLoaded` toast action so the discard
|
||||
// sticks: suspend sync, POST the explicit delete, run the
|
||||
// route's reload, then restart sync two ticks past the
|
||||
// deployed-seed write.
|
||||
UserDraft.stopSync(itemKind, path, { workspace })
|
||||
UserDraftDbSyncer.save({ workspace, itemKind, path, value: null }).catch((e) =>
|
||||
console.error('Reset to deployed: draft delete failed', e)
|
||||
)
|
||||
try {
|
||||
await onResetToDeployed()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true)
|
||||
} finally {
|
||||
await tick()
|
||||
await tick()
|
||||
UserDraft.restartSync(itemKind, path, { workspace })
|
||||
resetting = false
|
||||
popoverOpen = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5 text-primary min-w-[4.2rem]">
|
||||
{#if syncState === 'saving' || syncState === 'pending'}
|
||||
<RefreshCcw size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<CloudCheck size={16} />
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="text-secondary text-2xs">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Popover
|
||||
bind:isOpen={popoverOpen}
|
||||
placement="bottom-end"
|
||||
contentClasses="p-3 max-w-xs"
|
||||
usePointerDownOutside
|
||||
closeOnOutsideClick
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-primary min-w-[4.2rem] rounded-md px-1 py-0.5 hover:bg-surface-hover cursor-pointer"
|
||||
aria-label="Autosave status"
|
||||
>
|
||||
{#if syncState === 'saving' || syncState === 'pending'}
|
||||
<RefreshCcw size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<CloudCheck size={16} />
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="text-secondary text-2xs">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 text-sm">
|
||||
<p class="text-secondary leading-snug">
|
||||
All changes are saved as a draft on the server. The draft is per-user — your teammates'
|
||||
editors keep their own.
|
||||
</p>
|
||||
{#if showResetAction}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
loading={resetting}
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
on:click={() => void resetToDeployed()}
|
||||
>
|
||||
Reset to deployed
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
onDeployError,
|
||||
onDetails,
|
||||
onHistoryRestore,
|
||||
onNavigate
|
||||
onNavigate,
|
||||
onResetToDeployed
|
||||
}: FlowBuilderProps = $props()
|
||||
|
||||
let initialPathStore = writable(initialPath)
|
||||
@@ -1054,6 +1055,8 @@
|
||||
workspace={$workspaceStore}
|
||||
itemKind="flow"
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1088,6 +1091,7 @@
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
|
||||
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
{loading}
|
||||
|
||||
@@ -127,7 +127,8 @@
|
||||
onNavigate,
|
||||
disableAi,
|
||||
initialTestPanelCollapsed = false,
|
||||
initialPathChosen = false
|
||||
initialPathChosen = false,
|
||||
onResetToDeployed
|
||||
}: ScriptBuilderProps = $props()
|
||||
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
@@ -1866,7 +1867,13 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if $workspaceStore}
|
||||
<AutosaveIndicator workspace={$workspaceStore} itemKind="script" path={userDraftPath} />
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="script"
|
||||
path={userDraftPath}
|
||||
draftOnly={(savedScript as any)?.no_deployed === true}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1947,6 +1954,7 @@
|
||||
{@render settingsButton()}
|
||||
{/if}
|
||||
|
||||
|
||||
<DeployButton
|
||||
loading={!fullyLoaded}
|
||||
{loadingSave}
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
onSavedNewAppPath,
|
||||
onNavigate,
|
||||
initialRevs
|
||||
initialRevs,
|
||||
onResetToDeployed
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
@@ -904,6 +905,7 @@
|
||||
{newPath}
|
||||
{newApp}
|
||||
userDraftPath={appDraftPath}
|
||||
{onResetToDeployed}
|
||||
on:restore
|
||||
{policy}
|
||||
{fromHub}
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
onHideLeftPanel?: () => void
|
||||
onHideBottomPanel?: () => void
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -127,7 +130,8 @@
|
||||
onHideLeftPanel,
|
||||
onHideRightPanel,
|
||||
onHideBottomPanel,
|
||||
onNavigate = undefined
|
||||
onNavigate = undefined,
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
|
||||
/** Mirror of the path the user is editing in the pen popover. Initialized
|
||||
@@ -940,7 +944,13 @@
|
||||
</div>
|
||||
{#if $workspaceStore}
|
||||
<div class="ml-4">
|
||||
<AutosaveIndicator workspace={$workspaceStore} itemKind="app" path={userDraftPath} />
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind="app"
|
||||
path={userDraftPath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -173,6 +173,10 @@ export interface AppEditorProps {
|
||||
* drift — `previousMeta` would be empty and the modal wouldn't fire.
|
||||
*/
|
||||
initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta
|
||||
// Threaded through `AppEditorHeader` to the `AutosaveIndicator`
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export type App = {
|
||||
|
||||
@@ -38,4 +38,7 @@ export type FlowBuilderProps = {
|
||||
onDetails?: ({ path }: { path: string }) => void
|
||||
onHistoryRestore?: () => void
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@
|
||||
* raw-app value so the home-page row can render the friendly name
|
||||
* instead of the URL's autogenerated `draft_{uuid}` slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded through `RawAppEditorHeader` to the `AutosaveIndicator`
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -111,7 +115,8 @@
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed',
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
defaultSplitWithPreview = true,
|
||||
pendingDraftPath = $bindable(undefined)
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -1379,6 +1384,7 @@
|
||||
{getBundle}
|
||||
{onNavigate}
|
||||
{onDeploy}
|
||||
{onResetToDeployed}
|
||||
canUndo={historyManager.canUndo}
|
||||
canRedo={historyManager.canRedo}
|
||||
onUndo={handleUndo}
|
||||
|
||||
@@ -133,6 +133,9 @@
|
||||
* draft as `draft_path` so the home-page row can render the
|
||||
* friendly name instead of the URL's autogenerated draft slot. */
|
||||
pendingDraftPath?: string | undefined
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -160,7 +163,8 @@
|
||||
onNavigate = undefined,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onDeploy = undefined,
|
||||
pendingDraftPath = $bindable(undefined)
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
@@ -725,6 +729,8 @@
|
||||
workspace={$workspaceStore}
|
||||
itemKind="raw_app"
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -64,4 +64,10 @@ export interface ScriptBuilderProps {
|
||||
// overwrite it. Used by the session preview, which opens AI-created scripts
|
||||
// as new but with a path the AI already assigned.
|
||||
initialPathChosen?: boolean
|
||||
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
||||
// deployed" button can do the same thing the load-time toast offers.
|
||||
// Routes pass their own re-load-without-draft callback here; omit on
|
||||
// callers (session preview, embedded SDK) that shouldn't surface the
|
||||
// action at all.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
@@ -355,6 +355,11 @@
|
||||
initialRevs={currentRevs}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
onResetToDeployed={async () => {
|
||||
UserDraft.remove('app', path)
|
||||
await loadApp({ getDraft: false })
|
||||
redraw++
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -559,6 +559,10 @@
|
||||
bind:savedApp
|
||||
{diffDrawer}
|
||||
newApp={isNewApp}
|
||||
onResetToDeployed={async () => {
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
await loadApp({ getDraft: false })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -449,6 +449,10 @@
|
||||
onHistoryRestore={() => {
|
||||
loadFlow()
|
||||
}}
|
||||
onResetToDeployed={async () => {
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
await loadFlow({ getDraft: false })
|
||||
}}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
{flowStore}
|
||||
{flowStateStore}
|
||||
|
||||
@@ -395,6 +395,10 @@
|
||||
{diffDrawer}
|
||||
{savedPrimarySchedule}
|
||||
searchParams={page.url.searchParams}
|
||||
onResetToDeployed={async () => {
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
await loadScript({ getDraft: false })
|
||||
}}
|
||||
onDeploy={(e) => {
|
||||
// "Deploy & Stay here" / lib: stay on the editor (just confirm).
|
||||
if (e.stay) {
|
||||
|
||||
Reference in New Issue
Block a user