mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal
The "Loaded your saved draft" toast and the auto-opening
OtherUsersDraftsModal both surprised users on every editor mount. Move
both signals into the AutosaveIndicator label: "Loaded from draft" or
"Others are working on this {kind}" (priority) sits where Saving/Saved
do, with a one-shot light-green flash behind the indicator that fades
to transparent. Saving/Saved still win when they fire. The popover
gains a "See others' drafts" button that flips the modal open on
demand; the modal itself is now externally controlled via a bindable
\`isOpen\` threaded through DraftEditorModals.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
54c7388071
commit
5e14a7c633
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
|
||||
import { CloudCheck, RefreshCcw, RotateCcw, Users } from 'lucide-svelte'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -21,13 +21,32 @@
|
||||
// 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
|
||||
onResetToDeployed,
|
||||
// Set true on the first overlay response that came back with
|
||||
// `is_draft: true` — triggers the on-mount "Loaded from draft"
|
||||
// hint label and green-flash animation. Replaces the old
|
||||
// `notifyDraftLoaded` toast. We snapshot the prop at mount so
|
||||
// re-renders from prop churn don't re-trigger the hint.
|
||||
loadedFromDraft = false,
|
||||
// Number of OTHER workspace users with a draft at this path
|
||||
// (i.e. the deployed-overlay's `other_drafts_users` length).
|
||||
// When > 0, the on-mount hint takes priority over "Loaded from
|
||||
// draft" and the popover offers a "See others' drafts" button
|
||||
// that flips the OtherUsersDraftsModal open through
|
||||
// `onOpenOthersDrafts`.
|
||||
othersDraftsCount = 0,
|
||||
// Wired by the route to the bindable `othersModalOpen` it
|
||||
// threads into DraftEditorModals.
|
||||
onOpenOthersDrafts
|
||||
}: {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
draftOnly?: boolean
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
} = $props()
|
||||
|
||||
// `UserDraft.has` reads `entry.state.val` (a $state), so the $derived
|
||||
@@ -77,8 +96,61 @@
|
||||
}
|
||||
})
|
||||
|
||||
// On-mount load hint — "Others are working..." or "Loaded from draft".
|
||||
// Stays for HINT_LABEL_MS, and the wrapper gets a one-shot green-flash
|
||||
// CSS animation that fades to transparent. Snapshot the props at mount
|
||||
// so we don't re-fire the hint as the route re-renders the indicator.
|
||||
const HINT_LABEL_MS = 7000
|
||||
let hintLabel = $state('')
|
||||
let hintFlashKey = $state(0)
|
||||
let hintTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const kindLabel = $derived(
|
||||
itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script'
|
||||
)
|
||||
|
||||
// Triggers when either prop becomes truthy. Each truthy transition fires
|
||||
// a fresh flash — wins precedence is computed here too (others > loaded).
|
||||
// Seed to `false` (NOT to the props' current values) so a prop that is
|
||||
// already truthy at mount counts as the first false → true transition
|
||||
// and fires the hint; routes pass `loadedFromDraft = true` immediately
|
||||
// after the overlay response comes back, so this is the common case.
|
||||
let prevOthers = false
|
||||
let prevLoaded = false
|
||||
$effect(() => {
|
||||
const othersNow = othersDraftsCount > 0
|
||||
const loadedNow = !!loadedFromDraft
|
||||
untrack(() => {
|
||||
const othersTransition = othersNow && !prevOthers
|
||||
const loadedTransition = loadedNow && !prevLoaded
|
||||
if (othersTransition || loadedTransition) {
|
||||
hintLabel = othersNow ? `Others are working on this ${kindLabel}` : 'Loaded from draft'
|
||||
hintFlashKey++
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
hintTimer = setTimeout(() => {
|
||||
hintLabel = ''
|
||||
hintTimer = undefined
|
||||
}, HINT_LABEL_MS)
|
||||
}
|
||||
prevOthers = othersNow
|
||||
prevLoaded = loadedNow
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (hintTimer) clearTimeout(hintTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// "Saving..." / "Saved" beat any hint; otherwise the hint shows. Empty
|
||||
// string collapses the label `<span>`.
|
||||
const label = $derived(
|
||||
syncState === 'saving' || syncState === 'pending' ? 'Saving...' : savedVisible ? 'Saved' : ''
|
||||
syncState === 'saving' || syncState === 'pending'
|
||||
? 'Saving...'
|
||||
: savedVisible
|
||||
? 'Saved'
|
||||
: hintLabel
|
||||
)
|
||||
|
||||
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
|
||||
@@ -96,12 +168,28 @@
|
||||
popoverOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
function openOthersDrafts() {
|
||||
popoverOpen = false
|
||||
onOpenOthersDrafts?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-primary min-w-[4.2rem]"
|
||||
class="autosave-indicator-wrap relative flex items-center gap-1.5 text-primary min-w-[4.2rem] rounded-md"
|
||||
aria-label="Autosave status"
|
||||
>
|
||||
{#if hintLabel}
|
||||
<!-- One-shot green flash behind the indicator when a load hint appears.
|
||||
Keyed on `hintFlashKey` so re-triggering the hint replays the
|
||||
animation (Svelte tears the keyed block down and remounts). -->
|
||||
{#key hintFlashKey}
|
||||
<span
|
||||
class="autosave-hint-flash absolute inset-0 rounded-md pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/key}
|
||||
{/if}
|
||||
<Popover
|
||||
bind:isOpen={popoverOpen}
|
||||
placement="bottom-end"
|
||||
@@ -109,11 +197,11 @@
|
||||
closeOnOutsideClick
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div class='rounded-md p-1.5 hover:bg-surface-hover cursor-pointer'>
|
||||
<div class="relative rounded-md p-1.5 hover:bg-surface-hover cursor-pointer">
|
||||
{#if syncState === 'saving' || syncState === 'pending'}
|
||||
<RefreshCcw size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<CloudCheck size={16} />
|
||||
<CloudCheck size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -124,6 +212,21 @@
|
||||
All changes are saved as a draft on the server. The draft is per-user — your teammates'
|
||||
editors keep their own.
|
||||
</p>
|
||||
{#if othersDraftsCount > 0}
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<p class="text-primary text-xs">
|
||||
Other users are working on this {kindLabel}.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Users }}
|
||||
on:click={openOthersDrafts}
|
||||
>
|
||||
See others' drafts
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showResetAction}
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -136,9 +239,26 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if label}
|
||||
<span class="text-secondary text-2xs">{label}</span>
|
||||
<span class="relative text-secondary text-2xs">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Light-green fade behind the whole indicator when a load hint appears.
|
||||
`forwards` keeps the end state (transparent) so the backdrop disappears
|
||||
cleanly when the keyed wrapper unmounts. */
|
||||
@keyframes autosave-hint-flash-anim {
|
||||
0% {
|
||||
background-color: rgba(34, 197, 94, 0.22);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.autosave-hint-flash {
|
||||
animation: autosave-hint-flash-anim 1.8s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
onHistoryRestore,
|
||||
onNavigate,
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts,
|
||||
onTestJob
|
||||
}: FlowBuilderProps = $props()
|
||||
|
||||
@@ -1088,6 +1091,9 @@
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,10 @@
|
||||
disableAi,
|
||||
initialTestPanelCollapsed = false,
|
||||
initialPathChosen = false,
|
||||
onResetToDeployed
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts
|
||||
}: ScriptBuilderProps = $props()
|
||||
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
@@ -1894,6 +1897,9 @@
|
||||
path={userDraftPath}
|
||||
draftOnly={(savedScript as any)?.no_deployed === true}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,10 @@
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
onSavedNewAppPath,
|
||||
onNavigate,
|
||||
onResetToDeployed
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
@@ -883,6 +886,9 @@
|
||||
{newApp}
|
||||
userDraftPath={appDraftPath}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
on:restore
|
||||
{policy}
|
||||
{fromHub}
|
||||
|
||||
@@ -109,6 +109,11 @@
|
||||
// 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>
|
||||
// See ScriptBuilderProps — same semantics for the app editor's
|
||||
// indicator.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -131,7 +136,10 @@
|
||||
onHideRightPanel,
|
||||
onHideBottomPanel,
|
||||
onNavigate = undefined,
|
||||
onResetToDeployed
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts
|
||||
}: Props = $props()
|
||||
|
||||
/** Mirror of the path the user is editing in the pen popover. Initialized
|
||||
@@ -950,6 +958,9 @@
|
||||
path={userDraftPath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -167,6 +167,11 @@ export interface AppEditorProps {
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
// See ScriptBuilderProps — same semantics for the app editor's
|
||||
// indicator. Threaded through AppEditorHeader.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
}
|
||||
|
||||
export type App = {
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
editPathFor: (forkedPath: string) => string
|
||||
onLoadFromServer: () => void | Promise<void>
|
||||
getLocalDraft: () => unknown
|
||||
/** Bindable open-flag for the OtherUsersDraftsModal. The route owns
|
||||
* the state (so the AutosaveIndicator popover button can flip it on)
|
||||
* and binds it here. */
|
||||
othersModalOpen: boolean
|
||||
/** Defaults to true; set to false to suppress both modals. */
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -42,6 +46,7 @@
|
||||
editPathFor,
|
||||
onLoadFromServer,
|
||||
getLocalDraft,
|
||||
othersModalOpen = $bindable(),
|
||||
enabled = true
|
||||
}: Props = $props()
|
||||
</script>
|
||||
@@ -61,6 +66,7 @@
|
||||
currentUserUsername={$userStore?.username}
|
||||
{otherDraftsUsers}
|
||||
{editPathFor}
|
||||
bind:isOpen={othersModalOpen}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Banner-style modal shown on editor mount when the deployed-overlay
|
||||
* response carries `other_drafts_users` — i.e. someone other than the
|
||||
* authed user (or the legacy NULL-email row) also has a saved draft at
|
||||
* this path.
|
||||
* Modal opened on demand (from the AutosaveIndicator popover or the
|
||||
* home-page DraftBadge popover) when other workspace users have a
|
||||
* draft at the same path. The owner list is part of the
|
||||
* deployed-overlay / list payload; individual drafts are fetched
|
||||
* on-demand for the "View JSON" / "Fork" actions so the response
|
||||
* stays lean when many users are working on the same item.
|
||||
*
|
||||
* The list of owners is part of the get-by-path payload (so we don't
|
||||
* fan out a second request just to populate the banner); individual
|
||||
* drafts are fetched on-demand for the "View JSON" / "Fork" actions so
|
||||
* the deploy-overlay response stays lean when many users are working
|
||||
* on the same item.
|
||||
* The parent controls visibility — bind to `isOpen`. The modal does
|
||||
* NOT auto-open on mount; that used to surprise users every time
|
||||
* they opened an item with collaborators.
|
||||
*/
|
||||
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -37,12 +37,19 @@
|
||||
* Different editors live under different roots (`/scripts/edit/`,
|
||||
* `/flows/edit/`, ...) so the route owns the URL shape. */
|
||||
editPathFor: (forkedPath: string) => string
|
||||
/** Controlled visibility — bind from the parent. */
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
let { workspace, itemKind, path, currentUserUsername, otherDraftsUsers, editPathFor }: Props =
|
||||
$props()
|
||||
|
||||
let isOpen = $state(otherDraftsUsers.length > 0)
|
||||
let {
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
currentUserUsername,
|
||||
otherDraftsUsers,
|
||||
editPathFor,
|
||||
isOpen = $bindable()
|
||||
}: Props = $props()
|
||||
let busyFor = $state<string | null>(null)
|
||||
let jsonOpen = $state(false)
|
||||
let jsonOwnerLabel = $state('')
|
||||
@@ -178,7 +185,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Continue anyway</Button>
|
||||
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
|
||||
@@ -41,6 +41,11 @@ export type FlowBuilderProps = {
|
||||
// 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>
|
||||
// See ScriptBuilderProps — same semantics for the flow editor's
|
||||
// indicator.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
// Fired whenever a test run is started from the flow editor, with the
|
||||
// preview job id. Used by whitelabel embedders to track test jobs.
|
||||
onTestJob?: (e: { jobId: string }) => void
|
||||
|
||||
@@ -97,6 +97,11 @@
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
// See ScriptBuilderProps — same semantics for the raw-app editor's
|
||||
// indicator. Threaded through RawAppEditorHeader.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -117,7 +122,10 @@
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
defaultSplitWithPreview = true,
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -1405,6 +1413,9 @@
|
||||
{onNavigate}
|
||||
{onDeploy}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
canUndo={historyManager.canUndo}
|
||||
canRedo={historyManager.canRedo}
|
||||
onUndo={handleUndo}
|
||||
|
||||
@@ -136,6 +136,11 @@
|
||||
// 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>
|
||||
// See ScriptBuilderProps — same semantics for the raw-app editor's
|
||||
// indicator.
|
||||
loadedFromDraft?: boolean
|
||||
othersDraftsCount?: number
|
||||
onOpenOthersDrafts?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -164,7 +169,10 @@
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onDeploy = undefined,
|
||||
pendingDraftPath = $bindable(undefined),
|
||||
onResetToDeployed
|
||||
onResetToDeployed,
|
||||
loadedFromDraft = false,
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts
|
||||
}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
@@ -731,6 +739,9 @@
|
||||
path={liveEditorDraftStoragePath}
|
||||
draftOnly={newApp}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -73,4 +73,13 @@ export interface ScriptBuilderProps {
|
||||
// callers (session preview, embedded SDK) that shouldn't surface the
|
||||
// action at all.
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
// Triggers the AutosaveIndicator's on-mount "Loaded from draft" hint
|
||||
// (with a one-shot green flash) the first time it flips to true.
|
||||
loadedFromDraft?: boolean
|
||||
// Non-zero when other workspace users have a draft at this path.
|
||||
// Drives both the indicator's hint label ("Others are working on
|
||||
// this script") and the popover's "See others' drafts" button.
|
||||
othersDraftsCount?: number
|
||||
// Wired by the route to flip the OtherUsersDraftsModal open.
|
||||
onOpenOthersDrafts?: () => void
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
let savedApp:
|
||||
@@ -37,6 +36,8 @@
|
||||
* false once a deployed row exists at this path. */
|
||||
let isNewApp = $state(false)
|
||||
let otherDraftsUsers = $state<OtherDraftUser[]>([])
|
||||
let loadedFromDraft = $state(false)
|
||||
let othersModalOpen = $state(false)
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
@@ -146,17 +147,7 @@
|
||||
backendApp = { ...backendApp, value: savedDraftApp } as typeof backendApp
|
||||
}
|
||||
if (backendApp.is_draft) {
|
||||
notifyDraftLoaded({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'app',
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendApp.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
UserDraft.remove('app', path)
|
||||
await loadApp({ getDraft: false })
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
loadedFromDraft = true
|
||||
}
|
||||
const backendApp_ = structuredClone(stateSnapshot(backendApp))
|
||||
savedApp = {
|
||||
@@ -237,6 +228,7 @@
|
||||
redraw++
|
||||
}}
|
||||
getLocalDraft={() => app?.value}
|
||||
bind:othersModalOpen
|
||||
/>
|
||||
|
||||
{#key redraw}
|
||||
@@ -266,6 +258,9 @@
|
||||
await loadApp({ getDraft: false })
|
||||
redraw++
|
||||
}}
|
||||
{loadedFromDraft}
|
||||
othersDraftsCount={otherDraftsUsers.length}
|
||||
onOpenOthersDrafts={() => (othersModalOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { armRestartOnFirstInteraction, notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
import { armRestartOnFirstInteraction } from '$lib/userDraftToast'
|
||||
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { type OtherDraftUser } from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
|
||||
@@ -138,6 +138,8 @@
|
||||
* `updateApp` to `createApp` so a user-typed path is used. */
|
||||
let isNewApp = $state(false)
|
||||
let otherDraftsUsers = $state<OtherDraftUser[]>([])
|
||||
let loadedFromDraft = $state(false)
|
||||
let othersModalOpen = $state(false)
|
||||
async function loadApp(opts: { getDraft?: boolean } = {}): Promise<void> {
|
||||
const getDraft = opts.getDraft ?? true
|
||||
const tok = ++loadAppToken
|
||||
@@ -211,16 +213,7 @@
|
||||
}
|
||||
isNewApp = !!backendApp.no_deployed
|
||||
if (backendApp.is_draft) {
|
||||
notifyDraftLoaded({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'raw_app',
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendApp.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
draftHandle.draft = undefined
|
||||
await loadApp({ getDraft: false })
|
||||
}
|
||||
})
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// Apply the user's saved draft. The autosave for raw apps writes a
|
||||
// flat `RawAppDraft` (`{files, runnables, data, summary, policy,
|
||||
@@ -369,6 +362,7 @@
|
||||
editPathFor={(forkedPath) => `/apps_raw/edit/${forkedPath}`}
|
||||
onLoadFromServer={() => loadApp()}
|
||||
getLocalDraft={() => draftHandle.draft}
|
||||
bind:othersModalOpen
|
||||
/>
|
||||
|
||||
<RawAppTemplatePicker bind:open={templatePicker} onStart={onTemplatePickerStart} />
|
||||
@@ -399,6 +393,9 @@
|
||||
draftHandle.draft = undefined
|
||||
await loadApp({ getDraft: false })
|
||||
}}
|
||||
{loadedFromDraft}
|
||||
othersDraftsCount={otherDraftsUsers.length}
|
||||
onOpenOthersDrafts={() => (othersModalOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { armRestartOnFirstInteraction, notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
import { armRestartOnFirstInteraction } from '$lib/userDraftToast'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
|
||||
let savedFlow: Flow | undefined = $state(undefined)
|
||||
let otherDraftsUsers = $state<OtherDraftUser[]>([])
|
||||
let loadedFromDraft = $state(false)
|
||||
let othersModalOpen = $state(false)
|
||||
// `initialPath` is the editor-displayed path. Defaults to the URL
|
||||
// path so a deployed (or existing-draft) reload mounts FlowBuilder
|
||||
// with the real path; cleared to '' inside the `new_draft` branch
|
||||
@@ -215,16 +217,7 @@
|
||||
// lands at this URL path.
|
||||
isNewFlow = !!backendFlow.no_deployed
|
||||
if (backendFlow.is_draft) {
|
||||
notifyDraftLoaded({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'flow',
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendFlow.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
flowHandle.draft = undefined
|
||||
await loadFlow({ getDraft: false })
|
||||
}
|
||||
})
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// `backendFlow` is the deployed payload; the user's saved draft
|
||||
// (if any) is attached as `.draft`. Layer the draft over the
|
||||
@@ -315,6 +308,7 @@
|
||||
editPathFor={(forkedPath) => `/flows/edit/${forkedPath}`}
|
||||
onLoadFromServer={() => loadFlow()}
|
||||
getLocalDraft={() => flowHandle.draft}
|
||||
bind:othersModalOpen
|
||||
/>
|
||||
{#if notFound}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
@@ -338,6 +332,9 @@
|
||||
flowHandle.draft = undefined
|
||||
await loadFlow({ getDraft: false })
|
||||
}}
|
||||
{loadedFromDraft}
|
||||
othersDraftsCount={otherDraftsUsers.length}
|
||||
onOpenOthersDrafts={() => (othersModalOpen = true)}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
{flowStore}
|
||||
{flowStateStore}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
@@ -67,8 +66,16 @@
|
||||
let fullyLoaded = $state(false)
|
||||
/** Other workspace users (and the legacy NULL-email row, if any) with
|
||||
* a draft on this path. Populated from the deployed-overlay response
|
||||
* on each `loadScript`; the banner opens when the list is non-empty. */
|
||||
* on each `loadScript`; the AutosaveIndicator picks up the count for
|
||||
* its on-mount "Others are working on this script" hint. */
|
||||
let otherDraftsUsers = $state<OtherDraftUser[]>([])
|
||||
/** Whether the editor mounted on a per-user draft this load — flipped
|
||||
* true once when the overlay response says so, drives the
|
||||
* AutosaveIndicator's on-mount "Loaded from draft" hint. */
|
||||
let loadedFromDraft = $state(false)
|
||||
/** Bound through DraftEditorModals; flipped on from the
|
||||
* AutosaveIndicator popover's "See others' drafts" button. */
|
||||
let othersModalOpen = $state(false)
|
||||
|
||||
// Remounts ScriptBuilder on nav: false while a reload runs, true once data is
|
||||
// ready. A synchronous `{#key}` swap instead races Monaco's init against the
|
||||
@@ -161,19 +168,7 @@
|
||||
)
|
||||
}
|
||||
if (backendScript.is_draft) {
|
||||
notifyDraftLoaded({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'script',
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendScript.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
// Drop the in-memory draft and refetch *without* the
|
||||
// draft overlay — we don't trust the eventual delete
|
||||
// to have landed, so we read deployed directly.
|
||||
scriptHandle.draft = undefined
|
||||
await loadScript({ getDraft: false })
|
||||
}
|
||||
})
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// `backendScript` is the deployed payload; the user's saved
|
||||
// draft (if any) sits in `.draft`. Layer the draft over the
|
||||
@@ -249,6 +244,7 @@
|
||||
editPathFor={(forkedPath) => `/scripts/edit/${forkedPath}`}
|
||||
onLoadFromServer={() => loadScript()}
|
||||
getLocalDraft={() => scriptHandle.draft}
|
||||
bind:othersModalOpen
|
||||
/>
|
||||
{#if scriptHandle.draft && renderEditor}
|
||||
<ScriptBuilder
|
||||
@@ -262,6 +258,9 @@
|
||||
{diffDrawer}
|
||||
{savedPrimarySchedule}
|
||||
searchParams={page.url.searchParams}
|
||||
{loadedFromDraft}
|
||||
othersDraftsCount={otherDraftsUsers.length}
|
||||
onOpenOthersDrafts={() => (othersModalOpen = true)}
|
||||
onResetToDeployed={async () => {
|
||||
scriptHandle.draft = undefined
|
||||
await loadScript({ getDraft: false })
|
||||
|
||||
Reference in New Issue
Block a user