mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
fix: build the Google sign-in button from the design system
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
This commit is contained in:
co-authored by
Claude Opus 5
parent
5beb15baa4
commit
74137ca12a
@@ -6,7 +6,7 @@
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
|
||||
import AppConnectInner from './AppConnectInner.svelte'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import GoogleSigninButton from './GoogleSigninButton.svelte'
|
||||
import IconedResourceType from './IconedResourceType.svelte'
|
||||
import { addResourceTitle } from './resourceTypeDisplay'
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
disableChatOffset = false
|
||||
}: Props = $props()
|
||||
|
||||
/** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a
|
||||
* property of the click, and a prop would go stale between two different rows. */
|
||||
let fillPath: string | undefined = $state(undefined)
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let resourceType = $state('')
|
||||
let step = $state(1)
|
||||
@@ -32,29 +36,39 @@
|
||||
let appConnectInner: AppConnectInner | undefined = $state(undefined)
|
||||
|
||||
let rtToLoad: string | undefined = $state('')
|
||||
export async function open(rt?: string) {
|
||||
/** `fill` connects into a resource that already exists, instead of creating one. */
|
||||
export async function open(rt?: string, fill?: string) {
|
||||
fillPath = fill
|
||||
rtToLoad = rt
|
||||
drawer?.openDrawer?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per opening. The reactive statement below re-runs both when `rtToLoad` changes and
|
||||
* when `appConnectInner` binds, and a second `open()` runs `next()` a second time — which
|
||||
* walks a resource type opened with one straight past the Connect button and into
|
||||
* `window.open`. A popup opened from a reactive effect rather than the click is blocked,
|
||||
* so the drawer then sits on "Finish connection in popup window" with no popup.
|
||||
*/
|
||||
let openedFor: string | undefined = undefined
|
||||
function onRtToLoadChange(rtToLoad: string | undefined) {
|
||||
if (openedFor === rtToLoad) return
|
||||
openedFor = rtToLoad
|
||||
appConnectInner?.open(rtToLoad)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let darkMode: boolean = $state(false)
|
||||
run(() => {
|
||||
appConnectInner && onRtToLoadChange(rtToLoad)
|
||||
})
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
on:close={() => {
|
||||
step = 1
|
||||
openedFor = undefined
|
||||
dispatch('close')
|
||||
}}
|
||||
size="700px"
|
||||
@@ -83,22 +97,26 @@
|
||||
on:refresh
|
||||
express={expressOAuthSetup}
|
||||
{workspace}
|
||||
{fillPath}
|
||||
/>
|
||||
{#snippet actions()}
|
||||
<div class="flex gap-1">
|
||||
{#if step > 1}
|
||||
<Button variant="default" on:click={appConnectInner?.back ?? (() => {})}>Back</Button>
|
||||
<!-- Only when the user came through the type picker: opening the drawer for one
|
||||
resource type skips step 1, so Back would land on a list they never chose from. -->
|
||||
{#if step > 1 && !rtToLoad}
|
||||
<Button variant="default" unifiedSize="md" onClick={() => appConnectInner?.back()}>
|
||||
Back
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isGoogleSignin}
|
||||
<button {disabled} onclick={appConnectInner?.next}>
|
||||
<img
|
||||
class="h-10 w-auto object-contain"
|
||||
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
|
||||
alt="Google sign-in"
|
||||
/>
|
||||
</button>
|
||||
<GoogleSigninButton {disabled} onClick={() => appConnectInner?.next()} />
|
||||
{:else}
|
||||
<Button variant="accent" {disabled} on:click={appConnectInner?.next ?? (() => {})}>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
{disabled}
|
||||
onClick={() => appConnectInner?.next()}
|
||||
>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import GoogleSigninButton from '$lib/components/GoogleSigninButton.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
let step = $state(1)
|
||||
let disabled = $state(false)
|
||||
let manual = $state(true)
|
||||
let isGoogleSignin = $state(false)
|
||||
|
||||
let appConnect: AppConnectInner | undefined = $state(undefined)
|
||||
|
||||
@@ -41,18 +43,24 @@
|
||||
<div class="flex flex-row-reverse w-full pb-2 shrink-0">
|
||||
<div class="flex gap-2">
|
||||
{#if step > 2}
|
||||
<Button variant="default" on:click={appConnect?.back ?? (() => {})}>Back</Button>
|
||||
<Button variant="default" unifiedSize="md" onClick={() => appConnect?.back()}>
|
||||
Back
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="accent" {disabled} on:click={appConnect?.next ?? (() => {})}>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
Next
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isGoogleSignin}
|
||||
<GoogleSigninButton {disabled} onClick={() => appConnect?.next()} />
|
||||
{:else}
|
||||
<Button variant="accent" unifiedSize="md" {disabled} onClick={() => appConnect?.next()}>
|
||||
{#if step == 2 && !manual}
|
||||
Connect
|
||||
{:else if step == 1}
|
||||
Next
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -64,6 +72,7 @@
|
||||
bind:resourceType
|
||||
bind:disabled
|
||||
bind:manual
|
||||
bind:isGoogleSignin
|
||||
on:error
|
||||
on:refresh
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonType } from '$lib/components/common'
|
||||
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
unifiedSize?: ButtonType.UnifiedSize
|
||||
onClick?: ((e?: Event) => void) | undefined
|
||||
}
|
||||
|
||||
let { disabled = false, unifiedSize = 'md', onClick = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- developers.google.com/identity/branding-guidelines requires Google's own branding on the
|
||||
control that starts a Google sign-in, and allows a custom button for it as long as the G
|
||||
mark is unaltered and the label is one of theirs. So this is a design-system Button rather
|
||||
than the PNG Google ships, which carried no hover, focus or disabled state and stood at a
|
||||
different height than the buttons beside it. -->
|
||||
<Button variant="default" {unifiedSize} {disabled} startIcon={{ icon: GoogleIcon }} {onClick}>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition'
|
||||
import { CheckCircle2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
/** Sized by the row, not the caller: 20px is what an integration logo needs to stay legible. */
|
||||
icon: import('svelte').Snippet
|
||||
title: import('svelte').Snippet
|
||||
detail?: import('svelte').Snippet
|
||||
action: import('svelte').Snippet
|
||||
/** Plays the confirmation flash over the action once. */
|
||||
flash?: boolean
|
||||
}
|
||||
|
||||
let { icon, title, detail, action, flash = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- One row for both lists on the setup step. Data tables and credentials are the same
|
||||
thing to the reader — something the import could not configure, with an action that
|
||||
configures it — so they get the same icon size, spacing and text block. -->
|
||||
<li class="flex items-center gap-3 rounded-md border border-border-light px-3 py-2 text-xs">
|
||||
<div class="shrink-0">{@render icon()}</div>
|
||||
<!-- No gap and no leading override: `text-xs` already carries `leading-4`, and the two
|
||||
lines are one block of text, not two stacked items. -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{@render title()}
|
||||
{@render detail?.()}
|
||||
</div>
|
||||
<!-- The confirmation flash is the one from SaveButton: the work itself happens
|
||||
elsewhere — a drawer, a wizard — so only the overlay is reused here. The button
|
||||
stays live underneath either way; being configured is a state, not a dead end. -->
|
||||
<div class="relative shrink-0 overflow-hidden rounded-md">
|
||||
{@render action()}
|
||||
{#if flash}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService, WorkspaceService } from '$lib/gen'
|
||||
import { ArrowLeft, Check, CheckCircle2, Database, Loader2, X } from 'lucide-svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { ArrowLeft, Check, Database, Loader2, X } from 'lucide-svelte'
|
||||
import { tick } from 'svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
@@ -12,6 +11,10 @@
|
||||
import { resource } from 'runed'
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
|
||||
import ImportSetupRow from '$lib/components/ImportSetupRow.svelte'
|
||||
import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte'
|
||||
import { OauthService } from '$lib/gen'
|
||||
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
|
||||
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
|
||||
import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -40,6 +43,8 @@
|
||||
migrations: ProjectMigration[]
|
||||
status: 'unconfigured' | 'running' | 'done' | 'failed'
|
||||
error?: string
|
||||
/** Plays the confirmation flash once, right after the run that configured it. */
|
||||
justSaved: boolean
|
||||
}
|
||||
|
||||
/** A resource the project shipped that needed filling in. */
|
||||
@@ -69,6 +74,24 @@
|
||||
|
||||
// The wizard needs the instance-database pool and a confirmation host; the settings
|
||||
// page owns them there, so this step owns them here.
|
||||
// Which resource types this instance has an OAuth client for. A resource whose type is
|
||||
// in here can be connected instead of hand-filled, which for an OAuth type is the
|
||||
// difference between clicking Connect and pasting a token that expires in an hour.
|
||||
// Empty when no superadmin has configured any client — then every row falls back to
|
||||
// the editor, which is the only thing that would work anyway.
|
||||
const oauthConnects = resource(
|
||||
() => workspace,
|
||||
async () => {
|
||||
try {
|
||||
return (await OauthService.listOauthConnects()).map((c) => c.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
const connectable = $derived(new Set(oauthConnects.current ?? []))
|
||||
let appConnect: AppConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
|
||||
const confirmationModal = createAsyncConfirmationModal()
|
||||
let wizardOpen = $state(false)
|
||||
@@ -122,7 +145,8 @@
|
||||
return {
|
||||
name,
|
||||
migrations: enabled.filter((m) => m.datatable_name === name),
|
||||
status: (missing.includes(name) ? 'unconfigured' : 'done') as Row['status']
|
||||
status: (missing.includes(name) ? 'unconfigured' : 'done') as Row['status'],
|
||||
justSaved: false
|
||||
}
|
||||
})
|
||||
projectResources = exportData.resources ?? []
|
||||
@@ -232,6 +256,13 @@
|
||||
for (const m of row.migrations) await applyOneMigration(workspace, slug, m)
|
||||
row.status = 'done'
|
||||
row.error = undefined
|
||||
// One-shot, cleared by name rather than by reference: `load()` rebuilds the row
|
||||
// objects, so the one holding the flag when it fires may not be this one.
|
||||
row.justSaved = true
|
||||
setTimeout(() => {
|
||||
const current = rows.find((r) => r.name === name)
|
||||
if (current) current.justSaved = false
|
||||
}, 1500)
|
||||
} catch (e: any) {
|
||||
row.status = 'failed'
|
||||
row.error = e?.body ?? e?.message ?? String(e)
|
||||
@@ -267,9 +298,11 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
|
||||
<!-- Reads as what the user gets out of it, not as what the import failed to do:
|
||||
the step is skippable, so it has to say why finishing is worth their time. -->
|
||||
<p class="mt-0.5 text-xs text-secondary">
|
||||
The project is imported. What is left is the part it could not bring with it — connections and
|
||||
credentials this workspace has to supply.
|
||||
Your project is imported. For its apps and flows to actually run, they need a place to store
|
||||
data and credentials for the services they use — the import can't supply those for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -286,31 +319,33 @@
|
||||
<!-- Named and explained: the row underneath is a table called `main`, which
|
||||
says nothing to someone meeting the concept for the first time. -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length})
|
||||
</span>
|
||||
<p class="text-xs text-tertiary">
|
||||
Where apps and flows keep the data they read and write. Point {rows.length === 1
|
||||
? 'it'
|
||||
: 'them'} at a database and the tables get created for you.
|
||||
<p class="text-xs font-normal text-secondary">
|
||||
Where apps and flows keep the data they read and write.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="flex flex-col gap-3">
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each rows as row (row.name)}
|
||||
<li class="flex items-center gap-2 rounded-md border border-border-light px-3 py-2 text-xs">
|
||||
{#if row.status === 'done'}
|
||||
<Check size={14} class="shrink-0 text-emerald-600" />
|
||||
{:else if row.status === 'running'}
|
||||
<Loader2 size={14} class="shrink-0 animate-spin text-blue-500" />
|
||||
{:else if row.status === 'failed'}
|
||||
<X size={14} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<Database size={14} class="shrink-0 text-secondary" />
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-mono text-emphasis">{row.name}</span>
|
||||
<span class="block truncate text-tertiary">
|
||||
<ImportSetupRow flash={row.justSaved}>
|
||||
{#snippet icon()}
|
||||
{#if row.status === 'done'}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else if row.status === 'running'}
|
||||
<Loader2 size={20} class="animate-spin text-blue-500" />
|
||||
{:else if row.status === 'failed'}
|
||||
<X size={20} class="text-red-500" />
|
||||
{:else}
|
||||
<Database size={20} class="text-secondary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="min-w-0 truncate font-mono text-emphasis">{row.name}</span>
|
||||
{/snippet}
|
||||
{#snippet detail()}
|
||||
<span class="truncate text-secondary">
|
||||
{#if row.status === 'done'}
|
||||
{row.migrations.length} migration{row.migrations.length === 1 ? '' : 's'} run
|
||||
{:else if row.status === 'running'}
|
||||
@@ -321,72 +356,78 @@
|
||||
not configured yet
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<!-- The wizard owns creating a data table: picking or provisioning the
|
||||
database, writing the config, and reporting the connection. This step
|
||||
only says which name it needs and runs the migrations afterwards. -->
|
||||
<Button
|
||||
variant={row.status === 'done' ? 'subtle' : 'accent'}
|
||||
unifiedSize="sm"
|
||||
disabled={working}
|
||||
onClick={() => openWizard(row.name)}
|
||||
>
|
||||
{row.status === 'done' ? 'Configured' : 'Set up'}
|
||||
</Button>
|
||||
</li>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<!-- The wizard owns creating a data table: picking or provisioning the
|
||||
database, writing the config, and reporting the connection. This step
|
||||
only says which name it needs and runs the migrations afterwards. -->
|
||||
<Button
|
||||
variant={row.status === 'done' ? 'subtle' : 'accent'}
|
||||
unifiedSize="sm"
|
||||
disabled={working}
|
||||
onClick={() => openWizard(row.name)}
|
||||
>
|
||||
{row.status === 'done' ? 'Configured' : 'Set up'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ImportSetupRow>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if blanks.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-normal text-secondary">Credentials to fill ({blanks.length})</span
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
>Credentials to fill ({blanks.length})</span
|
||||
>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each blanks as b (b.path)}
|
||||
<li
|
||||
class="flex items-center gap-2 rounded-md border border-border-light px-3 py-2 text-xs"
|
||||
>
|
||||
{#if b.done}
|
||||
<Check size={14} class="shrink-0 text-emerald-600" />
|
||||
{:else}
|
||||
<!-- The integration's own icon, not a generic key: the row is identified by
|
||||
which provider it is for. Falls back to a generic box when unknown. -->
|
||||
<span class="shrink-0">
|
||||
<IconedResourceType name={b.resourceType} silent width="14px" height="14px" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-mono text-emphasis">{b.path}</span>
|
||||
<span class="block truncate text-tertiary">
|
||||
{b.done
|
||||
? b.resourceType
|
||||
: `${b.resourceType}${
|
||||
b.missing.length > 0 ? ` · missing ${b.missing.join(', ')}` : ''
|
||||
}`}
|
||||
</span>
|
||||
</span>
|
||||
<!-- The confirmation flash is the one from SaveButton: the save itself
|
||||
happens in the resource drawer, so only the overlay is reused here.
|
||||
The button stays live either way — "Saved" is a state, not a dead end. -->
|
||||
<div class="relative overflow-hidden rounded-md">
|
||||
{@const canConnect = !b.done && connectable.has(b.resourceType)}
|
||||
<!-- Laid out like the resource type rows in the Add-a-resource drawer: the
|
||||
integration's own icon, its product name, and the raw identifier demoted
|
||||
beside it. The path only matters when two resources share a type, so it
|
||||
stops being the thing the eye lands on. -->
|
||||
<ImportSetupRow flash={b.justSaved}>
|
||||
{#snippet icon()}
|
||||
{#if b.done}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else}
|
||||
<IconedResourceType name={b.resourceType} silent width="20px" height="20px" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<div class="flex min-w-0 flex-row items-baseline gap-2">
|
||||
<span class="min-w-0 truncate text-emphasis">
|
||||
{resourceTypeDisplayName(b.resourceType)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-mono text-2xs font-normal text-hint">
|
||||
{b.path}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet detail()}
|
||||
{#if !b.done && b.missing.length > 0}
|
||||
<span class="truncate text-secondary">
|
||||
Missing {b.missing.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<!-- Connect where the instance has a client for this type: asking for an
|
||||
OAuth resource by hand means pasting an access token that dies within
|
||||
the hour, since only a token Windmill obtained itself gets refreshed. -->
|
||||
<Button
|
||||
variant={b.done ? 'subtle' : 'accent'}
|
||||
unifiedSize="sm"
|
||||
disabled={working}
|
||||
onClick={() => resourceEditor?.initEdit(b.path)}
|
||||
onClick={() =>
|
||||
canConnect
|
||||
? appConnect?.open(b.resourceType, b.path)
|
||||
: resourceEditor?.initEdit(b.path)}
|
||||
>
|
||||
{b.done ? 'Saved' : 'Fill in'}
|
||||
{b.done ? 'Saved' : canConnect ? 'Connect' : 'Fill in'}
|
||||
</Button>
|
||||
{#if b.justSaved}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
|
||||
transition:fly={{ y: -10, duration: 300 }}
|
||||
>
|
||||
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/snippet}
|
||||
</ImportSetupRow>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -465,10 +506,19 @@
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
|
||||
<!-- The destination is not the workspace the app is in until the run switches to it,
|
||||
so the editor is told which one explicitly. -->
|
||||
so the editor is told which one explicitly.
|
||||
|
||||
Saving re-reads only the resources, never `load()`: a credential cannot change which
|
||||
data tables the project ships or which ones the workspace has, and `load()` raises
|
||||
`loading`, which replaces both lists with the spinner — so every save looked like the
|
||||
whole step had reloaded. -->
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
{workspace}
|
||||
onSaved={() => void load()}
|
||||
onRestored={() => void load()}
|
||||
onSaved={() => void refreshBlanks()}
|
||||
onRestored={() => void refreshBlanks()}
|
||||
/>
|
||||
|
||||
<!-- `on:refresh` fires once the connection has been written into the stub — the same moment
|
||||
a save is — so the rows settle the same way either route was taken. -->
|
||||
<AppConnectDrawer bind:this={appConnect} {workspace} on:refresh={() => void refreshBlanks()} />
|
||||
|
||||
Reference in New Issue
Block a user