mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
1118 lines
27 KiB
Svelte
1118 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common'
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
|
|
import Toggle from '$lib/components/Toggle.svelte'
|
|
import { AppService, DraftService, type Policy } from '$lib/gen'
|
|
import { redo, undo } from '$lib/history.svelte'
|
|
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
|
import type { Item } from '$lib/utils'
|
|
import {
|
|
AlignHorizontalSpaceAround,
|
|
BellOff,
|
|
Bug,
|
|
DiffIcon,
|
|
Expand,
|
|
FileJson,
|
|
FileUp,
|
|
FormInput,
|
|
History,
|
|
Laptop2,
|
|
Save,
|
|
Smartphone,
|
|
FileClock,
|
|
Sun,
|
|
Moon,
|
|
SunMoon,
|
|
Zap,
|
|
Globe
|
|
} from 'lucide-svelte'
|
|
import { getContext, untrack } from 'svelte'
|
|
import {
|
|
cleanValueProperties,
|
|
orderedJsonStringify,
|
|
type Value,
|
|
replaceFalseWithUndefined
|
|
} from '../../../utils'
|
|
import type { App, AppEditorContext, AppViewerContext } from '../types'
|
|
import { toStatic } from '../utils'
|
|
import AppExportButton from './AppExportButton.svelte'
|
|
import AppInputs from './AppInputs.svelte'
|
|
import PreviewToggle from './PreviewToggle.svelte'
|
|
|
|
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
|
|
|
import { sendUserToast } from '$lib/toast'
|
|
import DeploymentHistory from './DeploymentHistory.svelte'
|
|
import Awareness from '$lib/components/Awareness.svelte'
|
|
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
|
|
import Dropdown from '$lib/components/DropdownV2.svelte'
|
|
import AppEditorTutorial from './AppEditorTutorial.svelte'
|
|
import AppReportsDrawer from './AppReportsDrawer.svelte'
|
|
import DebugPanel from './contextPanel/DebugPanel.svelte'
|
|
|
|
import Summary from '$lib/components/Summary.svelte'
|
|
import HideButton from './settingsPanel/HideButton.svelte'
|
|
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
|
|
|
import AppJobsDrawer from './AppJobsDrawer.svelte'
|
|
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
|
|
import type { DiffDrawerI } from '$lib/components/diff_drawer'
|
|
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
|
|
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
|
|
import { computeSecretUrl } from './appDeploy.svelte'
|
|
import { updatePolicy } from './appPolicy'
|
|
|
|
interface Props {
|
|
policy: Policy
|
|
fromHub?: boolean
|
|
diffDrawer?: DiffDrawerI | undefined
|
|
savedApp?:
|
|
| {
|
|
value: App
|
|
draft?: any
|
|
path: string
|
|
summary: string
|
|
policy: any
|
|
draft_only?: boolean
|
|
custom_path?: string
|
|
}
|
|
| undefined
|
|
version?: number | undefined
|
|
leftPanelHidden?: boolean
|
|
rightPanelHidden?: boolean
|
|
bottomPanelHidden?: boolean
|
|
newApp: boolean
|
|
newPath?: string
|
|
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
|
|
onSavedNewAppPath?: (path: string) => void
|
|
onShowRightPanel?: () => void
|
|
onShowLeftPanel?: () => void
|
|
onShowBottomPanel?: () => void
|
|
onHideRightPanel?: () => void
|
|
onHideLeftPanel?: () => void
|
|
onHideBottomPanel?: () => void
|
|
}
|
|
|
|
let {
|
|
policy = $bindable(),
|
|
fromHub = false,
|
|
diffDrawer = undefined,
|
|
savedApp = $bindable(undefined),
|
|
version = $bindable(undefined),
|
|
leftPanelHidden = false,
|
|
rightPanelHidden = false,
|
|
bottomPanelHidden = false,
|
|
newApp,
|
|
newPath = '',
|
|
unsavedConfirmationModal,
|
|
onSavedNewAppPath,
|
|
onShowLeftPanel,
|
|
onShowRightPanel,
|
|
onShowBottomPanel,
|
|
onHideLeftPanel,
|
|
onHideRightPanel,
|
|
onHideBottomPanel
|
|
}: Props = $props()
|
|
|
|
let newEditedPath = $state('')
|
|
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
|
let deployedBy: string | undefined = $state(undefined) // Author
|
|
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
|
let open: boolean = $state(false) // Is confirmation modal open
|
|
let customPathError: string = $state('')
|
|
|
|
const {
|
|
app,
|
|
summary,
|
|
breakpoint,
|
|
appPath,
|
|
jobs,
|
|
jobsById,
|
|
staticExporter,
|
|
errorByComponent,
|
|
openDebugRun,
|
|
mode,
|
|
darkMode
|
|
} = getContext<AppViewerContext>('AppViewerContext')
|
|
|
|
const { history, jobsDrawerOpen, refreshComponents } =
|
|
getContext<AppEditorContext>('AppEditorContext')
|
|
|
|
const loading = $state({
|
|
publish: false,
|
|
save: false,
|
|
saveDraft: false
|
|
})
|
|
|
|
let selectedJobId: string | undefined = $state(undefined)
|
|
|
|
let pathError: string = $state('')
|
|
let appExport: AppExportButton | undefined = $state()
|
|
|
|
let draftDrawerOpen = $state(false)
|
|
let saveDrawerOpen = $state(false)
|
|
let inputsDrawerOpen = $state(fromHub)
|
|
let historyBrowserDrawerOpen = $state(false)
|
|
let debugAppDrawerOpen = $state(false)
|
|
let lazyDrawerOpen = $state(false)
|
|
let deploymentMsg = $state('')
|
|
|
|
function closeSaveDrawer() {
|
|
saveDrawerOpen = false
|
|
}
|
|
|
|
function closeDraftDrawer() {
|
|
draftDrawerOpen = false
|
|
}
|
|
|
|
async function createApp(path: string) {
|
|
policy = await updatePolicy($app, policy)
|
|
try {
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app,
|
|
path,
|
|
summary: $summary,
|
|
policy,
|
|
deployment_message: deploymentMsg,
|
|
custom_path: customPath
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: path,
|
|
policy: policy,
|
|
custom_path: customPath
|
|
}
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
try {
|
|
localStorage.removeItem(`app-${path}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
onSavedNewAppPath?.(path)
|
|
} catch (e) {
|
|
sendUserToast('Error creating app', e)
|
|
}
|
|
}
|
|
|
|
async function handleUpdateApp(npath: string) {
|
|
// We have to make sure there is no updates when we clicked the button
|
|
await compareVersions()
|
|
|
|
if (onLatest) {
|
|
// Handle directly
|
|
await updateApp(npath)
|
|
} else {
|
|
// There is onLatest, but we need more information while deploying
|
|
// We need it to show diff
|
|
// Handle through confirmation modal
|
|
await syncWithDeployed()
|
|
if (
|
|
deployedValue &&
|
|
savedApp &&
|
|
$app &&
|
|
orderedJsonStringify(deployedValue) ===
|
|
orderedJsonStringify(
|
|
replaceFalseWithUndefined({
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy,
|
|
custom_path: customPath
|
|
})
|
|
)
|
|
) {
|
|
await updateApp(npath)
|
|
} else {
|
|
confirmCallback = async () => {
|
|
open = false
|
|
await updateApp(npath)
|
|
}
|
|
// Open confirmation modal
|
|
open = true
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncWithDeployed() {
|
|
const deployedApp = await AppService.getAppByPath({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath!,
|
|
withStarredInfo: true
|
|
})
|
|
|
|
deployedBy = deployedApp.created_by
|
|
|
|
// Strip off extra information
|
|
deployedValue = replaceFalseWithUndefined({
|
|
...deployedApp,
|
|
id: undefined,
|
|
created_at: undefined,
|
|
created_by: undefined,
|
|
versions: undefined,
|
|
extra_perms: undefined
|
|
})
|
|
}
|
|
|
|
async function updateApp(npath: string) {
|
|
policy = await updatePolicy($app, policy)
|
|
await AppService.updateApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath!,
|
|
requestBody: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: npath,
|
|
deployment_message: deploymentMsg,
|
|
// custom_path requires admin so to accept update without it, we need to send as undefined when non-admin (when undefined, it will be ignored)
|
|
// it also means that customPath needs to be set to '' instead of undefined to unset it (when admin)
|
|
custom_path:
|
|
$userStore?.is_admin || $userStore?.is_super_admin ? (customPath ?? '') : undefined
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: npath,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
const appHistory = await AppService.getAppHistoryByPath({
|
|
workspace: $workspaceStore!,
|
|
path: npath
|
|
})
|
|
version = appHistory[0]?.version
|
|
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
if ($appPath !== npath) {
|
|
try {
|
|
localStorage.removeItem(`app-${appPath}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
onSavedNewAppPath?.(npath)
|
|
}
|
|
}
|
|
|
|
async function setPublishState() {
|
|
policy = await updatePolicy($app, policy)
|
|
await AppService.updateApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath,
|
|
requestBody: { policy }
|
|
})
|
|
if (policy.execution_mode == 'anonymous') {
|
|
sendUserToast('App require no login to be accessed')
|
|
} else {
|
|
sendUserToast('App require login and read-access')
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
$secondaryMenuLeftStore.isOpen = false
|
|
$secondaryMenuRightStore.isOpen = false
|
|
|
|
saveDrawerOpen = true
|
|
return
|
|
}
|
|
|
|
async function saveInitialDraft() {
|
|
policy = await updatePolicy($app, policy)
|
|
try {
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app,
|
|
path: newEditedPath,
|
|
summary: $summary,
|
|
policy,
|
|
draft_only: true,
|
|
custom_path: customPath
|
|
}
|
|
})
|
|
await DraftService.createDraft({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: newEditedPath,
|
|
typ: 'app',
|
|
value: {
|
|
value: $app,
|
|
path: newEditedPath,
|
|
summary: $summary,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: newEditedPath,
|
|
policy,
|
|
draft_only: true,
|
|
draft: {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: newEditedPath,
|
|
policy,
|
|
custom_path: customPath
|
|
},
|
|
custom_path: customPath
|
|
}
|
|
|
|
draftDrawerOpen = false
|
|
onSavedNewAppPath?.(newEditedPath)
|
|
} catch (e) {
|
|
sendUserToast('Error saving initial draft', e)
|
|
}
|
|
draftDrawerOpen = false
|
|
}
|
|
|
|
async function saveDraft(forceSave = false) {
|
|
if (newApp) {
|
|
// initial draft
|
|
draftDrawerOpen = true
|
|
return
|
|
}
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
|
|
const current = cleanValueProperties({
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy
|
|
})
|
|
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
|
sendUserToast('No changes detected, ignoring', false, [
|
|
{
|
|
label: 'Save anyway',
|
|
callback: () => {
|
|
saveDraft(true)
|
|
}
|
|
}
|
|
])
|
|
return
|
|
}
|
|
loading.saveDraft = true
|
|
try {
|
|
policy = await updatePolicy($app, policy)
|
|
let path = $appPath
|
|
if (savedApp.draft_only) {
|
|
await AppService.deleteApp({
|
|
workspace: $workspaceStore!,
|
|
path: path
|
|
})
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: newEditedPath || path,
|
|
draft_only: true,
|
|
custom_path: customPath
|
|
}
|
|
})
|
|
}
|
|
await DraftService.createDraft({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: savedApp.draft_only ? newEditedPath || path : path,
|
|
typ: 'app',
|
|
value: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: newEditedPath || path
|
|
}
|
|
}
|
|
})
|
|
|
|
savedApp = {
|
|
...(savedApp?.draft_only
|
|
? {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: savedApp.draft_only ? newEditedPath || path : path,
|
|
policy,
|
|
draft_only: true,
|
|
custom_path: customPath
|
|
}
|
|
: savedApp),
|
|
draft: {
|
|
summary: $summary,
|
|
value: structuredClone($state.snapshot($app)),
|
|
path: newEditedPath || path,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
}
|
|
|
|
sendUserToast('Draft saved')
|
|
try {
|
|
localStorage.removeItem(`app-${path}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
loading.saveDraft = false
|
|
if (newApp || savedApp.draft_only) {
|
|
onSavedNewAppPath?.(newEditedPath || path)
|
|
}
|
|
} catch (e) {
|
|
loading.saveDraft = false
|
|
throw e
|
|
}
|
|
}
|
|
|
|
let onLatest = $state(true)
|
|
async function compareVersions() {
|
|
if (version === undefined) {
|
|
return
|
|
}
|
|
try {
|
|
const appVersion = await AppService.getAppLatestVersion({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath
|
|
})
|
|
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
|
} catch (e) {
|
|
console.error('Error comparing versions', e)
|
|
onLatest = true
|
|
}
|
|
}
|
|
|
|
let lock = false
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (lock) return
|
|
|
|
let classes = event.target?.['className']
|
|
if (
|
|
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
|
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
|
|
) {
|
|
return
|
|
}
|
|
|
|
lock = true
|
|
|
|
switch (event.key) {
|
|
case 'Z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
const napp = redo(history)
|
|
for (const key in napp) {
|
|
$app[key] = napp[key]
|
|
}
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 'z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
const napp = undo(history, $app)
|
|
for (const key in napp) {
|
|
$app[key] = napp[key]
|
|
}
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 's':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
saveDraft()
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
// case 'ArrowDown': {
|
|
// let ids = generateIds()
|
|
// let idx = ids.indexOf($selectedIdStore)
|
|
// if (idx > -1 && idx < ids.length - 1) {
|
|
// $selectedIdStore = ids[idx + 1]
|
|
// event.preventDefault()
|
|
// }
|
|
// break
|
|
// }
|
|
// case 'ArrowUp': {
|
|
// let ids = generateIds()
|
|
// let idx = ids.indexOf($selectedIdStore)
|
|
// if (idx > 0 && idx < ids.length) {
|
|
// $selectedIdStore = ids[idx - 1]
|
|
// event.preventDefault()
|
|
// }
|
|
// break
|
|
// }
|
|
}
|
|
lock = false
|
|
}
|
|
|
|
let moreItems = $derived([
|
|
{
|
|
displayName: 'Deployment history',
|
|
icon: History,
|
|
action: () => {
|
|
historyBrowserDrawerOpen = true
|
|
},
|
|
disabled: !savedApp
|
|
},
|
|
{
|
|
displayName: 'Export',
|
|
icon: FileJson,
|
|
action: () => {
|
|
appExport?.open($app)
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Public URL',
|
|
icon: Globe,
|
|
action: async () => {
|
|
const secretUrl = await AppService.getPublicSecretOfApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath
|
|
})
|
|
window.open(computeSecretUrl(secretUrl), '_blank')
|
|
}
|
|
},
|
|
// {
|
|
// displayName: 'Publish to Hub',
|
|
// icon: faGlobe,
|
|
// action: () => {
|
|
// const url = appToHubUrl(toStatic($app, $staticExporter, $summary, $hubBaseUrlStore))
|
|
// window.open(url.toString(), '_blank')
|
|
// }
|
|
// },
|
|
|
|
{
|
|
displayName: 'App inputs',
|
|
icon: FormInput,
|
|
action: () => {
|
|
inputsDrawerOpen = true
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Schedule reports',
|
|
icon: FileClock,
|
|
action: () => {
|
|
appReportingDrawerOpen = true
|
|
},
|
|
disabled: !savedApp || savedApp.draft_only
|
|
},
|
|
{
|
|
displayName: 'Diff',
|
|
icon: DiffIcon,
|
|
action: async () => {
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed()
|
|
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedApp,
|
|
draft: savedApp.draft,
|
|
current: {
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
})
|
|
},
|
|
disabled: !savedApp
|
|
},
|
|
// App debug menu
|
|
{
|
|
displayName: 'Troubleshoot panel',
|
|
icon: Bug,
|
|
action: () => {
|
|
debugAppDrawerOpen = true
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Lazy mode',
|
|
icon: Zap,
|
|
action: () => {
|
|
lazyDrawerOpen = true
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Hub export',
|
|
icon: FileUp,
|
|
action: () => {
|
|
appExport?.open(toStatic($app, $staticExporter, $summary).app)
|
|
}
|
|
}
|
|
]) as Item[]
|
|
|
|
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
|
|
|
|
export function toggleTutorial() {
|
|
appEditorTutorial?.toggleTutorial()
|
|
}
|
|
|
|
let appReportingDrawerOpen = $state(false)
|
|
|
|
export function openTroubleshootPanel() {
|
|
debugAppDrawerOpen = true
|
|
}
|
|
|
|
function setTheme(newDarkMode: boolean | undefined) {
|
|
let globalDarkMode = window.localStorage.getItem('dark-mode')
|
|
? window.localStorage.getItem('dark-mode') === 'dark'
|
|
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
if (newDarkMode === true || (newDarkMode === null && globalDarkMode)) {
|
|
document.documentElement.classList.add('dark')
|
|
} else if (newDarkMode === false) {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
$darkMode = newDarkMode ?? globalDarkMode
|
|
$app.darkMode = newDarkMode
|
|
}
|
|
|
|
let priorDarkMode = document.documentElement.classList.contains('dark')
|
|
setTheme($app?.darkMode)
|
|
|
|
let customPath = $state(savedApp?.custom_path)
|
|
|
|
$effect(() => {
|
|
if ($openDebugRun == undefined) {
|
|
$openDebugRun = (jobId: string) => {
|
|
$jobsDrawerOpen = true
|
|
selectedJobId = jobId
|
|
}
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
saveDrawerOpen && untrack(() => compareVersions())
|
|
})
|
|
let hasErrors = $derived(Object.keys($errorByComponent).length > 0)
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onKeyDown} />
|
|
|
|
{#if unsavedConfirmationModal}
|
|
{@render unsavedConfirmationModal?.({
|
|
diffDrawer,
|
|
additionalExitAction: () => {
|
|
setTheme(priorDarkMode)
|
|
},
|
|
getInitialAndModifiedValues: () => ({
|
|
savedValue: savedApp,
|
|
modifiedValue: {
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
})
|
|
})}
|
|
{/if}
|
|
<DeployOverrideConfirmationModal
|
|
{deployedBy}
|
|
{confirmCallback}
|
|
bind:open
|
|
{diffDrawer}
|
|
bind:deployedValue
|
|
currentValue={{
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
|
policy,
|
|
custom_path: customPath
|
|
}}
|
|
/>
|
|
|
|
{#if $appPath == ''}
|
|
<Drawer bind:open={draftDrawerOpen} size="800px">
|
|
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
|
{#snippet actions()}
|
|
<div>
|
|
<Button
|
|
startIcon={{ icon: Save }}
|
|
disabled={pathError != ''}
|
|
on:click={() => saveInitialDraft()}
|
|
>
|
|
Save initial draft
|
|
</Button>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<AppEditorHeaderDeployInitialDraft {summary} {appPath} bind:pathError bind:newEditedPath />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
{/if}
|
|
|
|
<AppJobsDrawer
|
|
bind:open={$jobsDrawerOpen}
|
|
jobs={$jobs}
|
|
on:clear={() => {
|
|
$jobs = []
|
|
$errorByComponent = {}
|
|
}}
|
|
on:clearErrors={() => {
|
|
$errorByComponent = {}
|
|
}}
|
|
{hasErrors}
|
|
{selectedJobId}
|
|
refreshComponents={$refreshComponents}
|
|
jobsById={$jobsById}
|
|
errorByComponent={$errorByComponent}
|
|
/>
|
|
<Drawer bind:open={saveDrawerOpen} size="800px">
|
|
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
|
{#snippet actions()}
|
|
<div class="flex flex-row gap-4">
|
|
<Button
|
|
variant="accent"
|
|
disabled={!savedApp || savedApp.draft_only}
|
|
on:click={async () => {
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed()
|
|
|
|
saveDrawerOpen = false
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedApp,
|
|
draft: savedApp.draft,
|
|
current: {
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy,
|
|
custom_path: customPath
|
|
},
|
|
button: {
|
|
text: 'Looks good, deploy',
|
|
onClick: () => {
|
|
if ($appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}}
|
|
>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
<DiffIcon size={14} />
|
|
Diff
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
variant="accent"
|
|
startIcon={{ icon: Save }}
|
|
disabled={pathError != '' || customPathError != ''}
|
|
on:click={() => {
|
|
if ($appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
</div>
|
|
{/snippet}
|
|
<AppEditorHeaderDeploy
|
|
{newPath}
|
|
{policy}
|
|
{setPublishState}
|
|
appPath={$appPath}
|
|
{onLatest}
|
|
{savedApp}
|
|
bind:summary={$summary}
|
|
bind:customPath
|
|
bind:deploymentMsg
|
|
bind:customPathError
|
|
bind:pathError
|
|
bind:newEditedPath
|
|
hideSecretUrl={false}
|
|
/>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={inputsDrawerOpen} size="600px">
|
|
<DrawerContent title="App inputs configuration" on:close={() => (inputsDrawerOpen = false)}>
|
|
<AppInputs />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={historyBrowserDrawerOpen} size="1200px">
|
|
<DrawerContent title="Deployment History" on:close={() => (historyBrowserDrawerOpen = false)}>
|
|
<DeploymentHistory on:restore appPath={$appPath} />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={debugAppDrawerOpen} size="800px">
|
|
<DrawerContent title="Troubleshoot Panel" on:close={() => (debugAppDrawerOpen = false)}>
|
|
<DebugPanel />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={lazyDrawerOpen} size="800px">
|
|
<DrawerContent title="Lazy Mode" on:close={() => (lazyDrawerOpen = false)}>
|
|
<LazyModePanel />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<AppReportsDrawer bind:open={appReportingDrawerOpen} appPath={$appPath ?? ''} />
|
|
|
|
<div
|
|
class="border-b flex flex-row justify-between py-1 gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto"
|
|
>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
<Summary bind:value={$summary} />
|
|
<div class="flex gap-2">
|
|
<UndoRedo
|
|
undoProps={{ disabled: $history?.index === 0 }}
|
|
redoProps={{ disabled: $history && $history?.index === $history.history.length - 1 }}
|
|
on:undo={() => {
|
|
const napp = undo(history, $app)
|
|
for (const key in napp) {
|
|
$app[key] = napp[key]
|
|
}
|
|
}}
|
|
on:redo={() => {
|
|
const napp = redo(history)
|
|
for (const key in napp) {
|
|
$app[key] = napp[key]
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{#if $app}
|
|
<ToggleButtonGroup
|
|
selected={$app.fullscreen ? 'true' : 'false'}
|
|
on:selected={({ detail }) => {
|
|
$app.fullscreen = detail === 'true'
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
icon={AlignHorizontalSpaceAround}
|
|
iconOnly
|
|
value={'false'}
|
|
tooltip="The max width is 1168px and the content stay centered instead of taking the full page width"
|
|
{item}
|
|
size="md"
|
|
/>
|
|
<ToggleButton
|
|
tooltip="The width is of the app if the full width of its container"
|
|
icon={Expand}
|
|
iconOnly
|
|
value={'true'}
|
|
{item}
|
|
size="md"
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
{#if $app}
|
|
<ToggleButtonGroup
|
|
on:selected={({ detail }) => {
|
|
const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined
|
|
setTheme(theme)
|
|
}}
|
|
selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
icon={SunMoon}
|
|
iconOnly
|
|
value={'auto'}
|
|
tooltip="The app mode between dark/light is automatic"
|
|
{item}
|
|
size="md"
|
|
/>
|
|
<ToggleButton
|
|
icon={Sun}
|
|
iconOnly
|
|
value={'sun'}
|
|
tooltip="Force light mode"
|
|
{item}
|
|
size="md"
|
|
/>
|
|
<ToggleButton
|
|
tooltip="Force dark mode"
|
|
icon={Moon}
|
|
iconOnly
|
|
value={'dark'}
|
|
{item}
|
|
size="md"
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
<div class="flex flex-row gap-2">
|
|
<ToggleButtonGroup bind:selected={$breakpoint}>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
tooltip="Computer View"
|
|
icon={Laptop2}
|
|
iconOnly
|
|
value={'lg'}
|
|
{item}
|
|
size="md"
|
|
/>
|
|
<ToggleButton
|
|
tooltip="Mobile View"
|
|
icon={Smartphone}
|
|
iconOnly
|
|
value={'sm'}
|
|
{item}
|
|
size="md"
|
|
/>
|
|
{#if $breakpoint === 'sm'}
|
|
<Toggle
|
|
size="xs"
|
|
options={{
|
|
right: 'Enable mobile view for smaller screens',
|
|
rightTooltip:
|
|
'Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view'
|
|
}}
|
|
textClass="text-2xs whitespace-nowrap white !w-full"
|
|
bind:checked={$app.mobileViewOnSmallerScreens}
|
|
class="flex flex-row px-2 items-center"
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if $mode !== 'preview'}
|
|
<div class="flex gap-1">
|
|
<HideButton
|
|
direction="left"
|
|
hidden={leftPanelHidden}
|
|
on:click={() => {
|
|
if (leftPanelHidden) {
|
|
onShowLeftPanel?.()
|
|
} else {
|
|
onHideLeftPanel?.()
|
|
}
|
|
}}
|
|
/>
|
|
<HideButton
|
|
hidden={bottomPanelHidden}
|
|
direction="bottom"
|
|
on:click={() => {
|
|
if (bottomPanelHidden) {
|
|
onShowBottomPanel?.()
|
|
} else {
|
|
onHideBottomPanel?.()
|
|
}
|
|
}}
|
|
/>
|
|
<HideButton
|
|
hidden={rightPanelHidden}
|
|
direction="right"
|
|
on:click={() => {
|
|
if (rightPanelHidden) {
|
|
onShowRightPanel?.()
|
|
} else {
|
|
onHideRightPanel?.()
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if $enterpriseLicense && $appPath != ''}
|
|
<Awareness />
|
|
{/if}
|
|
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
|
<Dropdown items={moreItems} />
|
|
<AppEditorTutorial bind:this={appEditorTutorial} />
|
|
|
|
<div class="hidden md:inline relative overflow-visible shrink-0">
|
|
{#if hasErrors}
|
|
<span
|
|
class="animate-ping absolute inline-flex rounded-full bg-red-600 h-2 w-2 z-50 -right-0.5 -top-0.5"
|
|
></span>
|
|
<span class=" absolute inline-flex rounded-full bg-red-600 h-2 w-2 z-50 -right-0.5 -top-0.5"
|
|
></span>
|
|
{/if}
|
|
<Button
|
|
on:click={() => {
|
|
if (selectedJobId == undefined && $jobs.length > 0) {
|
|
selectedJobId = $jobs[$jobs.length - 1]
|
|
}
|
|
$jobsDrawerOpen = true
|
|
}}
|
|
unifiedSize="md"
|
|
variant={hasErrors ? 'accent' : 'default'}
|
|
btnClasses={'relative'}
|
|
destructive={hasErrors}
|
|
startIcon={{ icon: Bug }}
|
|
>
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<div>Debug runs</div>
|
|
<div class="text-2xs {hasErrors ? '' : 'text-primary'}"
|
|
>({$jobs?.length > 99 ? '99+' : ($jobs?.length ?? 0)})</div
|
|
>
|
|
{#if hasErrors}
|
|
<Button
|
|
size="xs"
|
|
btnClasses="-my-2 !px-1 !py-0"
|
|
variant="default"
|
|
on:click={() => errorByComponent.set({})}
|
|
><BellOff size={12} />
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
<AppExportButton bind:this={appExport} />
|
|
<PreviewToggle loading={loading.save} />
|
|
<Button
|
|
variant="accent"
|
|
loading={loading.save}
|
|
startIcon={{ icon: Save }}
|
|
on:click={() => saveDraft()}
|
|
unifiedSize="md"
|
|
disabled={!newApp && !savedApp}
|
|
shortCut={{ key: 'S' }}
|
|
>
|
|
Draft
|
|
</Button>
|
|
<Button
|
|
variant="accent"
|
|
loading={loading.save}
|
|
startIcon={{ icon: Save }}
|
|
on:click={save}
|
|
unifiedSize="md"
|
|
dropdownItems={$appPath != ''
|
|
? () => [
|
|
{
|
|
label: 'Fork',
|
|
onClick: () => {
|
|
window.open(`/apps/add?template=${appPath}`)
|
|
}
|
|
}
|
|
]
|
|
: undefined}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
</div>
|
|
</div>
|