Merge branch 'main' into app-preview-scroll

This commit is contained in:
Faton Ramadani
2023-03-06 08:23:05 +01:00
committed by GitHub
127 changed files with 5957 additions and 1139 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ pub async fn eval_timeout(
let (sender, mut receiver) = oneshot::channel::<IsolateHandle>();
let base_internal_url: String = base_internal_url.to_string();
timeout(
std::time::Duration::from_millis(2000),
std::time::Duration::from_millis(3000),
tokio::task::spawn_blocking(move || {
let mut ops = vec![];
@@ -104,7 +104,7 @@ pub async fn eval_timeout(
isolate.terminate_execution();
};
Error::ExecutionErr(format!(
"The expression of evaluation `{expr2}` took too long to execute (>2000ms)"
"The expression of evaluation `{expr2}` took too long to execute (>3000ms)"
))
})??
}
+6 -42
View File
@@ -19,6 +19,7 @@
"chart.js": "^3.9.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-zoom": "^2.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^2.29.3",
"fast-equals": "^4.0.3",
"highlight.js": "^11.7.0",
@@ -46,7 +47,6 @@
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.48.0",
"@windmill-labs/svelte-grid": "^5.1.6",
"@windmill-labs/svelvet": "^4.0.20",
"@zerodevx/svelte-toast": "^0.8.1",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",
@@ -1323,15 +1323,6 @@
"integrity": "sha512-wXHIG7XeMxwxnfaHajhjvz9c0u5uGgiQtTk/QxAVyViGdshsclfIfIWZF113QQBKCKp2jY/zP1cj64BrqioRoQ==",
"dev": true
},
"node_modules/@windmill-labs/svelvet": {
"version": "4.0.20",
"resolved": "https://registry.npmjs.org/@windmill-labs/svelvet/-/svelvet-4.0.20.tgz",
"integrity": "sha512-pbYoqYgLPNlNaLYiBQE5ealVLEXBrlAmzlSIeQYGnXIAEYpfkBxNyYmZo7Gek7ankn9WIHB0W4HHZKeCIo2v3A==",
"dev": true,
"dependencies": {
"d3-zoom": "^3.0.0"
}
},
"node_modules/@zeit/schemas": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.21.0.tgz",
@@ -2335,7 +2326,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
"engines": {
"node": ">=12"
}
@@ -2356,7 +2346,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"dev": true,
"engines": {
"node": ">=12"
}
@@ -2365,7 +2354,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dev": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
@@ -2378,7 +2366,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"dev": true,
"engines": {
"node": ">=12"
}
@@ -2387,7 +2374,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"dependencies": {
"d3-color": "1 - 3"
},
@@ -2399,7 +2385,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"engines": {
"node": ">=12"
}
@@ -2408,7 +2393,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true,
"engines": {
"node": ">=12"
}
@@ -2417,7 +2401,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dev": true,
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
@@ -2436,7 +2419,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dev": true,
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
@@ -8230,15 +8212,6 @@
"integrity": "sha512-wXHIG7XeMxwxnfaHajhjvz9c0u5uGgiQtTk/QxAVyViGdshsclfIfIWZF113QQBKCKp2jY/zP1cj64BrqioRoQ==",
"dev": true
},
"@windmill-labs/svelvet": {
"version": "4.0.20",
"resolved": "https://registry.npmjs.org/@windmill-labs/svelvet/-/svelvet-4.0.20.tgz",
"integrity": "sha512-pbYoqYgLPNlNaLYiBQE5ealVLEXBrlAmzlSIeQYGnXIAEYpfkBxNyYmZo7Gek7ankn9WIHB0W4HHZKeCIo2v3A==",
"dev": true,
"requires": {
"d3-zoom": "^3.0.0"
}
},
"@zeit/schemas": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.21.0.tgz",
@@ -8956,8 +8929,7 @@
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"d3-dag": {
"version": "0.11.5",
@@ -8974,14 +8946,12 @@
"d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"dev": true
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="
},
"d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dev": true,
"requires": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
@@ -8990,14 +8960,12 @@
"d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"dev": true
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"requires": {
"d3-color": "1 - 3"
}
@@ -9005,20 +8973,17 @@
"d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="
},
"d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
},
"d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dev": true,
"requires": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
@@ -9031,7 +8996,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dev": true,
"requires": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
+1 -1
View File
@@ -26,7 +26,6 @@
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.48.0",
"@windmill-labs/svelte-grid": "^5.1.6",
"@windmill-labs/svelvet": "^4.0.20",
"@zerodevx/svelte-toast": "^0.8.1",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",
@@ -72,6 +71,7 @@
"chart.js": "^3.9.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-zoom": "^2.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^2.29.3",
"fast-equals": "^4.0.3",
"highlight.js": "^11.7.0",
-1
View File
@@ -56,6 +56,5 @@
/* Flow graph viewer -> Svelvet library internal class overwrite */
.Node {
display: flex !important;
cursor: pointer !important;
}
}
@@ -110,7 +110,7 @@
{#if result != undefined}
{#if resultKind && resultKind != 'json'}
<div class="mb-2 text-gray-500 text-sm bg-gray-50/20">
as JSON&nbsp;<input type="checkbox" bind:checked={forceJson} /></div
as JSON&nbsp;<input class="windmillapp" type="checkbox" bind:checked={forceJson} /></div
>{/if}{#if typeof result == 'object' && Object.keys(result).length > 0}<div
class="mb-2 min-w-[400px] text-sm text-gray-700 relative"
>The result keys are: <b>{truncate(Object.keys(result).join(', '), 50)}</b>
+1 -24
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { FlowService, ScheduleService, type Flow } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { encodeState, formatCron, loadHubScripts, sendUserToast } from '$lib/utils'
@@ -8,11 +7,10 @@
import { setContext } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button, Drawer, DrawerContent } from './common'
import { Button } from './common'
import { dirtyStore } from './common/confirmationModal/dirtyStore'
import UnsavedConfirmationModal from './common/confirmationModal/UnsavedConfirmationModal.svelte'
import { OFFSET } from './CronInput.svelte'
import FlowGraphViewer from './FlowGraphViewer.svelte'
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
import FlowEditor from './flows/FlowEditor.svelte'
import { flowStateStore } from './flows/flowState'
@@ -154,7 +152,6 @@
setContext<FlowEditorContext>('FlowEditorContext', {
selectedId: selectedIdStore,
schedule: scheduleStore,
select,
previewArgs: previewArgsStore,
scriptEditorDrawer
})
@@ -178,22 +175,12 @@
$: initialPath && $workspaceStore && loadSchedule()
loadHubScripts()
let flowViewer: Drawer
</script>
{#if !$userStore?.operator}
<ScriptEditorDrawer bind:this={$scriptEditorDrawer} />
<UnsavedConfirmationModal />
<Drawer bind:this={flowViewer} size="75%">
<DrawerContent title="View Graph" on:close={flowViewer.closeDrawer} noPadding>
<div class="overflow-hidden h-full w-full">
<FlowGraphViewer flow={$flowStore} />
</div>
</DrawerContent>
</Drawer>
<div class="flex flex-col flex-1 h-screen">
<!-- Nav between steps-->
<div
@@ -201,16 +188,6 @@
>
<div class="flex flex-row">
<FlowImportExportMenu />
<Button
btnClasses="inline-flex"
startIcon={{ icon: faEye }}
variant="border"
color="light"
size="sm"
on:click={flowViewer.openDrawer}
>
Graph
</Button>
</div>
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
{#if $scheduleStore.enabled}
@@ -79,14 +79,13 @@
</Drawer>
<div class="grid grid-cols-3 w-full">
<div
bind:clientHeight={topHeight}
class="{noSide
? 'col-span-3'
: 'sm:col-span-2 col-span-3'} w-full border border-gray-400 h-screen"
: 'sm:col-span-2 col-span-3'} w-full border border-gray-400 max-h-screen"
class:overflow-auto={overflowAuto}
>
<FlowGraph
minHeight={topHeight}
minHeight={400}
modules={flow?.value?.modules}
failureModule={flow?.value?.failure_module}
on:click={(e) => (stepDetail = e.detail)}
@@ -452,13 +452,17 @@
<FlowGraph
success={isSuccess(job?.['success'])}
flowModuleStates={localFlowModuleStates}
on:click={(e) => {
if (e.detail.id) {
on:select={(e) => {
if (typeof e.detail == 'string') {
if (e.detail == 'Input') {
selectedNode = 'start'
} else if (e.detail == 'Result') {
selectedNode = 'end'
} else {
selectedNode = e.detail
}
} else {
selectedNode = e.detail.id
} else if (e.detail == 'Result') {
selectedNode = 'end'
} else if (e.detail == 'Input') {
selectedNode = 'start'
}
}}
modules={job.raw_flow?.modules ?? []}
@@ -206,9 +206,7 @@
value="javascript"
startIcon={{ icon: faCode }}
size="xs"
>
Dynamic (JS)
</ToggleButton>
/>
</ToggleButtonGroup>
<Button
variant="contained"
@@ -299,6 +297,4 @@
Not recognized input type {argName}
{/if}
</div>
{:else}
<p class="text-sm text-gray-700">Argument at {argName} is undefined</p>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { VariableService, type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue } from '$lib/utils'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { Button } from './common'
import InputTransformForm from './InputTransformForm.svelte'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
export let schema: Schema
export let args: Record<string, InputTransform | any> = {}
export let isValid: boolean = true
export let extraLib: string = 'missing extraLib'
export let previousModuleId: string | undefined = undefined
export let filter: string[] | undefined = undefined
export let noDynamicToggle = false
let clazz: string = ''
export { clazz as class }
let inputCheck: { [id: string]: boolean } = {}
$: isValid = allTrue(inputCheck) ?? false
$: if (args == undefined || typeof args !== 'object') {
args = {}
}
function removeExtraKey() {
const nargs = {}
Object.keys(args ?? {}).forEach((key) => {
if (keys.includes(key)) {
nargs[key] = args[key]
}
})
args = nargs
}
let pickForField: string | undefined
let itemPicker: ItemPicker | undefined = undefined
let variableEditor: VariableEditor | undefined = undefined
let keys: string[] = []
$: {
let lkeys = Object.keys(schema?.properties ?? {})
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
keys = lkeys
removeExtraKey()
}
}
</script>
<div class="w-full {clazz}">
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{#if (!filter || filter.includes(argName)) && Object.keys(schema.properties ?? {}).includes(argName)}
<div>
<InputTransformForm
{previousModuleId}
bind:arg={args[argName]}
bind:schema
bind:argName
bind:inputCheck={inputCheck[argName]}
bind:extraLib
{variableEditor}
{itemPicker}
bind:pickForField
{noDynamicToggle}
/>
</div>
{/if}
{/each}
{:else}
<div class="text-gray-500 text-sm">No inputs</div>
{/if}
</div>
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, _) => {
if (pickForField) {
args[pickForField].value = '$var:' + path
}
}}
itemName="Variable"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({
name: x.path,
...x
}))}
>
<div
slot="submission"
class="flex flex-row-reverse w-full bg-white border-t border-gray-200 rounded-bl-lg rounded-br-lg"
>
<Button
variant="border"
color="blue"
size="sm"
startIcon={{ icon: faPlus }}
on:click={() => {
variableEditor?.initNew?.()
}}
>
New variable
</Button>
</div>
</ItemPicker>
<VariableEditor bind:this={variableEditor} />
+1 -1
View File
@@ -42,7 +42,7 @@
<button on:click={logViewer.openDrawer}>Expand</button>
<div class="py-2 pr-2 text-xs flex gap-2 items-center">
Auto scroll
<input type="checkbox" bind:checked={scroll} />
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
</div>
</div>
</div>
+59 -55
View File
@@ -6,11 +6,9 @@
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import ArgInput from './ArgInput.svelte'
import { Button } from './common'
import InputTransformForm from './InputTransformForm.svelte'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
export let inputTransform = false
export let schema: Schema
export let args: Record<string, InputTransform | any> = {}
export let disabledArgs: string[] = []
@@ -18,16 +16,12 @@
export let editableSchema = false
export let isValid: boolean = true
export let extraLib: string = 'missing extraLib'
export let autofocus = false
export let previousModuleId: string | undefined = undefined
export let shouldHideNoInputs: boolean = false
export let compact = false
export let password: string | undefined = undefined
export let noVariablePicker = false
export let filter: string[] | undefined = undefined
export let noDynamicToggle = false
export let flexWrap = false
export let noDelete = false
@@ -42,17 +36,19 @@
}
function removeExtraKey() {
const nargs = {}
Object.keys(args ?? {}).forEach((key) => {
if (!keys.includes(key)) {
delete args[key]
delete inputCheck[key]
if (keys.includes(key)) {
nargs[key] = args[key]
}
})
args = nargs
}
let pickForField: string | undefined
let itemPicker: ItemPicker | undefined = undefined
let variableEditor: VariableEditor | undefined = undefined
let keys: string[] = []
$: {
let lkeys = Object.keys(schema?.properties ?? {})
@@ -68,48 +64,60 @@
<div class="w-full {clazz} {flexWrap ? 'flex flex-row flex-wrap gap-x-6 gap-y-2' : ''}">
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{#if (!filter || filter.includes(argName)) && Object.keys(schema.properties ?? {}).includes(argName)}
{#if Object.keys(schema.properties ?? {}).includes(argName)}
<div>
{#if inputTransform}
<InputTransformForm
{previousModuleId}
bind:arg={args[argName]}
bind:schema
bind:argName
bind:inputCheck={inputCheck[argName]}
bind:extraLib
{variableEditor}
{itemPicker}
bind:pickForField
{noDynamicToggle}
/>
{:else if typeof args == 'object' && schema?.properties[argName]}
<ArgInput
autofocus={i == 0 && autofocus}
label={argName}
bind:description={schema.properties[argName].description}
bind:value={args[argName]}
type={schema.properties[argName].type}
required={schema.required.includes(argName)}
bind:pattern={schema.properties[argName].pattern}
bind:valid={inputCheck[argName]}
defaultValue={schema.properties[argName].default}
bind:enum_={schema.properties[argName].enum}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
properties={schema.properties[argName].properties}
bind:itemsType={schema.properties[argName].items}
disabled={disabledArgs.includes(argName) || disabled}
{editableSchema}
{compact}
password={argName == password}
{variableEditor}
{itemPicker}
bind:pickForField
bind:extra={schema.properties[argName]}
/>
{:else}
Expected argument to be an object, got {JSON.stringify(args)} instead
{#if typeof args == 'object' && schema?.properties[argName]}
{#if editableSchema}
<ArgInput
autofocus={i == 0 && autofocus}
label={argName}
bind:description={schema.properties[argName].description}
bind:value={args[argName]}
type={schema.properties[argName].type}
required={schema.required.includes(argName)}
bind:pattern={schema.properties[argName].pattern}
bind:valid={inputCheck[argName]}
defaultValue={schema.properties[argName].default}
bind:enum_={schema.properties[argName].enum}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
properties={schema.properties[argName].properties}
bind:itemsType={schema.properties[argName].items}
disabled={disabledArgs.includes(argName) || disabled}
{editableSchema}
{compact}
password={argName == password}
{variableEditor}
{itemPicker}
bind:pickForField
bind:extra={schema.properties[argName]}
/>
{:else}
<ArgInput
autofocus={i == 0 && autofocus}
label={argName}
description={schema.properties[argName].description}
bind:value={args[argName]}
type={schema.properties[argName].type}
required={schema.required.includes(argName)}
pattern={schema.properties[argName].pattern}
bind:valid={inputCheck[argName]}
defaultValue={schema.properties[argName].default}
enum_={schema.properties[argName].enum}
format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
properties={schema.properties[argName].properties}
itemsType={schema.properties[argName].items}
disabled={disabledArgs.includes(argName) || disabled}
{editableSchema}
{compact}
password={argName == password}
{variableEditor}
{itemPicker}
bind:pickForField
extra={schema.properties[argName]}
/>
{/if}
{/if}
</div>
{/if}
@@ -124,11 +132,7 @@
bind:this={itemPicker}
pickCallback={(path, _) => {
if (pickForField) {
if (inputTransform) {
args[pickForField].value = '$var:' + path
} else {
args[pickForField] = '$var:' + path
}
args[pickForField] = '$var:' + path
}
}}
itemName="Variable"
@@ -242,16 +242,28 @@
<td class="p-2 " on:click={() => toggleRow(row, rowIndex)}>
<div class="center-center h-full w-full flex-wrap gap-1">
{#each actionButtons as actionButton, actionIndex (actionIndex)}
<AppButton
noWFull
{...actionButton}
preclickAction={async () => {
toggleRow(row, rowIndex)
}}
extraQueryParams={{ row: row.original }}
bind:componentInput={actionButton.componentInput}
bind:staticOutputs={$staticOutputsStore[actionButton.id]}
/>
{#if rowIndex == 0}
<AppButton
noWFull
{...actionButton}
preclickAction={async () => {
toggleRow(row, rowIndex)
}}
extraQueryParams={{ row: row.original }}
bind:componentInput={actionButton.componentInput}
bind:staticOutputs={$staticOutputsStore[actionButton.id]}
/>
{:else}
<AppButton
noWFull
{...actionButton}
preclickAction={async () => {
toggleRow(row, rowIndex)
}}
extraQueryParams={{ row: row.original }}
bind:componentInput={actionButton.componentInput}
/>
{/if}
{/each}
</div>
</td>
@@ -23,12 +23,26 @@
$: state = $worldStore?.state
$: lastInput && $worldStore && handleConnection()
$: lastInput && lastInput.type == 'template' && $state && (value = getValue(lastInput))
$: lastInput && lastInput.type == 'eval' && $state && (value = evalExpr(lastInput))
let timeout: NodeJS.Timeout | undefined = undefined
const debounce_ms = 50
function debounce(cb: () => void) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(cb, debounce_ms)
}
$: lastInput && $worldStore && debounce(handleConnection)
$: lastInput &&
lastInput.type == 'template' &&
$state &&
debounce(() => (value = getValue(lastInput)))
$: lastInput &&
lastInput.type == 'eval' &&
$state &&
debounce(() => (value = evalExpr(lastInput)))
function handleConnection() {
// console.log('handle connection', id, lastInput)
if (lastInput.type === 'connected') {
$worldStore?.connect<any>(lastInput, onValueChange)
} else if (lastInput.type === 'static' || lastInput.type == 'template') {
@@ -108,6 +122,7 @@
}
function onValueChange(newValue: any): void {
console.log('newValue', newValue, id)
if (lastInput.type === 'connected' && newValue !== undefined && newValue !== null) {
const { connection } = lastInput
@@ -61,12 +61,16 @@
<div class="app-select w-full mx-0.5" style="height: 34px" on:pointerdown|stopPropagation>
{#if !value || Array.isArray(value)}
<Select
--border-radius="0"
--border-color="#999"
multiple
on:change={(e) => e.stopPropagation()}
{items}
class={css?.input?.class ?? ''}
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={SELECT_INPUT_DEFAULT_STYLE.containerStyles + css?.input?.style}
containerStyles={'border-color: #999;' +
SELECT_INPUT_DEFAULT_STYLE.containerStyles +
css?.input?.style}
bind:value
{placeholder}
on:click={() => {
@@ -1,5 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte'
import { twMerge } from 'tailwind-merge'
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
import type { AppEditorContext, ComponentCustomCSS } from '../../types'
@@ -48,7 +49,10 @@
<AlignWrapper {verticalAlignment}>
<input
class="mx-0.5 {css?.input?.class ?? ''}"
class={twMerge(
'windmillapp w-full py-1.5 text-sm focus:ring-indigo-100 px-2 mx-0.5',
css?.input?.class ?? ''
)}
style={css?.input?.style ?? ''}
bind:this={input}
on:input={handleInput}
@@ -8,6 +8,8 @@
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InputValue from '../helpers/InputValue.svelte'
import { SELECT_INPUT_DEFAULT_STYLE } from '../../../../defaults'
import { twMerge } from 'tailwind-merge'
import Portal from 'svelte-portal'
export const staticOutputs: string[] = ['result']
export let id: string
@@ -97,16 +99,19 @@
<InputValue {id} input={configuration.create} bind:value={create} />
<AlignWrapper {horizontalAlignment} {verticalAlignment}>
<div class="app-select w-full mx-0.5" style="height: 34px" on:pointerdown|stopPropagation>
<div class="app-select w-full mx-0.5" style="height: 34px;" on:pointerdown|stopPropagation>
<Select
--border-radius="0"
--border-color="#999"
bind:filterText
on:filter={handleFilter}
on:clear={onChange}
on:change={onChange}
items={listItems}
class={css?.input?.class ?? ''}
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={SELECT_INPUT_DEFAULT_STYLE.containerStyles + css?.input?.style}
containerStyles={'border-color: #999;' +
SELECT_INPUT_DEFAULT_STYLE.containerStyles +
css?.input?.style}
{value}
{placeholder}
on:click={() => {
@@ -42,7 +42,10 @@
<AlignWrapper {verticalAlignment}>
<input
class={twMerge('mx-0.5', css?.input?.class ?? '')}
class={twMerge(
'windmillapp w-full py-1.5 text-sm focus:ring-indigo-100 px-2 mx-0.5',
css?.input?.class ?? ''
)}
style={css?.input?.style ?? ''}
on:focus={(e) => {
e?.stopPropagation()
@@ -1,5 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte'
import { twMerge } from 'tailwind-merge'
import type { AppInput } from '../../../inputType'
import type { Output } from '../../../rx'
import type { AppEditorContext, ComponentCustomCSS } from '../../../types'
@@ -54,9 +55,9 @@
{#key currency}
<CurrencyInput
inputClasses={{
formatted: 'p-0 ' + css?.input?.class,
wrapper: 'w-full',
formattedZero: 'text-black ' + css?.input?.class
formatted: twMerge('px-2 w-full py-1.5 windmillapp', css?.input?.class),
wrapper: 'w-full windmillapp',
formattedZero: twMerge('text-black windmillapp ', css?.input?.class)
}}
style={css?.input?.style}
bind:value
@@ -72,7 +72,7 @@
mounted = true
})
$: mounted && ($worldStore = buildWorld($staticOutputs, undefined, context))
$: mounted && ($worldStore = buildWorld($staticOutputs, $worldStore, context))
$: width = $breakpoint === 'sm' ? 'max-w-[640px]' : 'w-full '
$: lockedClasses = isLocked ? '!max-h-[400px] overflow-hidden pointer-events-none' : ''
</script>
@@ -94,7 +94,6 @@
{#if isLocked}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
transition:fade|local={{ duration: 200, easing: cubicOut }}
on:click={() => (isLocked = false)}
class="absolute inset-0 center-center bg-black/20 z-50 backdrop-blur-[1px] cursor-pointer"
>
@@ -64,6 +64,8 @@
// @ts-ignore
let container
// $: containerHeight = subGrid.map((item) => columnConfiguration.map((c) => item[c[1]].h))
</script>
<div
@@ -36,11 +36,8 @@ export function createNewGridItem(grid: GridItem[], id: string, data: AppCompone
appComponent.id = id
const newComponent = {
fixed: false,
resizable: true,
draggable: true,
customDragger: false,
customResizer: false,
x: 0,
y: 0
}
@@ -60,7 +57,10 @@ export function createNewGridItem(grid: GridItem[], id: string, data: AppCompone
min: { w: 1, h: 1 },
max: { w: column, h: 100 },
w: rec.w,
h: rec.h
h: rec.h,
customDragger: false,
customResizer: false,
fixed: false
}
const position = gridHelp.findSpace(newItem, grid, column) as { x: number; y: number }
newItem[column] = { ...newItem[column], ...position }
@@ -61,7 +61,7 @@
<div
class="overflow-auto min-w-[150px] border-t w-full relative flex flex-col gap-4 px-2 pt-4 pb-2"
>
<div class="relative">
<div class="relative {$connectingInput?.opened ? 'bg-white z-50' : ''}">
<input
bind:value={search}
class="px-2 py-1 border border-gray-300 rounded-sm {search ? 'pr-8' : ''}"
@@ -79,7 +79,7 @@
<InlineScriptEditorDrawer {editor} bind:this={inlineScriptEditorDrawer} bind:inlineScript />
<div class="h-full flex flex-col gap-1" transition:fly|local={{ duration: 50 }}>
<div class="h-full flex flex-col gap-1">
<div class="flex justify-between w-full gap-2 px-2 pt-1 flex-row items-center">
{#if name !== undefined}
<input bind:value={name} placeholder="Inline script name" class="!text-xs !rounded-xs" />
@@ -191,7 +191,7 @@
</Button>
</div>
{#if viewCssOptions}
<div transition:slide>
<div transition:slide|local>
{#each Object.keys(component.customCss ?? {}) as name}
{#if component?.customCss?.[name]}
<div class="w-full mb-2">
+1 -42
View File
@@ -4,13 +4,11 @@ import { inferArgs } from '$lib/infer'
import { emptySchema } from '$lib/utils'
import type { AppComponent } from './editor/component'
import type { App, ComponentCssProperty, ComponentCustomCSS, GridItem } from './types'
import { getRecommendedDimensionsByComponent } from './editor/component'
import { gridColumns } from './gridUtils'
import gridHelp from '@windmill-labs/svelte-grid/src/utils/helper'
import { twMerge } from 'tailwind-merge'
import type { AppInput, InputType, ResultAppInput, StaticAppInput } from './inputType'
import type { Output } from './rx'
import { getNextId } from '../flows/flowStateUtils'
import { createNewGridItem } from './editor/appUtils'
export function allItems(
grid: GridItem[],
@@ -283,45 +281,6 @@ export function insertNewGridItem(
return root
}
// The grid is needed to find a space for the new component
export function createNewGridItem(grid: GridItem[], id: string, data: AppComponent): GridItem {
const appComponent = data
appComponent.id = id
const newComponent = {
fixed: false,
resizable: true,
draggable: true,
customDragger: false,
customResizer: false,
x: 0,
y: 0
}
let newData: AppComponent = JSON.parse(JSON.stringify(appComponent))
const newItem: GridItem = {
data: newData,
id: id
}
gridColumns.forEach((column) => {
const rec = getRecommendedDimensionsByComponent(appComponent.type, column)
newItem[column] = {
...newComponent,
min: { w: 1, h: 1 },
max: { w: column, h: 100 },
w: rec.w,
h: rec.h
}
const position = gridHelp.findSpace(newItem, grid, column) as { x: number; y: number }
newItem[column] = { ...newItem[column], ...position }
})
return newItem
}
function recursiveGetIds(gridItem: GridItem): string[] {
const subGrids = gridItem.data.subGrids ?? []
@@ -7,10 +7,10 @@
import { onMount } from 'svelte'
export let noMinW = false
let show = false
export let show = false
let menu: HTMLDivElement
type Alignment = 'start' | 'end'
type Alignment = 'start' | 'end' | 'center'
type Side = 'top' | 'bottom'
type Placement = `${Side}-${Alignment}`
@@ -45,6 +45,7 @@
})
const placementsClasses = {
'bottom-center': 'origin-top-left left-1/2 transform -translate-x-1/2',
'bottom-start': 'origin-top-left left-0',
'bottom-end': 'origin-top-right right-0',
'top-start': 'origin-bottom-left left-0 bottom-0',
@@ -52,7 +53,7 @@
}
</script>
<div class="relative" bind:this={menu}>
<div class="relative z-10" bind:this={menu}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click|stopPropagation={() => {
@@ -5,14 +5,18 @@
import { flowStore } from './flowStore'
import WindmillIcon from '../icons/WindmillIcon.svelte'
import { Skeleton } from '../common'
import { getContext } from 'svelte'
import type { FlowEditorContext } from './types'
export let initialPath: string
export let loading: boolean
let size = 50
</script>
<div class="h-full overflow-hidden border-t">
<Splitpanes>
<Pane size={20} minSize={15} class="h-full">
<Pane {size} minSize={15} class="h-full">
<div class="grow overflow-auto bg-gray h-full bg-gray-50 relative">
{#if loading}
<div class="p-2 pt-10">
@@ -21,11 +25,11 @@
{/each}
</div>
{:else if $flowStore.value.modules}
<FlowModuleSchemaMap bind:modules={$flowStore.value.modules} root />
<FlowModuleSchemaMap bind:modules={$flowStore.value.modules} />
{/if}
</div>
</Pane>
<Pane size={80} minSize={40}>
<Pane class="drop-shadow-2xl" size={100 - size} minSize={40}>
{#if loading}
<div class="w-full h-full ">
<div class="block m-auto mt-40 w-10">
@@ -34,7 +34,7 @@
</script>
<div
class="flex items-center justify-between py-2 px-4 border-b border-gray-300 space-x-2 h-full max-h-12 flex-nowrap"
class="overflow-x-auto scrollbar-hidden flex items-center justify-between py-2 px-4 border-b border-gray-300 space-x-2 h-full max-h-12 flex-nowrap"
>
{#if flowModule}
<span class="text-sm w-full mr-4">
@@ -33,7 +33,7 @@
>
<div class="flex flex-col gap-y-4 py-2 w-full max-w-xl">
{#each value.branches as branch, i}
<div class="flex flex-row gap-x-4 w-full">
<div class="flex flex-row gap-x-4 w-full items-center">
<div class="grow">
<input type="text" bind:value={branch.summary} placeholder="Summary" />
</div>
@@ -9,6 +9,7 @@
import type { PropPickerWrapperContext } from '../propPicker/PropPickerWrapper.svelte'
import { writable } from 'svelte/store'
import Toggle from '../../Toggle.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
let hideOptional = false
@@ -75,9 +76,8 @@
<Badge large color="indigo">{m.id}</Badge>
</h2>
<SchemaForm
<InputTransformSchemaForm
noDynamicToggle
inputTransform
{filter}
class="mt-2"
schema={$flowStateStore[m.id]?.schema ?? {}}
@@ -16,7 +16,7 @@
{#if $selectedId?.startsWith('settings')}
<FlowSettings {initialPath} />
{:else if $selectedId === 'inputs'}
{:else if $selectedId === 'Input'}
<FlowInput />
{:else if $selectedId === 'constants'}
<FlowConstants />
@@ -28,6 +28,7 @@
import Alert from '$lib/components/common/alert/Alert.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowPathViewer from './FlowPathViewer.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
const { selectedId, previewArgs } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -225,9 +226,8 @@
pickableProperties={stepPropPicker.pickableProperties}
error={failureModule}
>
<SchemaForm
<InputTransformSchemaForm
schema={$flowStateStore[$selectedId]?.schema ?? {}}
inputTransform={true}
previousModuleId={previousModule?.id}
bind:args={value.input_transforms}
bind:extraLib={stepPropPicker.extraLib}
@@ -40,12 +40,7 @@
{:else if flowModule.value.type === 'identity'}
{#if $selectedId == 'failure'}
<Alert type="info" title="Error handlers are triggered upon non recovered errors">
If defined, the error handler will take as input, the result of the step that errored (which
has its error in the 'error field').
<br />
<br />
Steps are retried until they succeed, or until the maximum number of retries defined for that
spec is reached, at which point the error handler is called.
If defined, the error handler will take the error as input.
</Alert>
{/if}
@@ -10,7 +10,6 @@
import FlowSchedules from './FlowSchedules.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { Alert } from '$lib/components/common'
import { FlowGraph } from '$lib/components/graph'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import autosize from 'svelte-autosize'
@@ -25,8 +24,6 @@
export let initialPath: string
let topHeight = 0
$: url = `${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run/f/${$flowStore?.path}`
</script>
@@ -37,7 +34,6 @@
<Tab value="settings-metadata">Metadata</Tab>
<Tab value="settings-schedule">Schedule</Tab>
<Tab value="settings-same-worker">Shared Directory</Tab>
<Tab value="settings-graph">Graph</Tab>
<svelte:fragment slot="content">
<TabContent value="settings-metadata" class="p-4 h-full">
@@ -89,18 +85,22 @@
</a>
</li>
<li class="mt-2">
<div class="flex flex-row gap-2">
2. Use a trigger script and schedule this flow to run as frequently as
needed and compare a state persisted in Windmill to the state of the
external system. If a difference is detected, then the rest of the flow is
triggered. Oftentimes, the second step of a flow is a for-loop that will
iterate over every elements. When using a trigger, a default schedule will
be created.
<img
class="shadow-lg border rounded"
alt="static button"
src="/trigger_button.png"
/>
<div class="flex flex-col gap-2">
<p>
2. Use a trigger script and schedule this flow to run as frequently as
needed and compare a state persisted in Windmill to the state of the
external system. If a difference is detected, then the rest of the flow is
triggered. Oftentimes, the second step of a flow is a for-loop that will
iterate over every elements. When using a trigger, a default schedule will
be created.
</p>
<div>
<img
class="shadow-lg border rounded"
alt="static button"
src="/trigger_button.png"
/>
</div>
</div>
</li></ul
>
@@ -132,26 +132,6 @@
}}
/>
</TabContent>
<TabContent value="settings-graph">
<div
bind:clientHeight={topHeight}
class="max-w-full w-full overflow-hidden h-screen bg-gray-50"
>
{#if $flowStore.value.modules}
<FlowGraph
on:click={(e) => {
if (e.detail.id) {
$selectedId = e.detail.id
}
}}
minHeight={topHeight}
modules={$flowStore.value.modules}
failureModule={$flowStore.value.failure_module}
notSelectable
/>
{/if}
</div>
</TabContent>
</svelte:fragment>
</Tabs>
</div>
@@ -1,110 +0,0 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { getContext } from 'svelte'
import type { FlowModule } from '$lib/gen'
import FlowModuleSchemaMap from './FlowModuleSchemaMap.svelte'
import { slide } from 'svelte/transition'
import { faCodeBranch, faTimesCircle, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { Button } from '$lib/components/common'
import { classNames } from '$lib/utils'
import Icon from 'svelte-awesome'
import { deleteFlowStateById, emptyModule, idMutex } from '../flowStateUtils'
let selectedBranch = 0
export let module: FlowModule
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
async function addBranch() {
await idMutex.runExclusive(async () => {
if (module.value.type === 'branchall') {
const newModule = emptyModule()
module.value.branches.splice(module.value.branches.length, 0, {
summary: '',
skip_failure: false,
modules: []
})
module = module
}
})
}
function removeBranch(index: number) {
if (module.value.type === 'branchall') {
module.value.branches[index].modules.forEach((mod) => {
deleteFlowStateById(mod.id)
})
module.value.branches.splice(index, 1)
module = module
}
}
</script>
{#if module.value.type === 'branchall'}
<div class="flex text-xs px-2">
<div
class="w-full space-y-2 pb-2 flex flex-col border bg-gray-500 border-gray-600 bg-opacity-10 rounded-sm my-2 relative"
>
{#each module.value.branches ?? [] as branch, branchIndex (branchIndex)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div transition:slide|local>
<div
on:click={() => {
selectedBranch = branchIndex
select(`${module.id}-branch-${branchIndex}`)
}}
class={classNames(
`border-b ${
branchIndex > 0 ? 'border-t' : ''
} w-full p-2 bg-white border-gray-500 text-sm cursor-pointer flex items-center relative module`,
$selectedId === `${module.id}-branch-${branchIndex}`
? 'outline outline-2 outline-slate-900'
: ''
)}
>
<Icon data={faCodeBranch} scale={1.1} class="mr-2" />
<span
class="text-xs flex flex-row justify-between w-full flex-wrap gap-2 items-center truncate"
>
{branch.summary || `Branch ${branchIndex}`}
<button
class="absolute -top-2 -right-1 rounded-full h-4 w-4 bg-white center-center {$selectedId ===
`${module.id}-branch-${branchIndex}`
? ''
: '!hidden'} trash"
on:click={() => removeBranch(branchIndex)}
><Icon
data={faTimesCircle}
class="text-gray-600 hover:text-red-600"
scale={0.9}
/></button
>
</span>
</div>
<div>
<FlowModuleSchemaMap parentType="branchall" bind:modules={branch.modules} />
</div>
</div>
{/each}
<div class="overflow-clip ml-2 mt-2">
<Button
size="xs"
color="dark"
startIcon={{ icon: faCodeBranch }}
on:click={() => addBranch()}
>
Add branch
</Button>
</div>
</div>
</div>
{/if}
<style>
.module:hover .trash {
display: flex !important;
}
</style>
@@ -1,134 +0,0 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { getContext } from 'svelte'
import type { FlowModule } from '$lib/gen'
import FlowModuleSchemaMap from './FlowModuleSchemaMap.svelte'
import { faCodeBranch, faTimesCircle, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { Button } from '$lib/components/common'
import { classNames } from '$lib/utils'
import Icon from 'svelte-awesome'
import { deleteFlowStateById, emptyModule, idMutex } from '../flowStateUtils'
import { emptyFlowModuleState } from '../utils'
import { flowStateStore } from '../flowState'
import { slide } from 'svelte/transition'
export let module: FlowModule
let selectedBranch = 0
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
async function addBranch() {
await idMutex.runExclusive(async () => {
if (module.value.type === 'branchone') {
module.value.branches.splice(module.value.branches.length, 0, {
summary: '',
expr: 'false',
modules: []
})
module = module
}
})
}
function removeBranch(index: number) {
if (module.value.type === 'branchone') {
module.value.branches[index].modules.forEach((mod) => {
deleteFlowStateById(mod.id)
})
module.value.branches.splice(index, 1)
module = module
}
}
</script>
{#if module.value.type === 'branchone'}
<div class="flex text-xs px-2">
<div
class="w-full space-y-2 pb-2 flex flex-col border bg-gray-500 border-gray-600 bg-opacity-10 rounded-sm my-2 relative"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click={() => {
selectedBranch = 0
select(`${module.id}-branch-default`)
}}
class={classNames(
`border-b w-full p-2 bg-white border-gray-500 text-sm cursor-pointer flex items-center relative module`,
$selectedId === `${module.id}-branch-default`
? 'outline outline-2 outline-slate-900'
: ''
)}
>
<Icon data={faCodeBranch} scale={1.1} class="mr-2" />
<span
class="truncate text-xs flex flex-row justify-between w-full flex-wrap gap-2 items-center"
>
Default branch
</span>
</div>
<div>
<FlowModuleSchemaMap parentType="branchone" bind:modules={module.value.default} />
</div>
{#each module.value.branches ?? [] as branch, branchIndex (branchIndex)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div transition:slide|local>
<div
on:click={() => {
selectedBranch = branchIndex + 1
select(`${module.id}-branch-${branchIndex}`)
}}
class={classNames(
`border-b border-t w-full p-2 bg-white border-gray-500 text-sm cursor-pointer flex items-center relative module`,
$selectedId === `${module.id}-branch-${branchIndex}`
? 'outline outline-2 outline-slate-900'
: ''
)}
>
<Icon data={faCodeBranch} scale={1.1} class="mr-2" />
<span
class="text-xs flex flex-row justify-between w-full flex-wrap gap-2 items-center truncate"
>
{branch.summary || `Branch ${branchIndex}`}
<button
class="absolute -top-2 -right-2 rounded-full bg-white h-4 w-4 center-center trash {$selectedId ===
`${module.id}-branch-${branchIndex}`
? ''
: '!hidden'}"
on:click={() => removeBranch(branchIndex)}
><Icon
data={faTimesCircle}
class="text-gray-600 hover:text-red-600"
scale={0.9}
/></button
>
</span>
</div>
<div>
<FlowModuleSchemaMap parentType="branchone" bind:modules={branch.modules} />
</div>
</div>
{/each}
<div class="overflow-clip ml-2">
<Button
btnClasses=""
size="xs"
color="dark"
startIcon={{ icon: faCodeBranch }}
on:click={() => addBranch()}
>
Add branch
</Button>
</div>
</div>
</div>
{/if}
<style>
.module:hover .trash {
display: flex !important;
}
</style>
@@ -4,7 +4,7 @@
import { classNames } from '$lib/utils'
import { DollarSign } from 'lucide-svelte'
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
$: settingsClass = classNames(
'border w-full rounded-sm p-2 bg-white border-gray-400 text-sm cursor-pointer flex items-center',
@@ -12,7 +12,7 @@
)
</script>
<button on:click={() => select('constants')} class={settingsClass}>
<button on:click={() => ($selectedId = 'constants')} class={settingsClass}>
<DollarSign size={16} />
<span class="text-xs flex flex-row justify-between w-full gap-2 items-center truncate ml-1">
All Static Inputs
@@ -9,13 +9,13 @@
import { flowStore } from '../flowStore'
import { NEVER_TESTED_THIS_FAR } from '../utils'
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
function onToggle() {
if ($flowStore?.value?.failure_module) {
$flowStore.value.failure_module = undefined
// By default, we return to settings when disabling the failure module
select('settings-metadata')
$selectedId = 'settings-metadata'
} else {
const failureModule: FlowModuleState = {
schema: emptySchema(),
@@ -26,7 +26,7 @@
id: 'failure',
value: { type: 'identity' }
}
select('failure')
$selectedId = 'failure'
}
}
</script>
@@ -35,34 +35,35 @@
<div
on:click={() => {
if ($flowStore?.value?.failure_module) {
select('failure')
$selectedId = 'failure'
} else {
onToggle()
}
}}
class={classNames(
'border rounded-sm px-2 py-1 bg-white text-sm border-gray-400 cursor-pointer flex flex-col overflow-x-hidden ',
'border mx-auto rounded-sm px-2 py-1 bg-white text-sm border-gray-400 cursor-pointer flex justify-between items-center flex-row overflow-x-hidden ',
$selectedId?.includes('failure') ? 'outline outline-offset-1 outline-2 outline-slate-900' : ''
)}
style="min-width: 275px"
>
<div class=" flex justify-between items-center flex-wrap">
<div>
<Icon data={faBug} class="mr-2" />
<span class="font-bold text-xs">Error handler</span>
</div>
<div class="-my-1">
<Toggle checked={Boolean($flowStore?.value?.failure_module)} on:change={onToggle} />
</div>
</div>
<div class="w-full truncate block">
<div class=" items-center truncate flex text-xs">
{#if Boolean($flowStore?.value?.failure_module)}
<span>
{$flowStore.value.failure_module?.summary ||
($flowStore.value.failure_module?.value.type === 'rawscript'
? `Inline ${$flowStore.value.failure_module?.value.language}`
: 'Select a script')}
? `${$flowStore.value.failure_module?.value.language}`
: 'TBD')}
</span>
{/if}
</div>
<div class="-my-1">
<Toggle checked={Boolean($flowStore?.value?.failure_module)} on:change={onToggle} />
</div>
</div>
@@ -5,12 +5,11 @@
import Icon from 'svelte-awesome'
import { faFlagCheckered } from '@fortawesome/free-solid-svg-icons'
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
</script>
<FlowModuleSchemaItem
on:click={() => select('inputs')}
hasLine
on:click={() => ($selectedId = 'inputs')}
selected={$selectedId === 'inputs'}
bold
label="Flow Input"
@@ -6,9 +6,6 @@
import { fade } from 'svelte/transition'
import { Bed, PhoneIncoming, Repeat, Square, X } from 'lucide-svelte'
export let isFirst: boolean = false
export let isLast: boolean = false
export let hasLine: boolean = true
export let selected: boolean = false
export let deletable: boolean = false
export let retry: boolean = false
@@ -18,107 +15,98 @@
export let bold: boolean = false
export let id: string | undefined = undefined
export let label: string
export let modType: string | undefined = undefined
export let bgColor: string = ''
const margin = isLast ? '' : isFirst ? 'mb-0.5' : 'my-0.5'
const dispatch = createEventDispatcher()
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex relative" on:click>
<div
class={classNames(
'w-full module flex rounded-sm cursor-pointer',
selected ? 'outline outline-offset-1 outline-2 outline-gray-600' : '',
'flex relative'
)}
style="width: 275px; height: 34px; background-color: {bgColor};"
on:click
>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-20">
{#if retry}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center rounded border bg-white border-gray-400 text-gray-700 px-1 py-0.5"
>
<Repeat size={14} />
</div>
<svelte:fragment slot="text">Retries</svelte:fragment>
</Popover>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Square size={14} />
</div>
<svelte:fragment slot="text">Early stop/break</svelte:fragment>
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<PhoneIncoming size={14} />
</div>
<svelte:fragment slot="text">Suspend</svelte:fragment>
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Bed size={14} />
</div>
<svelte:fragment slot="text">Sleep</svelte:fragment>
</Popover>
{/if}
</div>
<div
class={classNames(
'flex pl-6 ml-0.5',
hasLine ? 'line' : '',
isFirst ? 'justify-center items-start' : 'justify-center items-center'
)}
/>
<div
class={classNames(
'w-full flex overflow-hidden rounded-sm cursor-pointer mr-2',
selected ? 'outline outline-offset-1 outline-2 outline-gray-600' : '',
margin
)}
class="flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
border border-gray-400 p-2 text-2xs module"
>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-20">
{#if retry}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Repeat size={14} />
</div>
<svelte:fragment slot="text">Retries</svelte:fragment>
</Popover>
{#if $$slots.icon}
<slot name="icon" />
<span class="mr-2" />
{/if}
<div class="flex-1 truncate" class:font-bold={bold}>{label}</div>
<div class="flex items-center space-x-2">
{#if id}
<Badge color="indigo">{id}</Badge>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Square size={14} />
</div>
<svelte:fragment slot="text">Early stop/break</svelte:fragment>
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<PhoneIncoming size={14} />
</div>
<svelte:fragment slot="text">Suspend</svelte:fragment>
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Bed size={14} />
</div>
<svelte:fragment slot="text">Sleep</svelte:fragment>
</Popover>
{/if}
</div>
<div
class="flex justify-between items-center w-full overflow-hidden rounded-sm
border border-gray-400 p-2 bg-white text-2xs module"
>
{#if $$slots.icon}
<slot name="icon" />
<span class="mr-2" />
{/if}
<div class="flex-1 truncate" class:font-bold={bold}>{label}</div>
<div class="flex items-center space-x-2">
{#if id}
<Badge color="indigo">{id}</Badge>
{/if}
{#if deletable}
<button
class="absolute -top-2 right-0 rounded-full h-4 w-4 trash center-center
border-[1.5px] border-gray-700 bg-white duration-150 hover:bg-red-400 hover:text-white
hover:border-red-700 {selected ? '' : '!hidden'}"
on:click|preventDefault|stopPropagation={(event) => dispatch('delete', event)}
>
<X size={12} strokeWidth={2} />
</button>
{/if}
</div>
</div>
</div>
{#if deletable}
<button
class="absolute -top-2 -right-2 rounded-full h-4 w-4 trash center-center
border-[1.5px] border-gray-700 bg-white duration-150 hover:bg-red-400 hover:text-white
hover:border-red-700 {selected ? '' : '!hidden'}"
on:click|preventDefault|stopPropagation={(event) =>
dispatch('delete', { event, id, type: modType })}
>
<X size={12} strokeWidth={2} />
</button>
{/if}
</div>
<style>
.module:hover .trash {
display: flex !important;
}
.line {
background: repeating-linear-gradient(to bottom, transparent 0 4px, rgb(120, 120, 120) 4px 8px)
50%/1px 100% no-repeat;
}
</style>
@@ -6,167 +6,220 @@
createBranches,
createLoop,
deleteFlowStateById,
emptyModule,
idMutex
emptyModule
} from '$lib/components/flows/flowStateUtils'
import { flowStateStore, type FlowModuleState } from '../flowState'
import { flowStateStore } from '../flowState'
import type { FlowModule } from '$lib/gen'
import FlowErrorHandlerItem from './FlowErrorHandlerItem.svelte'
import RemoveStepConfirmationModal from '../content/RemoveStepConfirmationModal.svelte'
import { emptyFlowModuleState } from '../utils'
import MapItem from './MapItem.svelte'
import FlowSettingsItem from './FlowSettingsItem.svelte'
import FlowInputsItem from './FlowInputsItem.svelte'
import InsertModuleButton from './InsertModuleButton.svelte'
import { slide } from 'svelte/transition'
import FlowConstantsItem from './FlowConstantsItem.svelte'
export let root: boolean = false
export let modules: FlowModule[] | undefined
export let parentType: 'flow' | 'forloop' | 'branchall' | 'branchone' | undefined = undefined
import { dfs, flowStore } from '../flowStore'
import { FlowGraph } from '$lib/components/graph'
import FlowErrorHandlerItem from './FlowErrorHandlerItem.svelte'
let indexToRemove: number | undefined = undefined
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
export let modules: FlowModule[] | undefined
export let sidebarSize: number | undefined = undefined
let idToRemove: string | undefined = undefined
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
async function insertNewModuleAtIndex(
modules: FlowModule[],
index: number,
kind: 'script' | 'forloop' | 'branchone' | 'branchall' | 'flow' | 'trigger' | 'approval' | 'end'
): Promise<void> {
await idMutex.runExclusive(async () => {
var module = emptyModule(kind == 'flow')
var state = emptyFlowModuleState()
if (kind == 'forloop') {
;[module, state] = await createLoop(module.id)
} else if (kind == 'branchone') {
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
): Promise<FlowModule[]> {
var module = emptyModule(kind == 'flow')
var state = emptyFlowModuleState()
if (kind == 'forloop') {
;[module, state] = await createLoop(module.id)
} else if (kind == 'branchone') {
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
}
$flowStateStore[module.id] = state
if (kind == 'trigger') {
module.summary = 'Trigger'
} else if (kind == 'approval') {
module.summary = 'Approval'
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
if (!modules) return [module]
modules.splice(index, 0, module)
return modules
}
function removeAtId(modules: FlowModule[], id: string): FlowModule[] {
const index = modules.findIndex((mod) => mod.id == id)
if (index != -1) {
const [removed] = modules.splice(index, 1)
const leaves = dfs([removed], (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId))
return modules
}
return modules.map((mod) => {
if (mod.value.type == 'forloopflow') {
mod.value.modules = removeAtId(mod.value.modules, id)
} else if (mod.value.type == 'branchall') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
} else if (mod.value.type == 'branchone') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
mod.value.default = removeAtId(mod.value.default, id)
}
const flowModule = module
if (!modules) return
modules.splice(index, 0, flowModule)
modules = modules
$flowStateStore[flowModule.id] = state
if (kind == 'trigger') {
flowModule.summary = 'Trigger'
} else if (kind == 'approval') {
flowModule.summary = 'Approval'
} else if (kind == 'end') {
flowModule.summary = 'Terminate flow'
flowModule.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
select(flowModule.id)
return mod
})
}
function removeAtIndex(index: number): void {
if (!modules) return
const [removedModule] = modules.splice(index, 1)
modules = modules
// async function insertModule(
// modules: FlowModule[],
// id: string,
// kind: string,
// where: 'before' | 'after'
// ): Promise<FlowModule[]> {
// console.log(modules, id, kind, where)
// const index = modules.findIndex((mod) => mod.id == id)
// if (index != -1) {
// console.log('INSERTING', modules, index)
// return await insertNewModuleAtIndex(modules, index + (where == 'after' ? 1 : 0), kind as any)
// }
// return await Promise.all(
// modules.map(async (mod) => {
// if (mod.value.type == 'forloopflow') {
// mod.value.modules = await insertModule(mod.value.modules, id, kind, where)
// } else if (mod.value.type == 'branchall') {
// mod.value.branches = await Promise.all(
// mod.value.branches.map(async (branch) => {
// branch.modules = await insertModule(branch.modules, id, kind, where)
// return branch
// })
// )
// } else if (mod.value.type == 'branchone') {
// mod.value.branches = await Promismod.value.branches.map((branch) => {
// branch.modules = await insertModule(branch.modules, id, kind, where)
// return branch
// })
// mod.value.default = await insertModule(mod.value.default, id, kind, where)
// }
// return mod
// })
// )
// }
const leaves = findLeaves(removedModule)
$: confirmationModalOpen = idToRemove !== undefined
leaves.forEach((leafId: string) => deleteFlowStateById(leafId))
select('settings-graph')
$: sidebarMode == 'graph' ? (sidebarSize = 40) : (sidebarSize = 20)
let sidebarMode: 'list' | 'graph' = 'graph'
let minHeight = 0
function selectNextId(id: any) {
if (modules) {
let allIds = dfs(modules, (mod) => mod.id)
if (allIds.length > 1) {
const idx = allIds.indexOf(id)
$selectedId = idx == 0 ? allIds[0] : allIds[idx - 1]
} else {
$selectedId = 'settings-metadata'
}
}
}
async function addBranch(module: FlowModule) {
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
module.value.branches.splice(module.value.branches.length, 0, {
summary: '',
expr: 'false',
modules: []
})
}
}
function findLeaves(flowModule: FlowModule): string[] {
const id = flowModule.id
if (flowModule.value.type === 'forloopflow') {
return [id, ...flowModule.value.modules.map((fm) => findLeaves(fm)).flat()]
function removeBranch(module: FlowModule, index: number) {
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
const leaves = dfs(module.value.branches[index].modules, (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId))
module.value.branches.splice(index, 1)
}
if (flowModule.value.type === 'branchall') {
return [
id,
...flowModule.value.branches
.map((branch) => branch.modules.map((mod) => findLeaves(mod)).flat())
.flat()
]
}
if (flowModule.value.type === 'branchone') {
return [
id,
...flowModule.value.branches
.map((branch) => {
return branch.modules.map((mod) => findLeaves(mod)).flat()
})
.flat(),
...flowModule.value.default.map((mod) => findLeaves(mod)).flat()
]
}
return [id]
}
$: confirmationModalOpen = indexToRemove !== undefined
</script>
<div class="flex flex-col h-full relative">
{#if root}
<div
class="z-10 sticky inline-flex flex-col gap-2 top-0 bg-gray-50 flex-initial p-2 items-center border-b border-gray-300"
>
<FlowSettingsItem />
<FlowConstantsItem />
</div>
{/if}
<ul class="w-full flex-auto {root ? 'px-2 pb-2 pt-3' : ''} py-1">
{#if root}
<li>
<FlowInputsItem />
</li>
{/if}
<div
class="z-10 sticky inline-flex flex-col gap-2 top-0 bg-gray-50 flex-initial p-2 items-center border-b border-gray-300"
>
<FlowSettingsItem />
<FlowConstantsItem />
</div>
{#if modules}
{#each modules as mod, index (mod.id ?? index)}
<div transition:slide|local>
<MapItem
{index}
bind:mod
on:delete={(event) => {
if (event.detail.detail.shiftKey || mod.value.type === 'identity') {
removeAtIndex(index)
} else {
indexToRemove = index
}
}}
on:insert={(e) => {
insertNewModuleAtIndex(index, e.detail)
}}
/>
</div>
{/each}
{/if}
{#if !modules || modules[modules?.length - 1]?.summary != 'Terminate flow'}
<InsertModuleButton
stop={!root && parentType == 'branchone'}
trigger={root && modules?.length == 0}
on:new={(e) => insertNewModuleAtIndex(modules?.length ?? 0, e.detail)}
/>
{:else}
<div class="my-2" />
{/if}
</ul>
{#if root}
<div class="sticky bottom-0 bg-gray-50 flex-none px-4 py-1 pb-2 border-t">
<FlowErrorHandlerItem />
</div>
{/if}
<div class="flex-auto grow" bind:clientHeight={minHeight}>
<FlowGraph
insertable
{minHeight}
rebuildOnChange={$flowStore}
maxHeight={minHeight}
modules={$flowStore.value?.modules}
{selectedId}
on:delete={({ detail }) => {
let e = detail.detail
if (e.event.shiftKey || e.type == 'identity') {
selectNextId(e.id)
removeAtId($flowStore.value.modules, e.id)
$flowStore = $flowStore
} else {
idToRemove = e.id
}
}}
on:insert={async ({ detail }) => {
if (detail.modules) {
await insertNewModuleAtIndex(detail.modules, detail.index ?? 0, detail.detail)
$flowStore = $flowStore
$selectedId = detail.modules[detail.index ?? 0].id
}
}}
on:newBranch={async ({ detail }) => {
if (detail.module) {
await addBranch(detail.module)
$flowStore = $flowStore
}
}}
on:deleteBranch={async ({ detail }) => {
if (detail.module) {
await removeBranch(detail.module, detail.index)
$flowStore = $flowStore
$selectedId = detail.module.id
}
}}
/>
</div>
<div
class="z-10 absolute w-full inline-flex flex-col gap-2 bottom-0 left-0 flex-initial p-2 items-center border-b"
>
<FlowErrorHandlerItem />
</div>
</div>
<RemoveStepConfirmationModal
bind:open={confirmationModalOpen}
on:canceled={() => {
indexToRemove = undefined
idToRemove = undefined
}}
on:confirmed={() => {
if (indexToRemove !== undefined) {
removeAtIndex(indexToRemove)
indexToRemove = undefined
if (idToRemove !== undefined) {
selectNextId(idToRemove)
removeAtId($flowStore.value.modules, idToRemove)
$flowStore = $flowStore
idToRemove = undefined
}
$selectedId = 'settings-graph'
}}
/>
@@ -6,7 +6,7 @@
import { flowStore } from '../flowStore'
import { SlidersHorizontal } from 'lucide-svelte'
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
$: settingsClass = classNames(
'border w-full rounded-sm p-2 bg-white border-gray-400 text-sm cursor-pointer flex items-center',
@@ -16,9 +16,11 @@
)
</script>
<button on:click={() => select('settings-metadata')} class={settingsClass}>
<button on:click={() => ($selectedId = 'settings-metadata')} class={settingsClass}>
<SlidersHorizontal size={16} />
<span class="text-xs font-bold flex flex-row justify-between w-full gap-2 items-center truncate ml-1">
<span
class="text-xs font-bold flex flex-row justify-between w-full gap-2 items-center truncate ml-1"
>
Settings
{#if $flowStore.value.same_worker}
@@ -3,29 +3,29 @@
import {
faCode,
faCodeBranch,
faPlus,
faBarsStaggered,
faBolt,
faCheck
} from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import Icon from 'svelte-awesome'
import { Repeat, Square } from 'lucide-svelte'
import { Cross, Repeat, Square } from 'lucide-svelte'
const dispatch = createEventDispatcher()
export let trigger = false
export let stop = false
export let open: boolean | undefined = undefined
</script>
<Menu noMinW placement="bottom-start" let:close>
<Menu bind:show={open} noMinW placement="bottom-center" let:close>
<button
slot="trigger"
type="button"
class="text-gray-900 bg-white border mx-0.5 border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-full text-sm w-6 h-6 flex items-center justify-center"
class=" text-gray-900 bg-white border mx-0.5 border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-full text-sm w-6 h-6 flex items-center justify-center"
>
<Icon data={faPlus} scale={0.8} />
<Cross size={12} />
</button>
<div class="divide-y divide-gray-100 text-xs w-40">
<div class="font-mono divide-y divide-gray-100 text-xs w-40">
<button
class="w-full text-left p-2 hover:bg-gray-100"
on:click={() => {
@@ -36,7 +36,7 @@
tabindex="-1"
>
<Icon data={faCode} scale={0.8} class="mr-2" />
Action (Script)
Action
</button>
{#if trigger}
<button
@@ -49,7 +49,7 @@
tabindex="-1"
>
<Icon data={faBolt} scale={0.8} class="mr-2" />
Trigger (Script)
Trigger
</button>
{/if}
<button
@@ -62,7 +62,7 @@
tabindex="-1"
>
<Icon data={faCheck} class="mr-1.5" scale={0.8} />
Approval (Script)
Approval
</button>
<button
class="w-full inline-flex text-left p-2 hover:bg-gray-100"
@@ -72,7 +72,7 @@
}}
role="menuitem"
>
<span class="mr-2">
<span class="mr-3">
<Repeat size={14} />
</span>
@@ -3,23 +3,35 @@
import { createEventDispatcher, getContext } from 'svelte'
import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte'
import type { FlowModule } from '$lib/gen'
import FlowModuleSchemaMap from './FlowModuleSchemaMap.svelte'
import InsertModuleButton from './InsertModuleButton.svelte'
import FlowBranchOneMap from './FlowBranchOneMap.svelte'
import FlowBranchAllMap from './FlowBranchAllMap.svelte'
import { faBarsStaggered, faCodeBranch, faLongArrowDown } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { Building, Repeat, Square } from 'lucide-svelte'
import { Building, GitBranchPlus, Repeat, Square } from 'lucide-svelte'
import type { Writable } from 'svelte/store'
export let mod: FlowModule
export let index: number
export let trigger: boolean
export let insertable: boolean
export let insertableEnd = false
export let annotation: string | undefined = undefined
export let branchable: boolean = false
export let bgColor: string = ''
export let modules: FlowModule[]
const { select, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
$: idx = modules.findIndex((m) => m.id === mod.id)
const { selectedId } = getContext<{ selectedId: Writable<string> }>('FlowGraphContext')
const dispatch = createEventDispatcher<{
delete: CustomEvent<MouseEvent>
insert: 'script' | 'forloop' | 'branchone' | 'branchall'
insert: {
modules: FlowModule[]
index: number
detail: 'script' | 'forloop' | 'branchone' | 'branchall'
}
select: string
newBranch: { module: FlowModule }
}>()
$: itemProps = {
@@ -33,109 +45,130 @@
function onDelete(event: CustomEvent<MouseEvent>) {
dispatch('delete', event)
}
let openMenu: boolean | undefined = undefined
let openMenu2: boolean | undefined = undefined
</script>
{#if mod}
<InsertModuleButton trigger={index == 0} on:new={(e) => dispatch('insert', e.detail)} />
{#if insertable}
<div
class="{openMenu ? 'z-10' : ''} w-7 absolute -top-9 left-[50%] right-[50%] -translate-x-1/2"
>
<InsertModuleButton
bind:open={openMenu}
{trigger}
on:new={(e) => {
dispatch('insert', { modules, index: idx, detail: e.detail })
}}
/>
</div>
{/if}
{#if mod.value.type === 'forloopflow'}
<li class="w-full">
<FlowModuleSchemaItem
deletable
label={mod.summary || 'For loop'}
id={mod.id}
on:delete={onDelete}
on:click={() => select(mod.id)}
{...itemProps}
>
<div slot="icon">
<Repeat size={16} />
</div>
</FlowModuleSchemaItem>
<div class="flex flex-row w-full">
<div class="w-7 shrink-0 line" />
<div class="grow my-1 min-w-0">
<div class="w-full">
<FlowModuleSchemaMap bind:modules={mod.value.modules} />
</div>
</div>
<FlowModuleSchemaItem
deletable={insertable}
label={mod.summary || 'For loop'}
id={mod.id}
on:delete={onDelete}
on:click={() => dispatch('select', mod.id)}
{...itemProps}
{bgColor}
>
<div slot="icon">
<Repeat size={16} />
</div>
</li>
</FlowModuleSchemaItem>
{:else if mod.value.type === 'branchone'}
<li>
<FlowModuleSchemaItem
deletable
on:delete={onDelete}
on:click={() => select(mod.id)}
{...itemProps}
id={mod.id}
label={mod.summary || 'Run one branch'}
>
<div slot="icon">
<Icon data={faCodeBranch} scale={1} />
</div>
</FlowModuleSchemaItem>
<FlowBranchOneMap bind:module={mod} />
</li>
<FlowModuleSchemaItem
deletable={insertable}
on:delete={onDelete}
on:click={() => dispatch('select', mod.id)}
{...itemProps}
id={mod.id}
label={mod.summary || 'Run one branch'}
{bgColor}
>
<div slot="icon">
<Icon data={faCodeBranch} scale={1} />
</div>
</FlowModuleSchemaItem>
{:else if mod.value.type === 'branchall'}
<li>
<FlowModuleSchemaItem
deletable
on:delete={onDelete}
on:click={() => select(mod.id)}
id={mod.id}
{...itemProps}
label={mod.summary || 'Run all branches'}
>
<div slot="icon">
<Icon data={faCodeBranch} scale={1} />
</div>
</FlowModuleSchemaItem>
<FlowBranchAllMap bind:module={mod} />
</li>
<FlowModuleSchemaItem
deletable={insertable}
on:delete={onDelete}
on:click={() => dispatch('select', mod.id)}
id={mod.id}
{...itemProps}
label={mod.summary || 'Run all branches'}
{bgColor}
>
<div slot="icon">
<Icon data={faCodeBranch} scale={1} />
</div>
</FlowModuleSchemaItem>
{:else}
<li>
<FlowModuleSchemaItem
on:click={() => select(mod.id)}
on:delete={onDelete}
deletable
id={mod.id}
{...itemProps}
label={mod.summary ||
(`path` in mod.value ? mod.value.path : undefined) ||
(mod.value.type === 'rawscript' ? `Inline ${mod.value.language}` : 'To be defined')}
>
<div slot="icon">
{#if mod.value.type === 'rawscript'}
<LanguageIcon lang={mod.value.language} width={16} height={16} />
{:else if mod.summary == 'Terminate flow'}
<Square size={16} />
{:else if mod.value.type === 'identity'}
<Icon data={faLongArrowDown} scale={1.1} />
{:else if mod.value.type === 'flow'}
<Icon data={faBarsStaggered} scale={1.0} />
{:else if mod.value.type === 'script'}
{#if mod.value.path.startsWith('hub/')}
<div>
<IconedResourceType
width="20px"
height="20px"
name={mod.value.path.split('/')[2]}
silent={true}
/>
</div>
{:else}
<Building size={14} />
{/if}
<FlowModuleSchemaItem
on:click={() => dispatch('select', mod.id)}
on:delete={onDelete}
deletable={insertable}
id={mod.id}
{...itemProps}
modType={mod.value.type}
{bgColor}
label={mod.summary ||
(`path` in mod.value ? mod.value.path : undefined) ||
(mod.value.type === 'rawscript' ? `Inline ${mod.value.language}` : 'To be defined')}
>
<div slot="icon">
{#if mod.value.type === 'rawscript'}
<LanguageIcon lang={mod.value.language} width={16} height={16} />
{:else if mod.summary == 'Terminate flow'}
<Square size={16} />
{:else if mod.value.type === 'identity'}
<Icon data={faLongArrowDown} scale={1.1} />
{:else if mod.value.type === 'flow'}
<Icon data={faBarsStaggered} scale={1.0} />
{:else if mod.value.type === 'script'}
{#if mod.value.path.startsWith('hub/')}
<div>
<IconedResourceType
width="20px"
height="20px"
name={mod.value.path.split('/')[2]}
silent={true}
/>
</div>
{:else}
<Building size={14} />
{/if}
</div>
</FlowModuleSchemaItem>
</li>
{/if}
</div>
</FlowModuleSchemaItem>
{/if}
{#if insertable && insertableEnd}
<div
class="{openMenu2 ? 'z-10' : ''} w-7 absolute top-11 left-[50%] right-[50%] -translate-x-1/2"
>
<InsertModuleButton
bind:open={openMenu2}
{trigger}
on:new={(e) => {
dispatch('insert', { modules, index: idx + 1, detail: e.detail })
}}
/>
</div>
{/if}
{#if insertable && branchable}
<div class="w-7 absolute top-11 left-[60%] right-[40%] -translate-x-1/2">
<button
on:click={() => {
dispatch('newBranch', { module: mod })
}}
type="button"
class=" text-gray-900 bg-white border mx-0.5 rotate-180 border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-full text-sm w-6 h-6 flex items-center justify-center"
>
<GitBranchPlus size={12} />
</button>
</div>
{/if}
{/if}
<style>
.line {
background: repeating-linear-gradient(to bottom, transparent 0 4px, rgb(120, 120, 120) 4px 8px)
50%/1px 100% no-repeat;
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import type { FlowModule } from '$lib/gen'
import { classNames } from '$lib/utils'
import { X } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import InsertModuleButton from './InsertModuleButton.svelte'
export let trigger = false
export let label: string
export let modules: FlowModule[] | undefined
export let index: number
export let insertable: boolean
export let bgColor: string = ''
export let selected: boolean
export let selectable: boolean
export let whereInsert: 'before' | 'after' = 'after'
export let deleteBranch: { module: FlowModule; index: number } | undefined = undefined
export let id: string | undefined = undefined
const dispatch = createEventDispatcher<{
insert: {
detail: 'script' | 'forloop' | 'branchone' | 'branchall'
modules: FlowModule[]
index: number
}
select: string
deleteBranch: { module: FlowModule; index: number }
}>()
let openMenu = false
</script>
{#if insertable && deleteBranch}
<div class="w-7 absolute -top-10 left-[50%] right-[50%] -translate-x-1/2">
<button
on:click|stopPropagation={() => {
dispatch('deleteBranch', deleteBranch)
}}
type="button"
class=" text-gray-900 bg-white border mx-0.5 border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-full text-sm w-6 h-6 flex items-center justify-center"
>
<X size={14} />
</button>
</div>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'w-full flex relative overflow-hidden rounded-sm ',
selectable ? 'cursor-pointer' : '',
selected ? 'outline outline-offset-1 outline-2 outline-gray-600' : ''
)}
style="min-width: 275px; height: 34px; background-color: {bgColor};"
on:click={() => {
if (selectable) {
if (id) {
dispatch('select', id)
} else {
dispatch('select', label)
}
}
}}
>
<div
class="flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
border border-gray-400 p-2 text-2xs module"
>
{#if $$slots.icon}
<slot name="icon" />
<span class="mr-2" />
{/if}
<div />
<div class="flex-1 truncate">{label}</div>
<div class="flex items-center space-x-2">
{#if id}
<Badge color="indigo">{id}</Badge>
{/if}
</div>
</div>
</div>
{#if insertable && modules}
<div
class="{openMenu ? 'z-10' : ''} w-7 absolute {whereInsert == 'after'
? 'top-12'
: '-top-10'} left-[50%] right-[50%] -translate-x-1/2"
>
<InsertModuleButton
bind:open={openMenu}
{trigger}
on:new={(e) => {
if (modules) {
dispatch('insert', { modules, index, detail: e.detail })
}
}}
/>
</div>
{/if}
@@ -4,7 +4,6 @@ import type { Schedule } from './scheduleUtils'
export type FlowEditorContext = {
selectedId: Writable<string>
select: (id: string) => void
schedule: Writable<Schedule>,
previewArgs: Writable<Record<string, any>>,
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
+355 -227
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import Svelvet from '@windmill-labs/svelvet'
import { sugiyama, dagStratify, decrossOpt, coordCenter } from 'd3-dag'
import { type FlowModule, FlowStatusModule, type ForloopFlow, RawScript } from '../../gen'
import { type FlowModule, FlowStatusModule } from '../../gen'
import {
NODE,
createIdGenerator,
@@ -14,35 +12,45 @@
type Loop,
type Branch,
type NestedNodes,
type ModuleHost,
type GraphModuleState,
type Edge
type GraphModuleState
} from '.'
import { defaultIfEmptyString, truncateRev } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { charsToNumber, numberToChars } from '../flows/utils'
import { createEventDispatcher, setContext } from 'svelte'
import Svelvet from './svelvet/container/views/Svelvet.svelte'
import type { UserEdgeType } from './svelvet/types'
import MapItem from '../flows/map/MapItem.svelte'
import VirtualItem from '../flows/map/VirtualItem.svelte'
import { writable, type Writable } from 'svelte/store'
export let success: boolean | undefined = undefined
export let modules: FlowModule[] | undefined = []
export let failureModule: FlowModule | undefined = undefined
export let minHeight: number = 0
export let maxHeight: number | undefined = undefined
export let notSelectable = false
export let flowModuleStates: Record<string, GraphModuleState> | undefined = undefined
export let rebuildOnChange: any = undefined
let selectedNode: string | undefined = undefined
export let selectedId: Writable<string | undefined> = writable<string | undefined>(undefined)
export let initialZoom: number | undefined = 1.0
export let insertable = false
setContext<{ selectedId: Writable<string | undefined> }>('FlowGraphContext', { selectedId })
let idGenerator: Generator
let nestedNodes: NestedNodes
let nodes: Node[] = []
let edges: Edge[] = []
let edges: UserEdgeType[] = []
let width: number, height: number
let errorHandlers: Record<string, number> = {}
let errorHandlers: Record<string, string> = {}
let dispatch = createEventDispatcher()
$: {
width && height && minHeight && selectedNode && flowModuleStates
rebuildOnChange
width && height && minHeight && $selectedId && flowModuleStates
nodes = edges = []
errorHandlers = {}
createGraph()
@@ -57,13 +65,46 @@
}
nestedNodes = nodes = []
nestedNodes.push(createVirtualNode(getParentIds(), 'Input'))
nestedNodes.push(
createVirtualNode(
getParentIds(),
'Input',
modules.length == 0 ? modules : undefined,
'after',
undefined,
undefined,
0,
0,
true,
undefined
)
)
modules.forEach((m) => {
const item = getConvertedFlowModule(m)
modules.forEach((m, i) => {
const item = getConvertedFlowModule(
m,
undefined,
undefined,
0,
i + 1 == modules?.length,
modules!
)
item && nestedNodes.push(item)
})
nestedNodes.push(createVirtualNode(getParentIds(), 'Result'))
nestedNodes.push(
createVirtualNode(
getParentIds(),
'Result',
undefined,
'before',
undefined,
undefined,
0,
modules.length,
true,
undefined
)
)
if (!flowModuleStates) {
if (failureModule) nestedNodes.push(createErrorHandler(failureModule))
@@ -75,50 +116,28 @@
})
}
const flatNodes = flattenNestedNodes(nestedNodes)
const layered = layoutNodes(flatNodes)
nodes = layered.nodes
// width = layered.width
height = layered.height
height = Math.min(Math.max(layered.height, minHeight), maxHeight ?? window.innerHeight - 100)
edges = createEdges(nodes)
}
function getConvertedFlowModule(
module: FlowModule,
parent: NestedNodes | string | undefined = undefined,
edgeLabel: string | undefined = undefined,
insideLoop: boolean = false
parent: NestedNodes | string | undefined,
edgeLabel: string | undefined,
loopDepth: number,
insertableEnd: boolean,
modules: FlowModule[]
): GraphItem | undefined {
const type = module.value.type
const parentIds = getParentIds(parent)
if (type === 'rawscript') {
const lang = module.value.language
return flowModuleToNode(
parentIds,
module.id,
module.summary || 'Inline script',
'inline',
module,
lang,
edgeLabel,
undefined,
insideLoop
)
} else if (type === 'script') {
const isHub = module.value.path.startsWith('hub/')
return flowModuleToNode(
parentIds,
module.id,
module.summary || module.value.path,
isHub ? 'hub' : 'workspace',
module,
undefined,
edgeLabel,
undefined,
insideLoop
)
} else if (type === 'forloopflow') {
return flowModuleToLoop(module.value.modules, module, parent)
if (type === 'forloopflow') {
//@ts-ignore
return flowModuleToLoop(modules, module, parent, loopDepth)
} else if (type === 'branchone') {
const branches = [
{ summary: 'Default branch', modules: module.value.default },
@@ -129,40 +148,29 @@
]
return flowModuleToBranch(
module,
modules,
branches,
['Default', ...module.value.branches.map((x) => `${truncateRev(x.expr, 20)}`)],
['', ...module.value.branches.map((x) => `${truncateRev(x.expr, 20)}`)],
parent,
insideLoop
loopDepth,
false
)
} else if (type === 'branchall') {
const branches = module.value.branches.map((b, i) => ({
summary: defaultIfEmptyString(b.summary, `Branch ${i + 1}`),
modules: b.modules
}))
return flowModuleToBranch(module, branches, [], parent, insideLoop)
} else if (type === 'flow') {
return flowModuleToNode(
parentIds,
module.id,
module.summary || 'Flow ' + module.value.path,
'inline',
module,
undefined,
edgeLabel,
undefined,
insideLoop
)
return flowModuleToBranch(module, modules, branches, [], parent, loopDepth, true)
}
return flowModuleToNode(
parentIds,
module.id,
module.summary || 'Identity step',
'inline',
module,
undefined,
edgeLabel,
undefined,
insideLoop
loopDepth,
insertableEnd,
false,
modules
)
}
@@ -174,12 +182,12 @@
if (!item) return []
if (isNode(item)) {
const id = numberToChars(item.id)
const id = item.id
return [id]
} else if (isLoop(item)) {
return getParentIds(item.items)
} else if (isBranch(item)) {
return item.items.map((i) => getParentIds(i)).flat()
return [item.nodeEnd.id]
}
return []
}
@@ -201,117 +209,134 @@
}
}
function getResultColor(): string | undefined {
function getResultColor(): string {
switch (success) {
case true:
return getStateColor(FlowStatusModule.type.SUCCESS)
case false:
return getStateColor(FlowStatusModule.type.FAILURE)
default:
return undefined
return '#fff'
}
}
function flowModuleToNode(
parentIds: string[],
id: string,
title: string,
host: ModuleHost,
onClickDetail: any,
lang?: RawScript.language,
edgeLabel?: string,
header?: string,
insideLoop: boolean = false
mod: FlowModule,
edgeLabel: string | undefined,
annotation: string | undefined,
loopDepth: number,
insertableEnd: boolean,
branchable: boolean,
modules: FlowModule[]
): Node {
const langImg: Record<RawScript.language, string> = {
deno: '/icons/ts-lang.svg',
go: '/icons/go-lang.svg',
python3: '/icons/python-lang.svg',
bash: '/icons/bash-lang.svg'
}
const hostImg: Record<ModuleHost, string> = {
hub: '/icons/hub-script.svg',
workspace: '/icons/inline-script.svg',
inline: ''
}
const wrapperWidth = lang ? 'w-[calc(100%-70px)]' : 'w-[calc(100%-50px)]'
let generatedId = idGenerator.next().value
let nodeId = id ?? numberToChars(generatedId - 1)
// console.log(id, generatedId, nodeId, charsToNumber(nodeId))
return {
id: charsToNumber(nodeId),
type: 'node',
id: mod.id,
position: { x: -1, y: -1 },
data: {
html: `
<div class="w-full flex justify-between items-center px-1">
<div class="${wrapperWidth} text-left ellipsize text-2xs truncate">
${title}
</div>
<div class="flex items-center">
${lang ? `<img src="${langImg[lang]}" class="grayscale">` : ''}
${host != 'inline' ? `<img src="${hostImg[host]}" class="grayscale">` : ''}
<span class="center-center font-semibold bg-indigo-100 text-indigo-800 rounded px-1 pb-[2px] ml-[2px]">
${nodeId}
</span>
</div>
</div>
<div class="text-2xs absolute -top-6 text-gray-600 truncate">${
flowModuleStates?.[nodeId]?.scheduled_for ?? header ?? ''
}<div>
`
custom: {
component: MapItem,
props: {
trigger: parentIds.length == 0,
mod,
insertable,
insertableEnd,
branchable,
bgColor: getStateColor(flowModuleStates?.[mod.id]?.type),
annotation,
modules
},
cb: (e: string, detail: any) => {
if (e == 'delete') {
dispatch('delete', detail)
} else if (e == 'select') {
if (!notSelectable) {
if ($selectedId != mod.id) {
$selectedId = mod.id
dispatch('select', mod)
}
}
} else if (e == 'insert') {
dispatch('insert', detail)
} else if (e == 'newBranch') {
dispatch('newBranch', detail)
}
}
}
},
host,
width: insideLoop ? NODE.width * 0.8 : NODE.width,
width: NODE.width,
height: NODE.height,
borderColor: selectedNode == nodeId ? 'black' : '#999',
bgColor: selectedNode == nodeId ? '#f5f5f5' : getStateColor(flowModuleStates?.[nodeId]?.type),
parentIds,
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = nodeId
}
if (onClickDetail.id == undefined) {
onClickDetail.id = nodeId
}
dispatch('click', onClickDetail)
},
edgeLabel
sourcePosition: 'bottom',
targetPosition: 'top',
edgeLabel,
loopDepth
}
}
function flowModuleToLoop(
modules: FlowModule[],
module: FlowModule,
parent: NestedNodes | string | undefined = undefined
module: FlowModule & { value: { type: 'forloopflow' } },
parent: NestedNodes | string | undefined,
loopDepth: number
): Loop {
const value = module.value as ForloopFlow
const expr = value.iterator.type == 'static' ? value.iterator.value : value.iterator.expr
const loop: Loop = {
type: 'loop',
items: [
flowModuleToNode(
getParentIds(parent),
module.id,
module.summary || `For Loop: ${defaultIfEmptyString(expr ?? '', 'TBD')}`,
'inline',
module,
undefined,
undefined,
flowModuleStates?.[module.id]?.iteration_total
? 'Iteration ' + flowModuleStates?.[module.id]?.iteration_total
: ''
: '',
loopDepth,
false,
false,
modules
)
]
}
modules.forEach((module) => {
const item = getConvertedFlowModule(module, loop.items, undefined, true)
const innerModules = module.value.modules
loop.items.push(
createVirtualNode(
getParentIds(loop.items),
`Do one iteration`,
innerModules,
'after',
undefined,
1000,
loopDepth + 1,
0,
true,
undefined,
undefined
)
)
innerModules.forEach((module, i) => {
const item = getConvertedFlowModule(
module,
loop.items,
undefined,
loopDepth + 1,
i + 1 == innerModules?.length,
innerModules
)
item && loop.items.push(item)
})
loop.items.push(
createVirtualNode(
getParentIds(loop.items),
`Collect iterations' results of For Loop ${module.id ?? ''}`,
`Collect result of each iteration`,
modules,
'after',
undefined,
1000
1000,
loopDepth,
0,
true,
undefined,
module.id
)
)
return loop
@@ -319,44 +344,92 @@
function flowModuleToBranch(
module: FlowModule,
modules: FlowModule[],
branches: { summary: string; modules: FlowModule[] }[],
edgesLabel: string[],
parent: string | NestedNodes | undefined = undefined,
insideLoop: boolean = false
): Branch {
const branch: Branch = {
type: 'branch',
node: flowModuleToNode(
getParentIds(parent),
module.id,
module.summary || module.value.type == 'branchall'
? 'Run all branches'
: 'Run one branch given predicate',
'inline',
module,
undefined,
undefined,
undefined,
insideLoop
),
items: []
}
const branchParent = [numberToChars(branch.node.id)]
loopDepth: number,
branchall: boolean
): Branch | Node {
const node = flowModuleToNode(
getParentIds(parent),
module,
undefined,
undefined,
loopDepth,
false,
true,
modules
)
const bitems: NestedNodes[] = []
const branchParent = [node.id]
if (branches.length == 0) {
branch.items.push([createVirtualNode(branchParent, 'No branches')])
bitems.push([
createVirtualNode(
branchParent,
'No branches',
undefined,
'after',
undefined,
0,
loopDepth,
0,
false,
undefined
)
])
}
branches.forEach(({ summary, modules }, i) => {
const items: NestedNodes = []
items.push(createVirtualNode(branchParent, summary, edgesLabel[i]))
items.push(
createVirtualNode(
branchParent,
summary,
modules,
'after',
edgesLabel[i],
undefined,
loopDepth,
0,
false,
{ module, index: i }
)
)
if (modules.length) {
modules.forEach((module, j) => {
const item = getConvertedFlowModule(module, items, undefined, insideLoop)
const item = getConvertedFlowModule(
module,
items,
undefined,
loopDepth,
j + 1 == modules?.length,
modules
)
item && items.push(item)
})
}
items.length && branch.items.push(items)
items.length && bitems.push(items)
})
return branch
return {
type: 'branch',
node,
nodeEnd: createVirtualNode(
bitems.map((i) => getParentIds(i)).flat(),
branchall ? 'Collect result of each branch' : 'Result of the chosen branch',
undefined,
'after',
undefined,
0,
loopDepth,
0,
true,
undefined,
module.id
),
items: bitems
}
}
function flattenNestedNodes(nestedNodes: NestedNodes, nodes: Node[] = []): Node[] {
@@ -371,18 +444,18 @@
node.items.forEach((item) => {
flattenNestedNodes(item, array)
})
array.push(node.nodeEnd)
}
})
return array
}
function layoutNodes(nodes: Node[]): { nodes: Node[]; height: number } {
// console.log('layoutNodes', nodes)
const stratify = dagStratify().id(({ id }: Node) => numberToChars(id))
const stratify = dagStratify().id(({ id }: Node) => id)
const dag = stratify(nodes)
const layout = sugiyama()
.decross(decrossOpt())
.decross(decrossOpt().large('medium'))
.coord(coordCenter())
.nodeSize(() => [NODE.width + NODE.gap.horizontal, NODE.height + NODE.gap.vertical])
const boxSize = layout(dag)
@@ -391,53 +464,61 @@
...des.data,
id: des.data.id,
position: {
x: des.x ? des.x + (width - boxSize.width - NODE.width) / 2 : 0,
x: des.x
? des.data.loopDepth * 50 + des.x + width - boxSize.width / 2 - NODE.width / 2
: 0,
y: des.y || 0
}
})),
height: Math.max(boxSize.height + NODE.height, minHeight)
height: boxSize.height + NODE.height
}
}
function createEdges(nodes: Node[]): Edge[] {
const edges: Edge[] = []
function createEdges(nodes: Node[]): UserEdgeType[] {
const edges: UserEdgeType[] = []
nodes.forEach((node) => {
node.parentIds.forEach((pid, i) => {
// skip virtual nodes such as collect result
if (errorHandlers[pid] && node.id < 900 && nodes.find((x) => x.id == errorHandlers[pid])) {
if (
false &&
errorHandlers[pid] &&
parseInt(node.id) < 900 &&
nodes.find((x) => x.id == errorHandlers[pid])
) {
edges.push({
id: `e-${pid}-${node.id}`,
source: charsToNumber(pid),
source: pid,
target: errorHandlers[pid],
labelBgColor: 'white',
arrow: true,
arrow: false,
animate: false,
noHandle: false,
label: node.edgeLabel
// type: 'smoothstep'
noHandle: true,
label: node.edgeLabel,
type: 'bezier'
})
edges.push({
id: `e-${pid}-${node.id}`,
source: errorHandlers[pid],
target: node.id,
labelBgColor: 'white',
arrow: true,
arrow: false,
animate: false,
noHandle: false,
label: node.edgeLabel
// type: 'smoothstep'
noHandle: true,
label: node.edgeLabel,
type: 'bezier'
})
} else {
edges.push({
id: `e-${pid}-${node.id}`,
source: charsToNumber(pid),
source: pid,
target: node.id,
labelBgColor: 'white',
arrow: true,
arrow: false,
animate: false,
noHandle: false,
label: node.edgeLabel
// type: 'smoothstep'
noHandle: true,
label: node.edgeLabel,
edgeColor: '#999',
type: 'bezier'
})
}
})
@@ -448,73 +529,120 @@
function createVirtualNode(
parentIds: string[],
label: string,
edgeLabel?: string,
offset?: number
modules: FlowModule[] | undefined,
whereInsert: 'before' | 'after' | undefined,
edgeLabel: string | undefined,
offset: number | undefined,
loopDepth: number,
index: number,
selectable: boolean,
deleteBranch: { module: FlowModule; index: number } | undefined,
mid: string | undefined = undefined
): Node {
const id = -idGenerator.next().value - 2 + (offset ?? 0)
return {
id,
type: 'node',
id: id.toString(),
position: { x: -1, y: -1 },
data: {
html: `
<div class="w-full max-h-full text-center ellipsize-multi-line text-2xs [-webkit-line-clamp:2] px-1">
${label}
</div>
`
// html: `
// <div class="w-full max-h-full text-center ellipsize-multi-line text-2xs [-webkit-line-clamp:2] px-1">
// ${label}
// </div>
// `
custom: {
component: VirtualItem,
props: {
label,
insertable,
modules,
bgColor: label == 'Result' ? getResultColor() : '#dfe6ee',
selected: $selectedId == label,
index,
selectable,
whereInsert,
deleteBranch,
id: mid
},
cb: (e: string, detail: any) => {
if (e == 'insert') {
dispatch('insert', detail)
} else if (e == 'select') {
$selectedId = detail
dispatch('select', detail)
} else if (e == 'deleteBranch') {
$selectedId = label
dispatch('deleteBranch', detail)
}
}
}
},
width: NODE.width,
height: NODE.height,
borderColor: selectedNode == label ? 'black' : '#999',
bgColor: label == 'Result' ? getResultColor() : selectedNode == label ? '#f5f5f5' : '#d4e4ff',
borderColor: $selectedId == label ? 'black' : '#999',
sourcePosition: 'bottom',
targetPosition: 'top',
parentIds,
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = label
}
console.log('clickCallback', label, node)
dispatch('click', label)
},
edgeLabel
edgeLabel,
loopDepth
}
}
function createErrorHandler(mod: FlowModule, parent_module?: string): Node {
const nId = -idGenerator.next().value - 1 + 1100
const nId = (-idGenerator.next().value - 1 + 1100).toString()
parent_module && (errorHandlers[parent_module] = nId)
let label = 'Error handler'
return {
type: 'node',
id: nId,
position: { x: -1, y: -1 },
data: {
html: `
<div class="w-full flex justify-between items-center px-1">
<div class="text-left ellipsize text-2xs truncate">
Error Handler
</div>
<div class="flex items-center">
<span class="center-center font-semibold bg-indigo-100 text-indigo-800 rounded px-1 pb-[2px] ml-[2px]">
${mod.id}
</span>
</div>
</div>
`
custom: {
component: VirtualItem,
props: {
label,
insertable: false,
modules: undefined,
bgColor: getStateColor(flowModuleStates?.[mod.id]?.type),
selected: $selectedId == mod.id,
index: 0,
selectable: true,
id: mod.id
},
cb: (e: string, detail: any) => {
console.log(detail)
if (e == 'select') {
$selectedId = detail
dispatch('select', detail)
}
}
}
},
width: NODE.width,
height: NODE.height,
bgColor: selectedNode == mod.id ? '#f5f5f5' : getStateColor(flowModuleStates?.[mod.id]?.type),
borderColor: selectedNode == mod.id ? 'black' : '#999',
sourcePosition: 'bottom',
targetPosition: 'top',
parentIds: parent_module ? [parent_module] : [],
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = mod.id
}
dispatch('click', mod)
}
loopDepth: 0
}
}
</script>
<div bind:clientWidth={width} class="w-full h-full overflow-hidden relative">
{#if width && height}
<Svelvet {nodes} {width} {edges} {height} bgColor="rgb(249 250 251)" />
{#key width}
<Svelvet
highlightEdges={false}
locked
initialLocation={{ x: width * (initialZoom ?? 1), y: height / 2 }}
{initialZoom}
{nodes}
{width}
{edges}
{height}
background={false}
bgColor="rgb(249 250 251)"
/>
{/key}
{/if}
</div>
+4 -34
View File
@@ -1,40 +1,9 @@
import type { FlowStatusModule } from "$lib/gen"
import type { UserNodeType } from "./svelvet/types";
export interface SvelvetNode<T = any> {
id: number;
position: { x: number, y: number };
data: T;
width: number;
height: number;
bgColor?: string;
fontSize?: number;
borderColor?: string;
parentNode?: number;
childNodes?: number[];
borderRadius?: number;
textColor?: string;
clickCallback?: Function;
image?: boolean;
src?: string;
sourcePosition?: 'left' | 'right' | 'top' | 'bottom';
targetPosition?: 'left' | 'right' | 'top' | 'bottom';
}
export interface Edge {
id: string;
source: number;
target: number;
label?: string;
labelBgColor?: string;
labelTextColor?: string;
edgeColor?: string;
type?: string;
animate?: boolean;
noHandle?: boolean;
arrow?: boolean;
}
export type ModuleHost = 'workspace' | 'inline' | 'hub'
export type Node = SvelvetNode & { parentIds: string[], edgeLabel?: string, host?: ModuleHost }
export type Node = UserNodeType & { parentIds: string[], edgeLabel?: string, host?: ModuleHost, type: 'node', loopDepth: number }
export type Loop = {
type: 'loop',
@@ -43,6 +12,7 @@ export type Loop = {
export type Branch = {
node: Node,
nodeEnd: Node,
type: 'branch',
items: NestedNodes[]
}
@@ -64,7 +34,7 @@ export type GraphModuleState = {
export type NestedNodes = GraphItem[]
export function isNode(item: GraphItem | NestedNodes | undefined): item is Node {
return typeof item?.['id'] === 'number'
return item?.['type'] === 'node'
}
export function isLoop(item: GraphItem | NestedNodes | undefined): item is Loop {
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 OSLabs Beta
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,186 @@
import type {
EdgeType,
NodeType,
ResizeNodeType,
StoreType,
} from '../../store/types/types';
import type { UserEdgeType, UserNodeType } from '../../types/types';
import { Collapsible } from '../models/Collapsible';
import type { CollapsibleType } from '../types/types';
import { get } from 'svelte/store';
import type { AnchorType } from '../../edges/types/types';
import { getAnchorById } from '../../edges/controllers/util';
import { getAnchors } from '../../edges/controllers/util';
const pkStringGenerator = () => (Math.random() + 1).toString(36).substring(7);
/*
Initializes store with array of Collapsible objects. You shoould only use this if you want the collapsible feature enabled.
*/
export function populateCollapsibleStore(
store: StoreType,
userNodes: UserNodeType[],
userEdges: UserEdgeType[],
canvasId: string
) {
const newCollapsibleStore: CollapsibleType[] = [];
for (let userNode of userNodes) {
const collapsible = new Collapsible(
pkStringGenerator(),
userNode.id,
0,
'expanded'
);
newCollapsibleStore.push(collapsible);
}
store.collapsibleStore.set(newCollapsibleStore);
}
/*
*/
function incrementHideCount(store: StoreType, nodeId: string) {
const collapsibles = get(store.collapsibleStore);
for (let collapsible of collapsibles)
if (collapsible.nodeId === nodeId) collapsible.hideCount++;
store.collapsibleStore.set(collapsibles);
}
// Given a nodeId, find ids of all connecting target nodes
function findTargets(store: StoreType, nodeId: string): string[] {
// get source anchors on the node
const anchors = getAnchors(store, {
nodeId: nodeId,
sourceOrTarget: 'source',
});
// get target anchors on other node, and record the node id
const targetNodeIds: string[] = [];
for (const anchor of anchors) {
const targetAnchorId = anchor.getOtherAnchorId();
const targetAnchor = getAnchorById(store, targetAnchorId);
targetNodeIds.push(targetAnchor.nodeId);
}
return targetNodeIds;
}
// traverses tree and increments hideCount
function traverseAndIncrement(
store: StoreType,
nodeId: string,
operation: 'increment' | 'decrement'
) {
const collapsibles = get(store.collapsibleStore);
recursiveTraverse(nodeId);
store.collapsibleStore.set(collapsibles);
function recursiveTraverse(nId: string) {
for (const collapsible of collapsibles) {
if (collapsible.nodeId === nId) {
if (operation === 'increment') collapsible.hideCount++;
else collapsible.hideCount--;
const targetIds = findTargets(store, nId);
for (const targetId of targetIds) {
recursiveTraverse(targetId);
}
}
}
}
}
function collapse(store: StoreType, nodeId: string) {
const targetNodeIds = findTargets(store, nodeId);
for (const targetNodeId of targetNodeIds)
traverseAndIncrement(store, targetNodeId, 'increment');
}
function expand(store: StoreType, nodeId: string) {
const targetNodeIds = findTargets(store, nodeId);
for (const targetNodeId of targetNodeIds)
traverseAndIncrement(store, targetNodeId, 'decrement');
}
export function toggleExpandAndCollapse(store: StoreType, nodeId: string) {
const collapsibles = getCollapsibles(store, { nodeId: nodeId });
if (collapsibles.length === 0) return; // when the collapsible feature is disabled, there will be no collapbsible objects
if (collapsibles.length > 1)
throw 'there should only be one collapsible object per node';
const collapsible = collapsibles[0];
if (collapsible.state === 'expanded') collapse(store, nodeId);
else expand(store, nodeId);
store.collapsibleStore.update((arr) => {
for (const c of arr) if (c.id === collapsible.id) c.toggleState();
return [...arr];
});
}
export function getCollapsibles(
store: StoreType,
filter?: { [key: string]: any }
) {
let collapsibles = Object.values(get(store.collapsibleStore));
// filter the array of anchors for elements that match filter
// Example: if filter = {sourceOrTarget: 'source', positionX: 35} then we will
//return all anchors with sourceOrTarget = source AND poxitionX = 35
if (filter !== undefined) {
collapsibles = collapsibles.filter((collapsible) => {
for (let filterKey in filter) {
const filterValue = filter[filterKey];
if (collapsible[filterKey as keyof CollapsibleType] !== filterValue)
return false;
}
return true;
});
}
// return list of anchors
return collapsibles;
}
/*
This function is responsible for filtering nodes should be displayed based on Collapsible.
It also filters node-associated elements such as anchors, edges, etc. so that when you collapse a node, the
edges also hide.
There is a better way to implement this with foreign keys; when collapsing a node, you would also collapse any rows with a foreign key
linking to that node (like a cascading delete in SQL, but with hiding instead of deleting)
*/
export function filterByCollapsible(
store: StoreType,
nodes: NodeType[],
resizeNodes: ResizeNodeType[],
anchors: AnchorType[],
edges: EdgeType[]
) {
// filter nodes for the collapsible nodes feature
const filteredNodes = nodes.filter((node) => {
const nodeId = node.id;
const collapssibleObj = get(store.collapsibleStore).find(
(e) => e.nodeId === nodeId
);
if (collapssibleObj === undefined) return true;
return collapssibleObj.isHidden() === false;
});
const filteredNodeIds = filteredNodes.map((e) => e.id);
// filter resizeNodes
const filteredResizeNodes = resizeNodes.filter((resizeNode) =>
filteredNodeIds.includes(resizeNode.nodeId)
);
const filteredAnchors = anchors.filter((selfAnchor) => {
const otherAnchorId = selfAnchor.getOtherAnchorId();
const otherAnchor = get(store.anchorsStore)[otherAnchorId];
if (
filteredNodeIds.includes(selfAnchor.nodeId) &&
filteredNodeIds.includes(otherAnchor.nodeId)
)
return true;
return false;
});
const filteredEdgeIds = new Set(filteredAnchors.map((e) => e.edgeId));
const filteredEdges = edges.filter((edge) => filteredEdgeIds.has(edge.id));
return {
filteredNodes,
filteredResizeNodes,
filteredAnchors,
filteredEdges,
};
}
@@ -0,0 +1,26 @@
/**
* This model implements functionality for nodes to expand and collapse their children
*/
import type { CollapsibleType } from '../types/types';
/** Class that implements collapsible/expandable functionality for Node objects
* @param {string} id Unique string that serves as a primary key
* @param {string} nodeId Foreign key to a Node Object
*/
export class Collapsible implements CollapsibleType {
constructor(
public id: string,
public nodeId: string,
public hideCount: number,
public state: 'expanded' | 'collapsed'
) {}
isHidden() {
return this.hideCount > 0;
}
toggleState() {
this.state = this.state === 'expanded' ? 'collapsed' : 'expanded';
}
}
@@ -0,0 +1,8 @@
export interface CollapsibleType {
id: string;
nodeId: string;
hideCount: number;
state: 'expanded' | 'collapsed';
isHidden: Function;
toggleState: Function;
}
@@ -0,0 +1,7 @@
# Container
This folder contains two containers: Svelvet and Canvas.
## Svelvet.svelte
Svelvet.svelte is the top level component
@@ -0,0 +1,120 @@
/*
This file contains "middleware" functions that sanitize user input (UserNodeType and UserEdgeType). Put functions that
maintain consistency between previous
*/
import { get } from 'svelte/store';
import {
bottomCb,
leftCb,
rightCb,
topCb,
} from '../../edges/controllers/anchorCbUser';
import type { StoreType } from '../../store/types/types';
import type { UserEdgeType, UserNodeType } from '../../types/types';
/**
* sanitizeCanvasOptions will sanitize the canvas level options so that incompatible features will not be run simulataneously
* @param store The array of nodes that have a UserNodeType
* @returns void. The store is modified directly
*/
export function sanitizeCanvasOptions(store: StoreType) {
enforceCollapsibleCompatibility(store);
}
function enforceCollapsibleCompatibility(store: StoreType) {
if (get(store.collapsibleOption)) {
store.nodeCreate.set(false);
}
}
/**
* sanitizeUserNodesAndEdges will sanitize the data initially passed in to Svelvet component. For example, the node that user specified have an integar as its id but to instantiate a Node and be compatible with uuid we will need to convert the integar id to a string.
* @param userNodes The array of nodes that have a UserNodeType
* @param userEdges The array of edges that have a UserEdgeType
* @returns An object of sanitized userNodes and userEdges
*/
export function sanitizeUserNodesAndEdges(
userNodes: UserNodeType[],
userEdges: UserEdgeType[]
) {
convertIdToString(userNodes);
convertEdgeIdsToString(userEdges);
convertAnchorPositionsToCallbacks(userNodes, userEdges);
setDefaultEdgeType(userEdges);
return { userNodes, userEdges };
}
/**
* setDefaultEdgeType ensures a default edge type of 'bezier' if the user does not set one, or sets a nonsense type.
* @param userEdges The array of edges that have a UserEdgeType
* @returns No return, the function edits the objects in place
*/
function setDefaultEdgeType(userEdges) {
for (let userEdge of userEdges) {
if (!['smoothstep', 'step', 'bezier', 'straight'].includes(userEdge.type))
userEdge.type = 'bezier';
}
}
/**
* convertAnchorPositionsToCallbacks
* @description
* WHY: This function is in order to maintain compliance with earlier versions of Svelvet.
* HISTORY: In Svelvet<=5, anchor points were hard-coded onto each node. Each node had a "sourcePosition"
* "targetPosition" where the edges would be attached. In Svelvet6, the store was re-designed
* from the ground up into an object-relational data model where anchor points could be attached
* at any point on the node using callbacks. This enabled features such as custom edge position,
* adaptive edge positioning, and dynamic edges.
* The purpose of this function is to parse the old way of specifying edge positions (as two source/target
* anchors on the node) into the Svelvet6 (where anchors are separate objects) in order to maintain a
* consistent user experience. However, we suggest that this functionality (parsing Svelvet5 syntax into
* Svelvet6 syntax) be removed completely in favor of only using Svelvet6 syntax in order to reduce edge cases.
*/
function convertAnchorPositionsToCallbacks(
userNodes: UserNodeType[],
userEdges: UserEdgeType[]
) {
// convert userNodes array into object for constant time lookup by id
const userNodesObj: { [key: string]: UserNodeType } = {};
for (let userNode of userNodes) userNodesObj[userNode.id] = userNode;
// iterate through userEdges, and check the source/target nodes.
for (let userEdge of userEdges) {
const userNodeSource = userNodesObj[userEdge.source];
const userNodeTarget = userNodesObj[userEdge.target];
const sourcePosition = userNodeSource.sourcePosition;
const targetPosition = userNodeTarget.targetPosition;
const cbs = { left: leftCb, right: rightCb, top: topCb, bottom: bottomCb };
if (sourcePosition) userEdge.sourceAnchorCb = cbs[sourcePosition];
if (targetPosition) userEdge.targetAnchorCb = cbs[targetPosition];
}
}
/**
* Converts node id's to strings. For Svelvet<6, node id's were numbers. These were switched to strings for compatibility with uuid. This function does not return a new array, instead it mutates and sanitizes the original array.
* @param userNodes The array of userNodes (not yet sanitized)
*/
function convertIdToString(userNodes: UserNodeType[]) {
userNodes = userNodes.map((node) => {
node.id = node.id.toString();
node.childNodes =
node.childNodes === undefined
? []
: node.childNodes.map((childId) => childId.toString());
return node;
});
}
/**
* Converts source/target node id's to string. For Svelvet<6, id's were numbers. These were switched to strings for compatibility with uuid. This function does not return a new array, instead it mutates and sanitizes the original array.
* @param userEdges The array of userEdges (not yet sanitized)
*/
function convertEdgeIdsToString(userEdges: UserEdgeType[]) {
userEdges = userEdges.map((edge) => {
edge.source = edge.source.toString();
edge.target = edge.target.toString();
return edge;
});
}
@@ -0,0 +1,370 @@
<script lang="ts">
import { onMount } from 'svelte'
import { zoom, zoomTransform, zoomIdentity } from 'd3-zoom'
import { select, selectAll, pointer } from 'd3-selection'
import SimpleBezierEdge from '../../edges/views/Edges/SimpleBezierEdge.svelte'
import StepEdge from '../../edges/views/Edges/StepEdge.svelte'
import SmoothStepEdge from '../../edges/views/Edges/SmoothStepEdge.svelte'
import type { EdgeType, NodeType, ResizeNodeType } from '../../store/types/types'
import Node from '../../nodes/views/Node.svelte'
import { findStore } from '../../store/controllers/storeApi'
import PotentialAnchor from '../../interactiveNodes/views/PotentialAnchor.svelte'
import TemporaryEdge from '../../interactiveNodes/views/TemporaryEdge.svelte'
import { determineD3Instance, zoomInit } from '../..//d3/controllers/d3'
import EditEdge from '../../editEdges/views/EditEdge.svelte'
import { filterByCollapsible } from '../../collapsible/controllers/util'
import type { AnchorType } from '../../edges/types/types'
//these are typscripted as any, however they have been transformed inside of store.ts
export let canvasId: string
export let width: number
export let height: number
export let initialZoom = 3
export let initialLocation
export let boundary = false
export let minimap = false
// here we lookup the store using the unique key
const store = findStore(canvasId)
const {
edgesStore,
nodesStore,
anchorsStore,
potentialAnchorsStore,
temporaryEdgeStore,
resizeNodesStore,
nodeSelected,
backgroundStore,
movementStore,
widthStore,
heightStore,
d3Scale,
edgeEditModal,
collapsibleStore
} = store
$: nodes = Object.values($nodesStore)
$: edges = Object.values($edgesStore)
$: anchors = Object.values($anchorsStore)
$: resizeNodes = Object.values($resizeNodesStore)
$: potentialAnchors = Object.values($potentialAnchorsStore)
$: tempEdges = $temporaryEdgeStore
/*
This block of code is responsible for reactivity of the collapsible feature
When collaspsibleStore changes, nodes/edges/resizeNodes/anchors are filtered so that
only the visible ones are displayed
*/
let filteredNodes: NodeType[]
let filteredEdges: EdgeType[]
let filteredResizeNodes: ResizeNodeType[]
let filteredAnchors: AnchorType[]
$: {
const tmp = $collapsibleStore // assignment is necessary for reactivity
const obj = filterByCollapsible(store, nodes, resizeNodes, anchors, edges)
filteredNodes = obj['filteredNodes']
filteredEdges = obj['filteredEdges']
filteredResizeNodes = obj['filteredResizeNodes']
filteredAnchors = obj['filteredAnchors']
}
// declaring the grid and dot size for d3's transformations and zoom
const gridSize = 15
const dotSize = 10
// leveraging d3 library to zoom/pan
let d3 = {
zoom,
zoomTransform,
zoomIdentity,
select,
selectAll,
pointer
}
let d3Zoom = determineD3Instance(
boundary,
d3,
nodeSelected,
width,
height,
movementStore,
backgroundStore,
gridSize,
dotSize,
canvasId,
d3Scale,
handleZoom
)
// d3Translate is used for the minimap
let d3Translate = { x: 0, y: 0, k: 1 }
onMount(() => {
// actualizes the d3 instance
let edgesd3 = d3
.select(`.Edges-${canvasId}`)
.call(d3Zoom)
.on('wheel.zoom', null)
.on('mousewheel.zoom', null)
.on('mousemove.zoom', null)
.on('DOMMouseScroll.zoom', null)
.on('dblclick.zoom', null)
let nodesd3 = d3
.select(`.Nodes-${canvasId}`)
.call(d3Zoom)
.on('wheel.zoom', null)
.on('mousewheel.zoom', null)
.on('mousemove.zoom', null)
.on('DOMMouseScroll.zoom', null)
.on('dblclick.zoom', null)
d3.selectAll('#dot')
.call(d3Zoom)
.on('wheel.zoom', null)
.on('mousewheel.zoom', null)
.on('mousemove.zoom', null)
.on('DOMMouseScroll.zoom', null)
.on('dblclick.zoom', null)
d3.select('#zoom_in').on('click', function () {
try {
d3Zoom
.scaleBy(edgesd3.transition().duration(250), 1.4)
.translateTo(d3Translate.x, d3Translate.y)
d3Zoom
.scaleBy(nodesd3.transition().duration(250), 1.4)
.translateTo(d3Translate.x, d3Translate.y)
} catch {}
})
d3.select('#zoom_out').on('click', function () {
try {
d3Zoom
.scaleBy(edgesd3.transition().duration(250), 0.714)
.translateTo(d3Translate.x, d3Translate.y)
d3Zoom
.scaleBy(nodesd3.transition().duration(250), 0.714)
.translateTo(d3Translate.x, d3Translate.y)
} catch {}
})
d3Translate = zoomInit(d3, canvasId, d3Zoom, d3Translate, initialLocation, initialZoom, d3Scale)
})
// This is necessary to make Graphview reactive to changes in initialZoom
// When initialZoom changes, then zoomInit will set the zoom/position
let prevZoom = initialZoom
let prevInitialLocationX = initialLocation.x
let prevInitialLocationY = initialLocation.y
$: if (
initialZoom !== prevZoom ||
prevInitialLocationX !== initialLocation.x ||
prevInitialLocationY !== initialLocation.y
) {
prevZoom = initialZoom
prevInitialLocationX = initialLocation.x
prevInitialLocationY = initialLocation.y
d3Translate = zoomInit(d3, canvasId, d3Zoom, d3Translate, initialLocation, initialZoom, d3Scale)
}
// moves canvas when you click on the minimap
// handles case for when minimap sends message back to initiate translation event (click to traverse minimap)
// moves camera to the clicked node
function miniMapClick(event) {
// onclick in case of boundless minimap
if (!boundary) {
// For edges
d3.select(`.Edges-${key}`)
.transition()
.duration(500)
.call(d3Zoom.translateTo, event.detail.x, event.detail.y)
// For nodes
d3.select(`.Nodes-${key}`)
.transition()
.duration(500)
.call(d3Zoom.translateTo, event.detail.x, event.detail.y)
}
// handles case for when minimap has a boundary
else {
// For edges
d3.select(`.Edges-${key}`)
.transition()
.duration(500)
.call(d3Zoom.translateTo, event.detail.x, event.detail.y)
// For nodes
d3.select(`.Nodes-${key}`)
.transition()
.duration(500)
.call(d3Zoom.translateTo, event.detail.x, event.detail.y)
}
}
const key = canvasId
function handleZoom(e) {
if (!$movementStore) return
//add a store that contains the current value of the d3-zoom's scale to be used in onMouseMove function
d3Scale.set(e.transform.k)
// should not run d3.select below if backgroundStore is false
if (backgroundStore) {
d3.select(`#background-${canvasId}`)
.attr('x', e.transform.x)
.attr('y', e.transform.y)
.attr('width', gridSize * e.transform.k)
.attr('height', gridSize * e.transform.k)
.selectAll('#dot')
.attr('x', (gridSize * e.transform.k) / 2 - dotSize / 2)
.attr('y', (gridSize * e.transform.k) / 2 - dotSize / 2)
.attr('opacity', Math.min(e.transform.k, 1))
}
// transform 'g' SVG elements (edge, edge text, edge anchor)
d3.select(`.Edges-${canvasId} g`).attr('transform', e.transform)
// transform div elements (nodes)
//@ts-ignore
let transform = d3.zoomTransform(this)
d3Translate = transform
store.d3ZoomParameters.set({ ...transform }) // record x,y position of pan, and zoom level
// selects and transforms all node divs from class 'Node' and performs transformation
d3.select(`.Node-${canvasId}`)
.style(
'transform',
'translate(' + transform.x + 'px,' + transform.y + 'px) scale(' + transform.k + ')'
)
.style('transform-origin', '0 0')
}
</script>
{#if $edgeEditModal}
<EditEdge edgeId={`${$edgeEditModal}`} {canvasId} />
{/if}
<!-- This is the container that holds GraphView and we have disabled right click functionality to prevent a sticking behavior -->
<div id="graphview-container">
<div class={`Nodes Nodes-${canvasId}`} on:contextmenu|preventDefault>
<!-- This container is transformed by d3zoom -->
<div class={`Node Node-${canvasId}`}>
{#each filteredNodes as node}
{#if node.data.html}
<Node {node} {canvasId} nodeId={node.id}>{@html node.data.html}</Node>
{:else if node.data.custom}
<Node isCustom {node} {canvasId} nodeId={node.id}>
<svelte:component
this={node.data.custom.component}
on:new={(e) => node?.data?.custom?.cb?.('new', e.detail)}
on:delete={(e) => node?.data?.custom?.cb?.('delete', e.detail)}
on:select={(e) => node?.data?.custom?.cb?.('select', e.detail)}
on:insert={(e) => node?.data?.custom?.cb?.('insert', e.detail)}
on:newBranch={(e) => node?.data?.custom?.cb?.('newBranch', e.detail)}
on:deleteBranch={(e) => node?.data?.custom?.cb?.('deleteBranch', e.detail)}
{...node.data.custom.props}
/>
</Node>
{:else}
<Node {node} {canvasId} nodeId={node.id}>{node.data.label}</Node>
{/if}
{/each}
<!-- {#each filteredResizeNodes as res}
<ResizeNode resizeId={res.id} {canvasId} />
{/each} -->
{#each potentialAnchors as potentialAnchor}
<PotentialAnchor
{canvasId}
x={potentialAnchor.positionX}
y={potentialAnchor.positionY}
potentialAnchorId={potentialAnchor.id}
/>
{/each}
</div>
</div>
</div>
<!-- rendering dots on the background depending on the zoom level -->
<svg
class={`Edges Edges-${canvasId}`}
viewBox="0 0 {$widthStore} {$heightStore}"
on:contextmenu|preventDefault
>
<defs>
<pattern
id={`background-${canvasId}`}
x="0"
y="0"
width={gridSize}
height={gridSize}
patternUnits="userSpaceOnUse"
>
<circle
id="dot"
cx={gridSize / 2 - dotSize / 2}
cy={gridSize / 2 - dotSize / 2}
r="0.5"
style="fill: gray"
/>
</pattern>
</defs>
{#if $backgroundStore}
<rect width="100%" height="100%" style="fill: url(#background-{canvasId});" />
{/if}
<!-- <g> tag defines which edge type to render depending on properties of edge object -->
<g>
{#each filteredEdges as edge}
{#if edge.type === 'smoothstep'}
<SmoothStepEdge {edge} {canvasId} />
{:else if edge.type === 'step'}
<StepEdge {edge} {canvasId} />
{:else}
<SimpleBezierEdge edgeId={edge.id} {canvasId} />
{/if}
{/each}
{#each tempEdges as temporaryEdge}
<TemporaryEdge {temporaryEdge} />
{/each}
<!-- {#each filteredAnchors as anchor} -->
<!-- note that these are SVG -->
<!-- <EdgeAnchor x={anchor.positionX} y={anchor.positionY} /> -->
<!-- {/each} -->
</g>
</svg>
<div id="buttons">
<button id="zoom_in">+</button>
<button id="zoom_out">-</button>
</div>
<style>
#buttons {
position: absolute;
top: 4px;
right: 4px;
z-index: 20;
}
#buttons > button {
border-radius: 4px;
background-color: white;
border: #999 solid 1px;
padding-right: 4px;
padding-left: 4px;
width: 24px;
}
svg {
}
.Nodes {
position: absolute;
width: 100%;
height: 100%;
}
.Node {
color: black; /* remove this once color is set to default via types */
width: 100%;
height: 100%;
}
#graphview-container {
pointer-events: none;
}
.pointer-events-auto {
pointer-events: auto;
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
const pkStringGenerator = () => (Math.random() + 1).toString(36).substring(7)
import type { UserEdgeType, UserNodeType } from '../../types/types'
import {
createStoreEmpty,
populateSvelvetStoreFromUserInput
} from '../../store/controllers/storeApi'
import { afterUpdate, onMount } from 'svelte'
import GraphView from './GraphView.svelte'
import { sanitizeCanvasOptions, sanitizeUserNodesAndEdges } from '../controllers/middleware'
export let nodes: UserNodeType[] // TODO: update type to make possible user id being a number
export let edges: UserEdgeType[] // TODO: update type to make possible user id being a number
export let bgColor = '#ffffff' // this is used to set the background color of the the Svelvet canvas
export let minimap = false
export let width: number = 600
export let height: number = 600
export let background: boolean = true
export let movement: boolean = true
export let canvasId: string = pkStringGenerator()
export let snap: boolean = false
export let snapTo: number = 30
export let nodeCreate: boolean = false
export let initialZoom = 3
export let initialLocation = { x: 0, y: 0 }
export let boundary = false
export let collapsible = false
export let shareable: boolean = false // used for "importExport" feature
export let locked: boolean = false // if true, node movement is disabled
export let editable: boolean = false
export let resizable: boolean = true
export let highlightEdges: boolean = true
// generates a unique string for each svelvet component's unique store instance
// creates a store that uses the unique sting as the key to create and look up the corresponding store
// this way we can have multiple Svelvet Components on the same page and prevent overlap of information
const store = createStoreEmpty(canvasId)
// stores (state) within stores, so that we cannot access values from everywhere
// const { widthStore, heightStore, nodesStore, derivedEdges } = svelvetStore;
// sets the state of the store to the values passed in from the Svelvet Component on initial render
onMount(() => {
// sanitize user input
let output = sanitizeUserNodesAndEdges(nodes, edges)
const userNodes = output['userNodes']
const userEdges = output['userEdges']
// set canvas related stores. you need to do this before setting node/edge related stores because
// initializing nodes/edges might read relevant options from the store.
store.widthStore.set(width)
store.heightStore.set(height)
store.backgroundStore.set(background)
store.movementStore.set(movement)
const optionsObj = { snap, snapTo } // TODO: rename to snap
store.options.set(optionsObj) //
store.nodeCreate.set(nodeCreate)
store.boundary.set(boundary)
store.collapsibleOption.set(collapsible)
store.lockedOption.set(locked)
store.editableOption.set(editable)
store.resizableOption.set(resizable)
store.highlightEdgesOption.set(highlightEdges)
// make sure that all canvas options are compatible
sanitizeCanvasOptions(store)
// set node/edge related stores
populateSvelvetStoreFromUserInput(canvasId, userNodes, userEdges)
})
// // enables data reactivity. TODO: this needs to be added back in
// Probably need to use findStore, not create store
afterUpdate(() => {
// sanitize user input
let output = sanitizeUserNodesAndEdges(nodes, edges)
const userNodes = output['userNodes']
const userEdges = output['userEdges']
// set canvas related stores. you need to do this before setting node/edge related stores because
// initializing nodes/edges might read relevant options from the store.
store.widthStore.set(width)
store.heightStore.set(height)
store.backgroundStore.set(background)
store.movementStore.set(movement)
const optionsObj = { snap, snapTo } // TODO: rename to snap
store.options.set(optionsObj) //
store.nodeCreate.set(nodeCreate)
store.boundary.set(boundary)
store.collapsibleOption.set(collapsible)
store.lockedOption.set(locked)
store.editableOption.set(editable)
store.resizableOption.set(resizable)
store.highlightEdgesOption.set(highlightEdges)
// make sure that all canvas options are compatible
sanitizeCanvasOptions(store)
// set node/edge related stores
populateSvelvetStoreFromUserInput(canvasId, userNodes, userEdges)
})
</script>
<!-- Now that a store has been created from the initial nodes and initial edges we drill props from the store down to the D3 GraphView along with the unique key -->
<div
class="Svelvet"
style={`width: ${width}px; height: ${height}px; background-color: ${bgColor};`}
>
<GraphView {canvasId} {width} {height} {initialLocation} {initialZoom} {boundary} {minimap} />
</div>
<style>
.Svelvet {
position: relative;
overflow: hidden;
display: grid;
font-family: 'Segoe UI', sans-serif;
}
</style>
@@ -0,0 +1,62 @@
// Required for "custom css" feature
import type { NodeType, StoreType } from '../../store/types/types';
// WHAT: For a given node with a user-defined classname, see if there are any css properties for height and width
// Get that height and width and set properties in node store. This is necessary for
export const forceCssHeightAndWidth = (store: StoreType, node: NodeType) => {
let width, height, innerText;
// Look through each CSS rule to find the one the user defined
for (let i = 0; i < document.styleSheets.length; i++) {
const styleSheet = document.styleSheets[i];
const styleRules = styleSheet.cssRules;
for (let j = 0; j < styleRules.length; j++) {
const rule = styleRules[j];
// this is necessary to get rid of typescript warning for rule.selectorText
if (!(rule instanceof CSSStyleRule)) continue;
if (rule.selectorText === `.${node.className}`) {
const initialText = rule.cssText; // getting the full text of the CSS rule
const i = initialText.indexOf('{'); // finding index of first bracket
innerText = initialText.substring(i + 1, initialText.length - 1); // extracting the CSS to insert into inline style
// Adjusting the width and height if they are set via the custom class
const arr = innerText.split(' ');
arr.forEach((str, i) => {
if (str === 'width:') {
width = str.concat(arr[i + 1]); // go through the array and join width and the number
const w = parseInt(arr[i + 1]); // getting the number for the width
width = w;
}
if (str === 'height:') {
height = str.concat(arr[i + 1]); // same as with the width
const h = parseInt(arr[i + 1]);
height = h;
}
});
}
}
}
// update the width/height of the node
store.nodesStore.update((nodes) => {
if (width !== undefined) nodes[node.id].width = width;
if (height !== undefined) nodes[node.id].height = height;
return { ...nodes };
});
// move the node a bit to force update to anchors, potential anchors
node.setPositionFromMovement(0, 0);
// update the position of the resize node. TODO: this should be refactored so that it cascades from movement
store.resizeNodesStore.update((resizeNodes) => {
for (let resizeNode of Object.values(resizeNodes)) {
if (resizeNode.nodeId === node.id) {
// set resizeNode position to bottom left corner of Node
resizeNode.positionX = node.positionX + node.width;
resizeNode.positionY = node.positionY + node.height;
}
}
return { ...resizeNodes };
});
};
@@ -0,0 +1,3 @@
d3 is responsible for zooming and panning.
The code works but is obviously in a bad state. However, at least it is modularized so it doesn't clutter graphview anymore.
@@ -0,0 +1,90 @@
// import { zoom, zoomTransform } from 'd3-zoom';
// import { select, selectAll } from 'd3-selection';
import { get } from 'svelte/store';
export function zoomInit(
d3,
canvasId,
d3Zoom,
d3Translate,
initialLocation,
initialZoom,
d3Scale
) {
//set default zoom logic
d3.select(`.Edges-${canvasId}`)
//makes sure translation is default at center coordinates
.transition()
.duration(0)
.call(d3Zoom.translateTo, 0, 0)
//moves camera to coordinates
.transition()
.duration(0)
.call(
d3Zoom.translateTo,
initialLocation.x / initialZoom,
initialLocation.y / initialZoom
)
// zooms in on selected point
.transition()
.duration(0)
.call(d3Zoom.scaleTo, initialZoom.toFixed(2));
// updates d3Translate with d3 object with x, y, and k values to be sent down to the minimap to be further calculated further
d3Translate = d3.zoomIdentity
.translate(initialLocation.x, initialLocation.y)
.scale(initialZoom.toFixed(2));
d3.select(`.Nodes-${canvasId}`)
.transition()
.duration(0)
.call(d3Zoom.translateTo, 0, 0)
.transition()
.duration(0)
.call(
d3Zoom.translateTo,
initialLocation.x / initialZoom,
initialLocation.y / initialZoom
)
.transition()
.duration(0)
.call(d3Zoom.scaleTo, initialZoom.toFixed(2));
// sets D3 scale to current k of object
d3Scale.set(d3.zoomTransform(d3.select(`.Nodes-${canvasId}`)).k);
return d3Translate;
}
// create d3 instance conditionally based on boundary prop
export function determineD3Instance(
boundary,
d3,
nodeSelected,
width,
height,
movementStore,
backgroundStore,
gridSize,
dotSize,
canvasId,
d3Scale,
handleZoom
) {
if (boundary) {
return d3
.zoom()
.filter(() => !get(nodeSelected))
.scaleExtent([0.4, 4]) // limits for zooming in/out
.translateExtent([
[0, 0],
[boundary.x, boundary.y],
]) // world extent
.extent([
[0, 0],
[width, height],
])
.on('zoom', handleZoom);
} else {
return d3
.zoom()
.filter(() => !get(nodeSelected))
.scaleExtent([0.4, 2])
.on('zoom', handleZoom);
}
}
@@ -0,0 +1,58 @@
import { writable, derived, get, readable } from 'svelte/store';
export function d3ZoomCreator(
nodeSelected,
movementStore,
backgroundStore,
canvasId,
gridSize,
dotSize,
d3Scale,
d3
) {
// TODO: Update d3Zoom type (refer to d3Zoom docs)
let d3Zoom: any = d3
.zoom()
.filter(() => !get(nodeSelected))
.scaleExtent([0.4, 2])
.on('zoom', handleZoom);
return d3Zoom;
// function to handle zoom events - arguments: d3ZoomEvent
function handleZoom(this: any, e: any): void {
if (!get(movementStore)) return;
//add a store that contains the current value of the d3-zoom's scale to be used in onMouseMove function
d3Scale.set(e.transform.k);
// should not run d3.select below if backgroundStore is false
if (get(backgroundStore)) {
d3.select(`#background-${canvasId}`)
.attr('x', e.transform.x)
.attr('y', e.transform.y)
.attr('width', gridSize * e.transform.k)
.attr('height', gridSize * e.transform.k)
.selectAll('#dot')
.attr('x', (gridSize * e.transform.k) / 2 - dotSize / 2)
.attr('y', (gridSize * e.transform.k) / 2 - dotSize / 2)
.attr('opacity', Math.min(e.transform.k, 1));
}
// transform 'g' SVG elements (edge, edge text, edge anchor)
d3.select(`.Edges-${canvasId} g`).attr('transform', e.transform);
// transform div elements (nodes)
let transform = d3.zoomTransform(this);
// selects and transforms all node divs from class 'Node' and performs transformation
d3.select(`.Node-${canvasId}`)
.style(
'transform',
'translate(' +
transform.x +
'px,' +
transform.y +
'px) scale(' +
transform.k +
')'
)
.style('transform-origin', '0 0');
}
}
@@ -0,0 +1,145 @@
# CHANGELOG
This article documents the changes from Svelvet5 to Svelvet6. It is for internal use.
## custom-nodes
https://www.svelvet.io/docs/custom-nodes/
Anchor points no longer display on nodes in isolation. This is because anchor points are no longer tied to the node object.
<img src="./images/custom-nodes-before.png" width="150" height="150">
<img src="./images/custom-nodes-after.png" width="150" height="150">
## custom-edges
https://www.svelvet.io/docs/custom-edges/
Edges now use adaptive anchors by default. Previously, source/target anchors were placed on the top/bottom of the node by default. It looks nice in the specific causes when target nodes are located below source nodes, but in the general case there will be intersecting edges.
<img src="./images/custom-edges-before.png" width="150" height="150">
<img src="./images/custom-edges-after.png" width="150" height="150">
## panning and zooming
https://www.svelvet.io/docs/pan-and-zoom/
Panning and zooming is functional. Removed option to stop panning, lock nodes due to store pollution. This is simple to add back if it is a community requested feature.
TODO: need to update this text in docs:
```
play around with the flow diagram below! If you wish to stop panning, set the movement prop to false. If you wish to stop node dragging, pass in the locked prop.
```
## usage with typescript
https://www.svelvet.io/docs/typescript/
Type names changed from Node, Edge ,to UserNodeType, UserEdgeType. The main reason for this is:
- The internal state of Svelvet is stored in objects Node, Edge which are different from the specifications passed in by users (hereafter referred to as UserNode, UserEdge). For example, UserNodes have "sourcePosition" and "targetPosition" parameters. However, the internal node project does not have "sourcePosition" and "targetPosition"; instead that functionality has been abstracted to a separate Anchor class. This promotes greater code modularity.
## CSS-Background
https://www.svelvet.io/docs/CSS-Background/
<img src="./images/css-background-before.png" width="150" height="150">
<img src="./images/css-background-after.png" width="150" height="150">
No visual changes. Moved background color out of store in order to minimize store pollution. This means that background color will not be serialized using the import-export-store feature. We talk more about this later in import-export-store section.
## node-grouping
https://www.svelvet.io/docs/node-grouping/
No change in functionality.
## snap-to-grid
https://www.svelvet.io/docs/snap-to-grid/
http://localhost:3000/compatability-8-snap-to-grid/
No change in functionality
## html-docs
https://www.svelvet.io/docs/snap-to-grid/
http://localhost:3000/compatability-8-snap-to-grid/
<img src="./images/html-docs-before.png" width="150" height="150">
<img src="./images/html-docs-after.png" width="150" height="150">
no change in functionality
## node-create
https://www.svelvet.io/docs/Interactive-Nodes/
http://localhost:3000/compatibility-10-node-create/
<img src="./images/node-create-before.png" width="150" height="150">
<img src="./images/node-create-after.png" width="150" height="150">
- Previously, the new node inherits from the old node. Now, the new node is created with default parameters. Note that we have functionality to edit nodes.
- edge creation works the same as before (not shown)
- there are now 4 potential anchor points, rather than one source / on target anchor point.
## custom-svelte-components
https://www.svelvet.io/docs/Custom-Svelte/
http://localhost:3000/compatibility-11-custom-svelte-components/
<img src="./images/custom-svelte-components-before.png" width="150" height="150">
<img src="./images/custom-svelte-components-after.png" width="150" height="150">
Same functionality as before. The reason why it looks different is because I created my own dummy Svelte component to test.
## Minimap
http://localhost:3000/compatibility-12-minimap/
https://www.svelvet.io/docs/Minimap/
Same functionality as before. It looks like there is a big with HTML feature (unrelated to minimap)
<img src="./images/minimap-before.png" width="150" height="150">
<img src="./images/minimap-after.png" width="150" height="150">
## Initial zoom and location
Same functionality as Svelvet5. It appears the center of the canvas is 0,0
## node classes
<img src="./images/node-classes-before.png" width="150" height="150">
<img src="./images/node-classes-after.png" width="150" height="150">
You need to put !important in the CSS
## ImportDiagrams
https://www.svelvet.io/docs/importDiagrams/
http://localhost:3000/featureImportExport/
Feature works as expected. Note that previous version of Svelvet did not serialize callbacks (there is no general way to do this since callbacks can involve closures). We do not do serialize callbacks in Svelvet6; this includes anchors which are reset to adaptive upon serialization.
As before, any canvas-wide parameters (such as nodeCreate, backgroundColor, etc.) will not be serialized.
This feature is fragile since there is no guarantee that it will work with new features involving nodes and edges. In order to future-proof this feature, it would be best to specify that import/export of diagrams only serializes certain parameters.
## Diagram Boundary
http://localhost:3000/compatibility-15-diagramBoundary/
https://www.svelvet.io/docs/boundary/
Feature works as expected
## Iteractive Editable nodes
http://localhost:3000/compatibility-16-nodeEdit/
https://www.svelvet.io/docs/editNodes/
Feature works as expected. NodeEdit is now active by default. This is to reduce store pollution. In addition, node edit chaning width/height of nodes has been removed because this functionality is duplicated by resizableNodes
## Deletable nodes
This feature has been removed to reduce feature bloat. Its functionality is duplicated by Interactive Editable nodes feature.
@@ -0,0 +1,44 @@
# Design Patterns
Svelvet was originally written as a monolith. This increased development speed but made the code fragile. Teams struggled to implement simple features such as deleting nodes and resizing nodes. One major goal of Svelvet6 was to re-design Svelvet with more robust design patterns. This document serves as an opinionated guide towards how Svelvet should be structured.
## Each Svelvet feature should have its own folder
Svelvet has a lot of features that interact with each other. In order to encourage modularity, each feature should be given its own folder. For example, code related to the "resizableNodes" feature can be found in the folder `$lib/resizableNodes`.
## Svelvet should follow an MVC Architecture
While Svelvet is a frontend libray, it has components that interact in a complex way. As such, code should adhere to an MVC architecture to increase modularity. One useful way to think about MVC is drawing an analogy to frontend/backend/database. The frontend is the "view" of MVC, the backend is the "controller" of MVC, and the database is the "model" of MVC. To that end, within each feature folder there should be four folders: models, views, controllers, types.
- models: holds code related to the internal state of Svelvet
- controllers: holds code used to interact with the Svelvet models. Ideally, all interaction with Svelvet models/stores should take place through controllers.
- views: holds code used to visualize the Svelvet state. Ideally, views should not modify models directly.
- types: holds types/interfaces for Typescript. This is unrelated to MVC.
More comments: Explicitly labeling folders as model/view/controller increases verbosity and most projects do not do this. We feel that this increased verbosity is worth the tradeoff of reminding developers they should be following establshed design patterns when coding. If you are considering removing explicit model/view/controller folders, consider that Svelvet is an OSP where developers may have little to no prior coding experience; increased verbosity may be helpful in guiding developers towards established design principles.
## Svelvet's internal state should be an object-relational data structure
Too Long Didn't Read: The main takeaway can be summed up as: make the Svelvet store look more like PostgreSQL and less like mongoDB.
### A longer explanation
The store holds Svelvet's internal state. We urge future developers to structure the store as a relational object rather than an unstructured object. To give an analogy, if Svelvet was a full-stack app with a backend and database, you should use a postgreSQL instead of mongoDB to store the internal state of Svelvet because nodes/edges are inherently relational. We give an example of a bad design pattern and a good design pattern below.
Suppose you want to implement a "resizableNodes" feature, where users can resize nodes by dragging a node corner. One (bad) way to do this is by could do this by hacking in an extra div on the Node component representing a draggable control point, then hacking in a "resizeNode" method on the Node object so that when the control point is dragged the node is resized. While it may be easier to throw everything on an unstructured Node object, this is bad for modularity. As more and more features get added in, the Node object becomes bloated, difficult to read, and difficult to debug.
The better way to do this is to create a brand new resizableNodes model. This model should hold `nodeId` (a foreign key to a node object), `positionX` (its x-position), and `positionY` (its y-position). This model should also have a method `setPosition` that sets its x,y position, but also sets the width/height of the associated node defined by `nodeId`. Note the similarity of the process described above to adding extra data to a SQL database; a big advantage of SQL is that it is easy to add new relational data by creating a brand new table and linking with a foreign key.
By creating new objects/tables whenever adding new features, you make the code more readable, more modular, and more testable.
### Foreign keys
One question you may come up with when adding new tables is how to structure foreign keys. In our resizableNodes example, we placed a foreign key `nodeId` on our ResizableNode object/table. Alternatively, we could have placed a foreign key `ResizableNodeId` on our Node object/table. How do we choose between these two alternatives (or maybe we could even do two foreign keys)?
You can make the decision on foreign keys based on your component heirarchy. A Node can exist without functionality to resize itself, but a ResizableNode should not exist if its parent Node does not exist. Therefore, you should place a `nodeId` foreign key on ResizableNode.
This decision has the following advantages:
- Increased readability: When developers are reading the Node class definition, they are not overwhelmed by all the different Svelvet features. On the other hand, when developers are reading the ResizableNode class definition, they should realize that ResizableNodes are children of Nodes.
- Increased modularity: You can remove a ResizableNode without disturbing core Node functionality.
- Easy deletes: Previous teams struggled with implementing delete functionality. When you think about a SQL database, deleting rows is very simple. You simply delete the row, then specify cascade to delete other rows that reference the primary key of the row you deleted. Keeping a disciplined approach when structuring foreign keys makes the entity hierarchies clear, and makes operations such as delete easy to implement.
@@ -0,0 +1,5 @@
# Documentation
- Documentation should be written assuming the developer working on Svelvet has coding experience equivalent to six weeks of bootcamp.
- If you write documents in tsDoc format, you can use TypeDoc to generate documentation automatically. For instructions on how to use TypeDoc, see Tutorials.md
- Write READMEs for every feature so that future teams can understand how they work. Put your email down if you want to be a resource for future teams.
@@ -0,0 +1,34 @@
# README
This README provides suggestions to developers working on Svelvet.
## What is Svelvet?
Svelvet is a frontend library that allows users to programmatically create graph diagrams. Graphs are composed of nodes and edges; each edge connects two nodes. There are two main challenges when working with Svelvet: (1) nodes and edges interact in complex ways, making new features difficult to implement without interfering with old features, and (2) Svelvet has an active userbase, making breaking changes undesirable.
## Things to think about as a Svelvet developer
- Svelvet teams have 3.5 weeks to iterate on the project, less if you consider time spent on marketing/deployment. It is important for code to be readable, otherwise future teams will be unable to understand the codebase within a reasonable timeframe.
- It is important to write documentation that is easy understand. Write comments/documentation assuming that developers have ~6 weeks of prior bootcamp experience.
- Writing non-modular code with zero tests and zero documentation increases technical debt and puts future teams in a bad place. Accumulated technical debt can kill projects.
- Svelvet has an active userbase. When possible, breaking changes should be avoided. However, if a breaking change must occur, it is better that it happen sooner rather than later.
## Suggestions
- Write modular code, separated by feature. For suggestions, see `./DESIGN_PATTERNS.md`
- Write tests. Svelvet components interact with each other in complex ways, making it difficult to predict whether changes will break Svelvet without tests.
- Writing tests and documentation is good for your resume. A long list of features by itself makes for poor resume; mentioning testing, documentation, and specific technologies used to implement specific features make for a stronger resume.
- Don't leave typescript warnings unaddressed. This makes it so much easier to debug.
## Where to start
Here is one way to start understanding the Svelvet codebase
(1) Read the Node class (`$lib/nodes/models/Node.ts`)
(2) Create a new branch, delete all features except for nodes, containers, and store, then try to get Svelvet to running only rendering nodes to the screen. You can test Svelvet using routes such as `testingplayground` at `http://localhost:3000/testingplayground`.
(3) Try to refactor Node.ts. You may notice that Node.ts has 16 fields, when really only six of them are important (id, canvasId, positionX, positionY, widthX, widthY). Create a new table `NodeAttributes` that links to Node via foreign key, move all attributes (bgColor, textColor, etc) to this new table and get Node rendering again.
(4) After understanding how Node works, add back the "edges" folder. Try to get Svelvet working rendering only nodes and edges to the screen.
(5) Node / Edge / Anchor form the core tables of Svelvet. All other feature build on top of these core tables.
@@ -0,0 +1,14 @@
## custom node functionality should be refactored into its own table.''
## tay10alan60@gmail.com - minimap, d3, node deletion, interactiveNodes
## dillan - customSvelete components, deletable nodes, node classes
## Minimap 75 boundless, 200 bounded
## New team:
- split up the website and the svelvet npm package
- continue store refactoring
- refactor store dispatch into flux architecture
- testing - 1 full iteration
@@ -0,0 +1,25 @@
# Tutorials
This file contains instructions on how to do useful things such as generating developer documentation, publishing to npm, etc.
## TypeDoc
You may notice TSDoc comments throughout our code. These TSDoc comments can be converted into documentation using TypeDoc:
```
npx typedoc --entryPointStrategy expand src/lib
```
This command will create documentation in root folder `./docs`.
## Publishing to npm
- create an account on npm.js. You can skip this step if you already have an account
- log in with `$ npm login`
- Within the `src/lib` directory, type `npm version patch` to increment version number. Or if you want a new name, you can modify `src/lib/package.json` directly
- In the base directory, type `npm run package`. This will use svelte-kit's package feature to create an npm package in `./package`.
- Within the `./package` directory, type `npm publish` to publish to npm. Note that you cannot "overwrite" previous publishes, you must increment the version number
## Testing npm package
- install locally with `npm install svelvet-lime@latest -f`, where svelvet-lime is replaced with whatever you named your package. Note that if you have a previously installed version of svelvet, you must force a re-install otherwise you will be using an outdated npm package.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,170 @@
import { rightCb, leftCb, topCb, bottomCb } from './anchorCbUser'; // these are callbacks used to calculate anchor position relative to node
import type { AnchorCbType } from '../../edges/types/types';
import type { StoreType } from '../../store/types/types';
import { getPotentialAnchorById } from '../../interactiveNodes/controllers/util';
import { getAnchors } from './util';
import { getNodeById } from '../../nodes/controllers/util';
export function fixedCbCreator(
store: StoreType,
edgeId: string,
anchorId: string,
userNodeId: string,
positionCb: Function // positionCb should be a function that takes 4 arguments (x,y,width,height) and returns a 3-array [x,y,angle] that represents the x,y position of the anchor as well as it's angle with respect to it's node.
) {
const rn = <AnchorCbType>fixedCb;
rn.type = 'fixed';
return rn;
function fixedCb() {
// get the two anchors
const anchors = getAnchors(store, { edgeId: edgeId });
if (anchors.length !== 2)
throw `there should be two anchors per edge, have: ${anchors.length}`;
let [anchorSelf, anchorOther] = anchors;
if (anchorSelf.id !== anchorId)
[anchorSelf, anchorOther] = [anchorOther, anchorSelf];
const node = getNodeById(store, userNodeId);
const { positionX, positionY, width, height } = node;
const [x, y, angle] = positionCb(positionX, positionY, width, height);
anchorSelf.positionX = x;
anchorSelf.positionY = y;
anchorSelf.angle = angle;
// update the other anchor if it is a dynamic anchor
// Note dyanamic anchor callbacks have a check that prevents an infinite loop
if (anchorOther.callback.type === 'dynamic') anchorOther.callback();
}
}
export function dynamicCbCreator(
store: StoreType,
edgeId: string,
anchorId: string
) {
const rn = <AnchorCbType>dynamicCb;
rn.type = 'dynamic';
return rn;
function dynamicCb() {
// get the two anchors
const anchors = getAnchors(store, { edgeId: edgeId });
if (anchors.length !== 2)
throw `there should be two anchors per edge, have: ${anchors.length}`;
let [anchorSelf, anchorOther] = anchors;
if (anchorSelf.id !== anchorId)
[anchorSelf, anchorOther] = [anchorOther, anchorSelf];
// get the two nodes
const nodeSelf = getNodeById(store, anchorSelf.nodeId);
const nodeOther = getNodeById(store, anchorOther.nodeId);
// get the midpoints
const [xSelf, ySelf, xOther, yOther] = [
nodeSelf.positionX + nodeSelf.width / 2,
nodeSelf.positionY + nodeSelf.height / 2,
nodeOther.positionX + nodeOther.width / 2,
nodeOther.positionY + nodeOther.height / 2,
];
// record angle for later. We use this so we don't have an infinite loop
let prevAngle = anchorSelf.angle;
// calculate the slope
const slope = (ySelf - yOther) / (xSelf - xOther);
// slope<1 means -45 to 45 degrees so left/right anchors
if (Math.abs(slope) < 1) {
// self node is on the left, other node is on the right
if (nodeSelf.positionX < nodeOther.positionX) {
const [selfX, selfY] = rightCb(
nodeSelf.positionX,
nodeSelf.positionY,
nodeSelf.width,
nodeSelf.height
);
const [otherX, otherY] = leftCb(
nodeOther.positionX,
nodeOther.positionY,
nodeOther.width,
nodeOther.height
);
anchorSelf.setPosition(selfX, selfY);
anchorSelf.angle = 0; // if the self node is on the left, the anchor should have orientation of 0 degrees on the unit circle
} else {
// in this case, the self node is on the right and the other node is on the left
const [selfX, selfY] = leftCb(
nodeSelf.positionX,
nodeSelf.positionY,
nodeSelf.width,
nodeSelf.height
);
const [otherX, otherY] = rightCb(
nodeOther.positionX,
nodeOther.positionY,
nodeOther.width,
nodeOther.height
);
anchorSelf.setPosition(selfX, selfY);
anchorSelf.angle = 180;
}
} else {
if (nodeSelf.positionY < nodeOther.positionY) {
// here the self node is above the other node
const [selfX, selfY] = bottomCb(
nodeSelf.positionX,
nodeSelf.positionY,
nodeSelf.width,
nodeSelf.height
);
const [otherX, otherY] = topCb(
nodeOther.positionX,
nodeOther.positionY,
nodeOther.width,
nodeOther.height
);
anchorSelf.setPosition(selfX, selfY);
anchorSelf.angle = 270;
} else {
const [selfX, selfY] = topCb(
nodeSelf.positionX,
nodeSelf.positionY,
nodeSelf.width,
nodeSelf.height
);
const [otherX, otherY] = bottomCb(
nodeOther.positionX,
nodeOther.positionY,
nodeOther.width,
nodeOther.height
);
anchorSelf.setPosition(selfX, selfY);
anchorSelf.angle = 90;
}
}
// if the anchor changed position, then do operation for other anchor
// otherwise, don't do anything. This check is so we don't have an infinite loop
if (prevAngle !== anchorSelf.angle) anchorOther.callback();
}
}
export function potentialAnchorCbCreator(
store: StoreType,
potentialAnchorId: string,
userNodeId: string,
positionCb: Function // positionCb should be a function that takes 4 arguments (x,y,width,height) and returns a 3-array [x,y,angle] that represents the x,y position of the anchor as well as it's angle with respect to it's node.
) {
return fixedCb;
function fixedCb() {
// get the two anchors
const potentialAnchor = getPotentialAnchorById(store, potentialAnchorId);
const node = getNodeById(store, userNodeId);
const { positionX, positionY, width, height } = node;
const [x, y, angle] = positionCb(positionX, positionY, width, height);
potentialAnchor.positionX = x;
potentialAnchor.positionY = y;
potentialAnchor.angle = angle;
// potential anchors don't have partners so we can end here
}
}
@@ -0,0 +1,98 @@
/**
* These are callbacks used to define anchor positions relative to the node they are attached to.
* These may be provided to developers as examples of how to write their own custom callbacks for adjustable anchors
* It calculates the position of an anchor (x,y) coordinates given a node parameterized by (x,y,width, height)
*/
/**
* @function rightCb - This is a callback function to define the anchor position on the node to be on the right side of the node.
* @param xNode - positionX of the attached node
* @param yNode - positionY of the attached node
* @param widthNode - width of the attached node
* @param heightNode - height of the attached node
* @returns [xAnchor, yAnchor, 0]
* xAnchor - positionX for the anchor
* yAnchor - positionY for the anchor
* 0 - this is angle of the anchor with respect to the node. The right anchor should fall at 0 degrees on the unit circle.
* @export rightCb
*/
export const rightCb = (
xNode: number,
yNode: number,
widthNode: number,
heightNode: number
) => {
const xAnchor = xNode + widthNode;
const yAnchor = yNode + heightNode / 2;
return [xAnchor, yAnchor, 0];
};
/**
* @function leftCb - This is a callback function to define the anchor position on the node to be on the left side of the node.
* @param xNode - positionX of the attached node
* @param yNode - positionY of the attached node
* @param widthNode - width of the attached node
* @param heightNode - height of the attached node
* @returns [xAnchor, yAnchor, 180]
* xAnchor - positionX for the anchor
* yAnchor - positionY for the anchor
* 180 - this is angle of the anchor with respect to the node. The left anchor should fall at 180 degrees on the unit circle.
* @export leftCb
*/
export const leftCb = (
xNode: number,
yNode: number,
widthNode: number,
heightNode: number
) => {
const xAnchor = xNode;
const yAnchor = yNode + heightNode / 2;
return [xAnchor, yAnchor, 180];
};
/**
* @function topCb - This is a callback function to define the anchor position on the node to be on the top of the node.
* @param xNode - positionX of the attached node
* @param yNode - positionY of the attached node
* @param widthNode - width of the attached node
* @param heightNode - height of the attached node
* @returns [xAnchor, yAnchor, 90]
* xAnchor - positionX for the anchor
* yAnchor - positionY for the anchor
* 90 - this is angle of the anchor with respect to the node. The top anchor should fall at 90 degrees on the unit circle.
* @export topCb
*/
export const topCb = (
xNode: number,
yNode: number,
widthNode: number,
heightNode: number
) => {
const xAnchor = xNode + widthNode / 2;
const yAnchor = yNode;
return [xAnchor, yAnchor, 90];
};
/**
* @function bottomCb - This is a callback function to define the anchor position on the node to be on the bottom of the node.
* @param xNode - positionX of the attached node
* @param yNode - positionY of the attached node
* @param widthNode - width of the attached node
* @param heightNode - height of the attached node
* @returns - [xAnchor, yAnchor, 90]
* xAnchor - positionX for the anchor
* yAnchor - positionY for the anchor
* 270 - this is angle of the anchor with respect to the node. The bottom anchor should fall at 270 degrees on the unit circle.
* @export bottomCb
*/
export const bottomCb = (
xNode: number,
yNode: number,
widthNode: number,
heightNode: number
) => {
const xAnchor = xNode + widthNode / 2;
const yAnchor = yNode + heightNode;
return [xAnchor, yAnchor, 270];
};
@@ -0,0 +1,82 @@
import type { StoreType } from '../../store/types/types';
import { writable, derived, get, readable } from 'svelte/store';
import type { AnchorType } from '../types/types';
import { getNodeById } from '../../nodes/controllers/util';
/**
* Finds all Anchors that matches the conditions specified in the filter parameter from a Svelvet store and returns these Anchors in an array.
*
* @param store The Svelvet store containing the state of a Svelvet component
* @param filter An object to specify conditions. Example: if filter = \{ sourceOrTarget: 'source', positionX: 35 \} then we will return all anchors with sourceOrTarget = source AND poxitionX = 35
* @returns An array of Anchors that matches the conditions specified in the filter parameter
*/
export function getAnchors(store: StoreType, filter?: { [key: string]: any }) {
let anchors = Object.values(get(store.anchorsStore));
// filter the array of anchors for elements that match filter
// Example: if filter = {sourceOrTarget: 'source', positionX: 35} then we will
//return all anchors with sourceOrTarget = source AND poxitionX = 35
if (filter !== undefined) {
anchors = anchors.filter((anchor) => {
for (let filterKey in filter) {
const filterValue = filter[filterKey];
if (anchor[filterKey as keyof AnchorType] !== filterValue) return false;
}
return true;
});
}
// return list of anchors
return anchors;
}
/**
* Gets one anchor (source anchor or target anchor) from a given edge
*
* @param store The Svelvet store containing the state of the Svelvet component
* @param edgeId The id of a given edge
* @param sourceOrTarget A string of 'source' or 'target' to specify which anchor the function should return
* @returns The source or target Anchor object of a given edge
*/
export function getAnchorFromEdge(
store: StoreType,
edgeId: string,
sourceOrTarget: 'source' | 'target'
): AnchorType {
const edge = getEdgeById(store, edgeId);
const anchors = getAnchors(store, { edgeId: edgeId });
if (anchors.length !== 2)
throw `there should be two anchors for a given edge, there are ${anchors.length}`;
// there should be one source anchor and one target anchor. Return the source anchor
const anchor = anchors.filter(
(anchor) => anchor.sourceOrTarget === sourceOrTarget
);
if (anchor.length !== 1)
throw `there should only be one source/target anchor`;
return anchor[0];
}
/**
* getEdgeById will look for the targeted Edge that has the same id provided in the Svelvet component store.
*
* @param store The Svelvet store containing the state of a Svelvet component
* @param id The id of the targeted Node
* @returns The targeted Edge object in store.edgesStore
*/
export function getEdgeById(store: StoreType, id: string) {
const edgesStore = get(store.edgesStore);
const edge = edgesStore[id];
if (edge === undefined) throw 'edge not found';
return edge;
}
/**
* getAnchorById will look for the targeted Anchor that has the same id in the Svelvet component store.
*
* @param store The Svelvet store containing the state of a Svelvet component
* @param id The id of the targeted Anchor
* @returns The target Anchor object in store.anchorsStore
*/
export function getAnchorById(store: StoreType, id: string) {
const anchorsStore = get(store.anchorsStore);
const anchor = anchorsStore[id];
return anchor;
}
@@ -0,0 +1,119 @@
/**
* This is where we create, update the Anchor store.
*/
import type { AnchorCbType, AnchorType } from '../../edges/types/types';
import { stores } from '../../store/models/store';
import { getAnchors } from '../controllers/util';
/** Class representing an Anchor that implements Anchortype.
* @param {string} id The id of the Anchor
* @param {string} nodeId The id of the Node which the instantiated Anchor will be attached to
* @param {string} edgeId The id of the Edge which connects to the instantiated Anchor
* @param {'source' | 'target'} sourceOrTarget Specify the Anchor is a source or target
* @param {number} positionX The 'X' coordinate of the Anchor
* @param {number} positionY The 'Y' coordinate of the Anchor
* @param {function} callback The callback function that will determine the position of this Anchor
* @param {string} canvasId The canvasId of the Svelvet component that will hold this Anchor
* @param { number } angle This is the orientation of the anchor and is used to make sure bezier/step curves are rendered perpendicular to the node. Angles are defined along the unit circle. EX: 0 = right side of node, 180 = left side of node.
*/
export class Anchor implements AnchorType {
constructor(
public id: string,
public nodeId: string,
public edgeId: string,
public sourceOrTarget: 'source' | 'target',
public positionX: number,
public positionY: number,
public callback: AnchorCbType,
public canvasId: string,
public angle: 0 | 90 | 180 | 270
) {}
getOtherAnchorId() {
const store = stores[this.canvasId];
const anchors = getAnchors(store, { edgeId: this.edgeId });
if (anchors.length !== 2)
throw 'something is wrong, every anchor should have exactly one other associated anchor';
const anchor = anchors.filter((anchor) => anchor.id !== this.id)[0];
return anchor.id;
}
/**
* setPositionFromNode will invoke the user-defined callback to calculate the position of the Anchor, set the position of the Anchor in the anchorsStore, and also update the Edge position accordingly.
*/
setPositionFromNode() {
// calculate the new position of the anchor using user-defined callback
this.callback();
const { edgesStore } = stores[this.canvasId];
// update edges
edgesStore.update((edges) => {
const edge = edges[this.edgeId];
if (this.sourceOrTarget === 'source') {
edge.sourceX = this.positionX;
edge.sourceY = this.positionY;
} else {
edge.targetX = this.positionX;
edge.targetY = this.positionY;
}
return { ...edges };
});
}
/**
* updateEdges will update the edgesStore based on Anchor's change.
*/
updateEdges() {
const { edgesStore } = stores[this.canvasId];
/** update edges by deconstructing edges store and setting its new vlue to stores[this.canvasID] */
edgesStore.update((edges) => {
const edge = edges[this.edgeId];
// this means that no edge was found, just return without doing anything
if (edge === undefined) {
return { ...edges };
}
if (this.sourceOrTarget === 'source') {
edge.sourceX = this.positionX;
edge.sourceY = this.positionY;
} else {
edge.targetX = this.positionX;
edge.targetY = this.positionY;
}
return { ...edges };
});
}
/**
* setPosition will update the positionX and positionY of the Anchor and also cascade changes to related Edge.
* @param x The X coordinate of the new position for the Anchor
* @param y The Y coordinate of the new position for the Anchor
*/
setPosition(x: number, y: number) {
this.positionX = x;
this.positionY = y;
this.updateEdges();
}
/**
* Anchor.setPositionFromMovement works similarly to Anchor.setPosition. But setPosition is more powerful and we recommend using setPosition whenever possible and in the future, setPositionFromMovement can be removed.
* @param movementX The mouse movement value on the X-axis
* @param movementY The mouse movement value on the Y-axis
*/
setPositionFromMovement(movementX: number, movementY: number) {
this.positionX += movementX;
this.positionY += movementY;
const { edgesStore } = stores[this.canvasId];
edgesStore.update((edges) => {
const edge = edges[this.edgeId];
if (this.sourceOrTarget === 'source') {
edge.sourceX += movementX;
edge.sourceY += movementY;
} else {
edge.targetX += movementX;
edge.targetY += movementY;
}
return { ...edges };
});
}
}
@@ -0,0 +1,97 @@
import { findStore } from '../../store/controllers/storeApi';
import type { UserEdgeType } from '../../types/types';
import type { EdgeType } from '../../store/types/types';
import { stores } from '../../store/models/store';
import { getAnchors, getAnchorFromEdge } from '../../edges/controllers/util';
/**
* Class Edge that implements EdgeType.
* @param id The id of the Edge
* @param sourceX The X coordinate of the source Anchor
* @param sourceY The Y coordinate of the source Anchor
* @param targetX The X coordinate of the target Anchor
* @param targetY The Y coordinate of the target Anchor
* @param canvasId The canvasId of the Svelvet component that holds the instantiated Edge
* @param label The label of the Edge
* @param type The type of the Edge (options: 'straight', 'smoothstep', 'step', or 'bezier'). If user doesn't specify, the type will default to 'bezier'.
* @param labelBgColor The background color of the Edge label
* @param labelTextColor The text color of the Edge label
* @param edgeColor The color of the Edge
* @param animate Boolean value to specify whether the Edge should be animated
* @param noHandle Boolean value but looks like it is already depracated and can be removed without damage
* @param arraw Boolean value to specify whether the Edge displays an arrow near its target Anchor
*/
export class Edge implements EdgeType {
constructor(
public id: string,
public sourceX: number,
public sourceY: number,
public targetX: number,
public targetY: number,
public canvasId: string,
public label: string,
public type: 'straight' | 'smoothstep' | 'step' | 'bezier',
public labelBgColor: string,
public labelTextColor: string,
public edgeColor: string,
public animate: boolean,
public noHandle: boolean,
public arrow: boolean,
public clickCallback: Function,
public className: string
) {}
/**
* delete is going to delete the Edge and also delete associated Anchors
*/
delete() {
const store = stores[this.canvasId];
const { nodesStore, anchorsStore, edgesStore } = store;
const sourceAnchor = getAnchorFromEdge(store, this.id, 'source'); // this is a bit wasteful
const targetAnchor = getAnchorFromEdge(store, this.id, 'target');
anchorsStore.update((anchors) => {
for (const anchorId in anchors) {
if (anchorId === sourceAnchor.id || anchorId == targetAnchor.id)
delete anchors[anchorId];
}
return { ...anchors };
});
edgesStore.update((edges) => {
delete edges[this.id];
return { ...edges };
});
}
/**
* setExportableData will construct an object that holds all the edge data that can be exported. This is needed for the Exporting Diagram feature.
* @returns The object of exportable edge data. The format of the object should be as close as what user initially passes in to Svelvet.
*/
setExportableData() {
const exportableData: UserEdgeType = {
id: this.id,
label: this.label,
type: this.type,
labelBgColor: this.labelBgColor,
labelTextColor: this.labelTextColor,
edgeColor: this.edgeColor,
animate: this.animate,
noHandle: this.noHandle,
arrow: this.arrow,
source: 'dummy', // these will be set later
target: 'dummy', // these will be set later
};
// set source, target on exportableData
const store = findStore(this.canvasId);
const anchors = getAnchors(store, { edgeId: this.id });
if (anchors.length !== 2) throw 'there should be two anchors per edge';
for (const anchor of anchors) {
if (anchor.sourceOrTarget === 'target')
exportableData.target = anchor.nodeId;
if (anchor.sourceOrTarget === 'source')
exportableData.source = anchor.nodeId;
}
return exportableData;
}
}
@@ -0,0 +1,19 @@
export type AnchorCbType = {
(): void;
type: 'dynamic' | 'fixed';
};
export interface AnchorType {
id: string; // note that the user never specifies an anchor and they are generated dynamically. id will be a random string.
nodeId: string;
edgeId: string;
sourceOrTarget: 'source' | 'target';
positionX: number;
positionY: number;
callback: AnchorCbType; // callback is used to calculate positionX, positionY based on parent node's data, and set the anchor position // TODO: rename to something better
angle: number;
setPositionFromNode: Function;
setPosition: Function;
updateEdges: Function;
getOtherAnchorId: Function;
}
@@ -0,0 +1,109 @@
<script lang="ts">
import { findStore } from '../../../store/controllers/storeApi'
import { getEdgeById } from '../../../edges/controllers/util'
import EdgeText from '../Edges/EdgeText.svelte'
import { get } from 'svelte/store'
import type { EdgeProps } from '../Edges/types'
export let baseEdgeProps: EdgeProps
export let canvasId
// destructuring the props passed in from the parent component
$: ({ path, animate, arrow, label, labelBgColor, labelTextColor, edgeColor, centerX, centerY } =
baseEdgeProps)
// setting edge text props
$: edgeTextProps = {
label: label,
labelBgColor: labelBgColor,
labelTextColor: labelTextColor,
centerX: centerX,
centerY: centerY
}
// Click event handlers
// At some point in the future, it would be good to refactor event handling to use the flux architecture
// ie, events will create an action that will be dispatched to some centralized reducer.
// or in other words, the creators of Redux knew what they were doing.
// The advantage of this re-design would be greater modularity; views would be agnostic to the exact features implmemented,
// and they would be only responsible to detecting events and dispatch actions.
const edgeId = baseEdgeProps.id
const store = findStore(canvasId)
const edge = getEdgeById(store, edgeId)
const highlightEdgesOption = get(store.highlightEdgesOption)
const handleRightClick = () => {
const store = findStore(canvasId)
const { editableOption } = store
// handles edgeEdit feature
if (get(editableOption)) store.edgeEditModal.set(edgeId)
}
const handleClick = () => {
const store = findStore(canvasId)
const edge = getEdgeById(store, edgeId)
// handles edge clickCallback feature
if (edge.clickCallback) edge.clickCallback(edge)
console.log(edge.className)
}
const defaultArrow = `0 0, 9 4.5, 0 9`
</script>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="8" refY="4" orient="auto">
<polygon points={defaultArrow} fill="gray" />
</marker>
</defs>
<!-- This is an invisible edge that is used to implement event events, because the visible edge is thin and hard to click on. It
highlights on hover -->
{#if highlightEdgesOption}
<path
id={`edgeSelector`}
d={path}
fill="transparent"
stroke={'red'}
stroke-opacity="0"
stroke-width="10"
on:contextmenu={handleRightClick}
on:click={handleClick}
on:keypress={() => {}}
/>
{/if}
{#if arrow}
<path
class={animate ? `animate ${edge.className}` : `${edge.className}`}
d={path}
fill="transparent"
stroke={edgeColor ? edgeColor : 'gray'}
marker-end="url(#arrow)"
aria-label="svg-path"
/>
{:else}
<path
class={animate ? `animate ${edge.className}` : `${edge.className}`}
d={path}
fill="transparent"
stroke={edgeColor ? edgeColor : 'gray'}
aria-label="svg-path"
/>
{/if}
{#if edgeTextProps.label}
<EdgeText {edgeTextProps} />
{/if}
<style>
.animate {
stroke-dasharray: 5;
animation: dash 50000s linear;
}
@keyframes dash {
from {
stroke-dashoffset: 1000000;
}
}
#edgeSelector:hover {
stroke: 'red';
stroke-opacity: 0.5;
}
</style>
@@ -0,0 +1,14 @@
<script lang="ts">
export let x: number
export let y: number
</script>
<!-- renders simple half-circle for the anchor point of the edge -->
<circle cx={x} cy={y} r={5} stroke="white" fill="black" />
<style>
circle {
position: absolute;
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { EdgeTextProps } from './types';
// destructuring props to pass into BaseEdge component
export let edgeTextProps: EdgeTextProps;
$: ({ label, labelBgColor, labelTextColor, centerX, centerY } =
edgeTextProps);
const shiftRectY: number = 7;
$: pxRatio = label.length < 3 ? 9 : 7;
// determine the center point of the edge to be used in the EdgeText component
$: textCenterX = centerX;
$: textCenterY = centerY;
// determine width of rect to render based on label.length (removing spaces)
// pxRatio is an estimate of how many pixels 1 character might take up
// pxRatio not 100% accurate as font is not monospace
$: spaces = label.split(' ').length - 1;
$: newLength = label.length - spaces;
$: labelPx = newLength * pxRatio;
</script>
{#if typeof label === 'undefined' || !label}
{null}
{:else}
<g>
<rect
class="EdgeTextBg"
data-testid="edge-text-bg"
fill={labelBgColor ? labelBgColor : 'white'}
x={textCenterX - labelPx / 2}
y={textCenterY - shiftRectY}
width={labelPx}
height={16}
/>
<text
class="EdgeText"
x={textCenterX}
y={textCenterY}
font-size="12px"
dominant-baseline="central"
text-anchor="middle"
fill={labelTextColor ? labelTextColor : 'black'}
>
{label}
</text>
</g>
{/if}
@@ -0,0 +1,159 @@
<script lang="ts">
import BaseEdge from './BaseEdge.svelte'
// import { Position } from '../types/utils';
// // enumerable values (static) set for Position
// export var Position;
// (function (Position) {
// Position["Left"] = "left";
// Position["Right"] = "right";
// Position["Top"] = "top";
// Position["Bottom"] = "bottom";
// })(Position || (Position = {}));
// //
// // export type CoordinateExtent = [[number, number], [number, number]];
const Position = { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }
import { findStore } from '../../../store/controllers/storeApi'
import { getAnchorFromEdge } from '../../../edges/controllers/util'
function calculateControlOffset(distance, curvature) {
if (distance >= 0) {
return 0.5 * distance
} else {
return curvature * 25 * Math.sqrt(-distance)
}
}
// get the control point for the bezier curve (in the middle of the edge)
function getControlWithCurvature({ pos, x1, y1, x2, y2, c }) {
let ctX, ctY
switch (pos) {
case Position.Left:
{
ctX = x1 - calculateControlOffset(x1 - x2, c)
ctY = y1
}
break
case Position.Right:
{
ctX = x1 + calculateControlOffset(x2 - x1, c)
ctY = y1
}
break
case Position.Top:
{
ctX = x1
ctY = y1 - calculateControlOffset(y1 - y2, c)
}
break
case Position.Bottom:
{
ctX = x1
ctY = y1 + calculateControlOffset(y2 - y1, c)
}
break
}
return [ctX, ctY]
}
// returns string to pass into edge 'path' svg d attribute (where to be drawn)
// referenced from ReactFlow.dev
function getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
curvature = 0.25
}) {
const [sourceControlX, sourceControlY] = getControlWithCurvature({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
c: curvature
})
const [targetControlX, targetControlY] = getControlWithCurvature({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
c: curvature
})
return `M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`
}
// determining center of the bezier curve to know where to place the bezier edge text label
function getSimpleBezierCenter({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
curvature = 0.25
}) {
const [sourceControlX, sourceControlY] = getControlWithCurvature({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
c: curvature
})
const [targetControlX, targetControlY] = getControlWithCurvature({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
c: curvature
})
// cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate
// https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve
const centerX =
sourceX * 0.125 + sourceControlX * 0.375 + targetControlX * 0.375 + targetX * 0.125
const centerY =
sourceY * 0.125 + sourceControlY * 0.375 + targetControlY * 0.375 + targetY * 0.125
const xOffset = Math.abs(centerX - sourceX)
const yOffset = Math.abs(centerY - sourceY)
return [centerX, centerY, xOffset, yOffset]
}
export let canvasId: string
export let edgeId: string
const store = findStore(canvasId)
const { nodesStore, edgesStore, anchorsStore } = store
let edge
$: edge = $edgesStore[edgeId]
let params
$: {
const store = findStore(canvasId)
const sourceAnchor = getAnchorFromEdge(store, edge.id, 'source')
const targetAnchor = getAnchorFromEdge(store, edge.id, 'target')
const mapAngle = { 0: 'right', 90: 'top', 180: 'left', 270: 'bottom' }
params = {
sourceX: edge.sourceX,
sourceY: edge.sourceY,
sourcePosition: mapAngle[sourceAnchor.angle],
targetX: edge.targetX,
targetY: edge.targetY,
targetPosition: mapAngle[targetAnchor.angle],
curvature: 0.25
}
}
// pass in params to function that returns a string value for SVG path d attribute (where to be drawn)
$: path = getSimpleBezierPath(params)
$: [centerX, centerY] = getSimpleBezierCenter(params)
// pass necessary values to BaseEdge component
// BaseEdge renders a 'base' path that can be customized by parent Edge components
$: baseEdgeProps = {
...edge,
path: path,
centerX: centerX,
centerY: centerY
}
</script>
<BaseEdge {baseEdgeProps} {canvasId} />
@@ -0,0 +1,176 @@
<script>
import BaseEdge from './BaseEdge.svelte';
import { getCenter } from './utils';
const Position = {};
Position['Left'] = 'left';
Position['Right'] = 'right';
Position['Top'] = 'top';
Position['Bottom'] = 'bottom';
// These are some helper methods for drawing the round corners
// The name indicates the direction of the path. "bottomLeftCorner" goes
// from bottom to the left and "leftBottomCorner" goes from left to the bottom.
// We have to consider the direction of the paths because of the animated lines.
const bottomLeftCorner = (x, y, size) =>
`L ${x},${y - size}Q ${x},${y} ${x + size},${y}`;
const leftBottomCorner = (x, y, size) =>
`L ${x + size},${y}Q ${x},${y} ${x},${y - size}`;
const bottomRightCorner = (x, y, size) =>
`L ${x},${y - size}Q ${x},${y} ${x - size},${y}`;
const rightBottomCorner = (x, y, size) =>
`L ${x - size},${y}Q ${x},${y} ${x},${y - size}`;
const leftTopCorner = (x, y, size) =>
`L ${x + size},${y}Q ${x},${y} ${x},${y + size}`;
const topLeftCorner = (x, y, size) =>
`L ${x},${y + size}Q ${x},${y} ${x + size},${y}`;
const topRightCorner = (x, y, size) =>
`L ${x},${y + size}Q ${x},${y} ${x - size},${y}`;
const rightTopCorner = (x, y, size) =>
`L ${x - size},${y}Q ${x},${y} ${x},${y + size}`;
// returns string to pass into edge 'path' svg d attribute (where to be drawn)
export function getSmoothStepPath({
sourceX,
sourceY,
sourcePosition = Position.Bottom,
targetX,
targetY,
targetPosition = Position.Top,
borderRadius = 5,
centerX,
centerY,
}) {
const [_centerX, _centerY, offsetX, offsetY] = getCenter({
sourceX,
sourceY,
targetX,
targetY,
});
const cornerWidth = Math.min(borderRadius, Math.abs(targetX - sourceX));
const cornerHeight = Math.min(borderRadius, Math.abs(targetY - sourceY));
const cornerSize = Math.min(cornerWidth, cornerHeight, offsetX, offsetY);
const leftAndRight = [Position.Left, Position.Right];
const cX = typeof centerX !== 'undefined' ? centerX : _centerX;
const cY = typeof centerY !== 'undefined' ? centerY : _centerY;
let firstCornerPath = null;
let secondCornerPath = null;
// for non-mixed edge top/bottom
if (sourceX <= targetX) {
firstCornerPath =
sourceY <= targetY
? bottomLeftCorner(sourceX, cY, cornerSize)
: topLeftCorner(sourceX, cY, cornerSize);
secondCornerPath =
sourceY <= targetY
? rightTopCorner(targetX, cY, cornerSize)
: rightBottomCorner(targetX, cY, cornerSize);
} else {
firstCornerPath =
sourceY < targetY
? bottomRightCorner(sourceX, cY, cornerSize)
: topRightCorner(sourceX, cY, cornerSize);
secondCornerPath =
sourceY < targetY
? leftTopCorner(targetX, cY, cornerSize)
: leftBottomCorner(targetX, cY, cornerSize);
}
// for non-mixed edge left/right
if (
leftAndRight.includes(sourcePosition) &&
leftAndRight.includes(targetPosition)
) {
if (sourceX <= targetX) {
firstCornerPath =
sourceY <= targetY
? rightTopCorner(cX, sourceY, cornerSize)
: rightBottomCorner(cX, sourceY, cornerSize);
secondCornerPath =
sourceY <= targetY
? bottomLeftCorner(cX, targetY, cornerSize)
: topLeftCorner(cX, targetY, cornerSize);
} else if (
(sourcePosition === Position.Right &&
targetPosition === Position.Left) ||
(sourcePosition === Position.Left &&
targetPosition === Position.Right) ||
(sourcePosition === Position.Left && targetPosition === Position.Left)
) {
// and sourceX > targetX
firstCornerPath =
sourceY <= targetY
? leftTopCorner(cX, sourceY, cornerSize)
: leftBottomCorner(cX, sourceY, cornerSize);
secondCornerPath =
sourceY <= targetY
? bottomRightCorner(cX, targetY, cornerSize)
: topRightCorner(cX, targetY, cornerSize);
}
// for mixed edges (top/bottom to left/right) OR (left/right to top/bottom)
} else if (
leftAndRight.includes(sourcePosition) &&
!leftAndRight.includes(targetPosition)
) {
if (sourceX <= targetX) {
firstCornerPath =
sourceY <= targetY
? rightTopCorner(targetX, sourceY, cornerSize)
: rightBottomCorner(targetX, sourceY, cornerSize);
} else {
firstCornerPath =
sourceY <= targetY
? leftTopCorner(targetX, sourceY, cornerSize)
: leftBottomCorner(targetX, sourceY, cornerSize);
}
secondCornerPath = '';
} else if (
!leftAndRight.includes(sourcePosition) &&
leftAndRight.includes(targetPosition)
) {
if (sourceX <= targetX) {
firstCornerPath =
sourceY <= targetY
? bottomLeftCorner(sourceX, targetY, cornerSize)
: topLeftCorner(sourceX, targetY, cornerSize);
} else {
firstCornerPath =
sourceY <= targetY
? bottomRightCorner(sourceX, targetY, cornerSize)
: topRightCorner(sourceX, targetY, cornerSize);
}
secondCornerPath = '';
}
return `M ${sourceX},${sourceY}${firstCornerPath}${secondCornerPath}L ${targetX},${targetY}`;
}
export let edge;
export let borderRadius = 5;
export let canvasId;
import { findStore } from '../../../store/controllers/storeApi';
import { getAnchorFromEdge } from '../../../edges/controllers/util';
let params;
$: {
const store = findStore(canvasId);
const sourceAnchor = getAnchorFromEdge(store, edge.id, 'source');
const targetAnchor = getAnchorFromEdge(store, edge.id, 'target');
const mapAngle = { 0: 'right', 90: 'top', 180: 'left', 270: 'bottom' };
params = {
sourceX: edge.sourceX,
sourceY: edge.sourceY,
targetX: edge.targetX,
targetY: edge.targetY,
sourcePosition: mapAngle[sourceAnchor.angle],
targetPosition: mapAngle[targetAnchor.angle],
borderRadius: borderRadius,
};
}
$: [centerX, centerY] = getCenter(params);
$: path = getSmoothStepPath(params);
$: baseEdgeProps = {
...edge,
path: path,
centerX: centerX,
centerY: centerY,
};
</script>
<BaseEdge {baseEdgeProps} {canvasId} />
@@ -0,0 +1,8 @@
<script>
import SmoothStepEdge from './SmoothStepEdge.svelte';
export let edge;
export let canvasId;
</script>
<!-- Utilizes the SmoothStepEdge Component and sets borderRadius to zero to give a sharp edge -->
<SmoothStepEdge {edge} borderRadius={0} {canvasId} />
@@ -0,0 +1,58 @@
import type { XYPosition, Position } from './utils';
export interface Node<T = any> {
id: number;
position: XYPosition;
data: T;
width: number;
height: number;
bgColor?: string;
fontSize?: number;
borderColor?: string;
borderRadius?: number;
textColor?: string;
clickCallback?: Function;
image?: boolean;
src?: string;
sourcePosition?: 'left' | 'right' | 'top' | 'bottom';
targetPosition?: 'left' | 'right' | 'top' | 'bottom';
}
export interface Edge {
id: string;
source: number;
target: number;
label?: string;
labelBgColor?: string;
labelTextColor?: string;
edgeColor?: string;
type?: string;
animate?: boolean;
noHandle?: boolean;
arrow?: boolean;
}
export interface DerivedEdge extends Edge {
sourceX: number;
sourceY: number;
sourcePosition: Position;
targetX: number;
targetY: number;
targetPosition: Position;
}
export interface EdgeProps extends DerivedEdge {
path: string;
centerX: number;
centerY: number;
}
export interface EdgeTextProps {
label?: any;
labelBgColor?: string;
labelTextColor?: string;
centerX: number;
centerY: number;
}
export type HandleType = 'source' | 'target';
@@ -0,0 +1,84 @@
// enumerable values (static) set for Position
export enum Position {
Left = 'left',
Right = 'right',
Top = 'top',
Bottom = 'bottom',
}
// interface for XYPosition to use in nodes and edges
export interface XYPosition {
x: number;
y: number;
}
// type for z axis positioning with D3
export type XYZPosition = XYPosition & { z: number };
// interface for changing dimensions of Viewport
export interface Dimensions {
width: number;
height: number;
}
// interface of Rect divs in zoompane
export interface Rect extends Dimensions, XYPosition {}
// interface of Box using XYPosition of nodes
export interface Box extends XYPosition {
x2: number;
y2: number;
}
// D3 type array for Transform
export type Transform = [number, number, number];
//
// export type CoordinateExtent = [[number, number], [number, number]];
export interface GetCenterParams {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition?: Position;
targetPosition?: Position;
}
//needed for getCenter funciotn
const LeftOrRight = [Position.Left, Position.Right];
//used to determine the position for edge text on a Smooth or Step Edge
export const getCenter = ({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Bottom,
targetPosition = Position.Top,
}: GetCenterParams): [number, number, number, number] => {
const sourceIsLeftOrRight = LeftOrRight.includes(sourcePosition);
const targetIsLeftOrRight = LeftOrRight.includes(targetPosition);
// we expect flows to be horizontal or vertical (all handles left or right respectively top or bottom)
// a mixed edge is when one the source is on the left and the target is on the top for example.
const mixedEdge =
(sourceIsLeftOrRight && !targetIsLeftOrRight) ||
(targetIsLeftOrRight && !sourceIsLeftOrRight);
if (mixedEdge) {
const xOffset = sourceIsLeftOrRight ? Math.abs(targetX - sourceX) : 0;
const centerX = sourceX > targetX ? sourceX - xOffset : sourceX + xOffset;
const yOffset = sourceIsLeftOrRight ? 0 : Math.abs(targetY - sourceY);
const centerY = sourceY < targetY ? sourceY + yOffset : sourceY - yOffset;
return [centerX, centerY, xOffset, yOffset];
}
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
return [centerX, centerY, xOffset, yOffset];
};
@@ -0,0 +1,153 @@
<script lang="ts">
import { findStore } from '../../store/controllers/storeApi';
import { writable, derived, get, readable } from 'svelte/store';
import { getEdgeById } from '../../edges/controllers/util';
export let edgeId: string;
export let canvasId: string;
let label;
let width;
let height;
let customClass;
let backgroundColor;
let type: 'straight' | 'smoothstep' | 'step' | 'bezier';
const store = findStore(canvasId);
const canvasWidth = get(store.widthStore);
const canvasHeight = get(store.widthStore);
const edge = getEdgeById(store, edgeId);
const { edgesStore, nodeSelected } = store;
function closeModal() {
const store = findStore(canvasId);
store.edgeEditModal.set(null);
}
function handleDelete() {
const store = findStore(canvasId);
const edge = getEdgeById(store, edgeId);
edge.delete();
closeModal();
}
function handleSubmit(e) {
e.preventDefault();
const store = findStore(canvasId);
const edge = getEdgeById(store, edgeId);
if (label) edge.label = label;
if (backgroundColor) edge.edgeColor = backgroundColor;
if (type) edge.type = type;
// width = '';
// height = '';
// customClass = '';
// label = '';
store.edgesStore.set($edgesStore);
closeModal();
}
</script>
<div
class="EditEdge"
style="left:{canvasWidth / 3}px; top:{canvasHeight / 3}px"
on:mouseover={(e) => ($nodeSelected = true)}
on:focus
on:wheel|preventDefault
>
<form on:submit={handleSubmit}>
<label for="label-input">Edge label</label>
<input
type="text"
id="label-input-{edgeId}"
placeholder={edge.label}
bind:value={label}
/>
<label for="bg-color-input">Edge color</label>
<input
type="color"
id="bg-color-input-{edgeId}"
class="bgci"
bind:value={backgroundColor}
/>
<input
type="text"
placeholder={edge.edgeColor}
bind:value={backgroundColor}
/>
<label for="type-input">Edge type</label>
<input
type="text"
id="type-input-{edgeId}"
placeholder={edge.type}
bind:value={type}
/>
</form>
<div class="btn-container">
<button on:click={handleSubmit}>Submit</button>
<button on:click={handleDelete}>Delete</button>
<button on:click={closeModal}>Cancel</button>
</div>
</div>
<style>
.EditEdge {
position: absolute;
padding: 0 1rem 1rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid #333333;
border-radius: 0.25rem;
background-color: #ffffff;
z-index: 10;
user-select: text;
box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.4);
color: #333333;
}
label {
font-size: 0.8rem;
font-weight: bold;
margin-bottom: 0.15rem;
}
.btn-container {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
}
button {
border-radius: 0.25rem;
background-color: white;
box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.4);
margin: 0.2rem;
}
input {
height: 1.6rem;
border-color: #e45b56;
}
.bgci {
height: 2rem;
width: 5rem;
padding: 0;
border: none;
background-color: none;
}
button:hover {
cursor: pointer;
background-color: #e45b56;
color: white;
}
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,5 @@
# README
## Description
Thie folder implements functionality to create new nodes and new edges dynamically.
@@ -0,0 +1,100 @@
const pkStringGenerator = () => (Math.random() + 1).toString(36).substring(7);
import type { StoreType, PotentialAnchorType } from '../../store/types/types';
import { get } from 'svelte/store';
import { Node } from '../../nodes/models/Node';
import { Edge } from '../../edges/models/Edge';
import { Anchor } from '../../edges/models/Anchor';
import {
rightCb,
leftCb,
topCb,
bottomCb,
} from '../../edges/controllers/anchorCbUser';
import { createPotentialAnchor } from '../../store/controllers/util';
import { dynamicCbCreator } from '../../edges/controllers/anchorCbDev';
import { getAnchors } from '../../edges/controllers/util';
import { getPotentialAnchors } from './util';
/**
* Creates a new edge and two adaptive anchor points and updates the edgesStore and anchorsStore.
*
* @param store The Svelvet store containing the state of a Svelvet component
* @param sourceNodeId The id of the source Node
* @param targetNodeId The id of the target Node
* @param canvasId The canvasId of a Svelvet component
*/
export function createEdgeAndAnchors(
store: StoreType,
sourceNodeId: string,
targetNodeId: string,
canvasId: string
) {
// create an edge
const edgeId = pkStringGenerator();
const newEdge = new Edge(
edgeId,
-1,
-1,
-1,
-1,
canvasId,
'',
'bezier',
'black',
'black',
'black',
false,
true,
false,
() => {},
''
);
// create source anchor
const sourceAnchorId = pkStringGenerator();
const sourceDynamicCb = dynamicCbCreator(store, edgeId, sourceAnchorId);
const sourceAnchor = new Anchor(
sourceAnchorId,
sourceNodeId,
edgeId,
'source',
-1, // dummy variables for x,y,angle for now
-1, // dummy variables for x,y,angle for now
sourceDynamicCb,
canvasId,
0 // dummy variables for x,y,angle for now
);
// create target anchor
const targetAnchorId = pkStringGenerator();
const targetDynamicCb = dynamicCbCreator(store, edgeId, targetAnchorId);
const targetAnchor = new Anchor(
targetAnchorId,
targetNodeId,
edgeId,
'target',
-1, // dummy variables for x,y,angle for now
-1, // dummy variables for x,y,angle for now
targetDynamicCb,
canvasId,
0 // dummy variables for x,y,angle for now
);
// put everything into the store
const { edgesStore, anchorsStore } = store;
anchorsStore.update((anchors) => {
anchors[sourceAnchorId] = sourceAnchor;
anchors[targetAnchorId] = targetAnchor;
return { ...anchors };
});
edgesStore.update((edges) => {
edges[edgeId] = newEdge;
return { ...edges };
});
// make sure to update positions. TODO: don't need to do this for the entire store
const anchors = getAnchors(store);
for (const anchor of anchors) anchor.callback();
}

Some files were not shown because too many files have changed in this diff Show More