fix(frontend): App Editor v0 (#886)

This commit is contained in:
Faton Ramadani
2022-11-14 17:30:49 +01:00
committed by GitHub
parent 4dc05b913e
commit cc5f629a7b
46 changed files with 2456 additions and 230 deletions
+23 -23
View File
@@ -428,29 +428,6 @@
},
"query": "SELECT dependency_job FROM flow WHERE path = $1 AND workspace_id = $2"
},
"190cb52762c82b4c7f318decf73cfb4377825c93b5777904b82f8940bb18be3e": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Jsonb"
]
}
},
"query": "INSERT INTO app\n (workspace_id, path, summary, policy)\n VALUES ($1, $2, $3, $4) RETURNING id"
},
"1ad8677694aca94ee0e6da287d7cc028dcf673583a0e3e4fedd0e5d6766c5860": {
"describe": {
"columns": [],
@@ -2683,6 +2660,29 @@
},
"query": "DELETE FROM token WHERE token = $1 RETURNING email"
},
"9def921a0b697e4cbcb6ad88196ff6f66358f3b819aaf4e279596ccfc433fa92": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Jsonb"
]
}
},
"query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions)\n VALUES ($1, $2, $3, $4, '{}') RETURNING id"
},
"a227548b6604c56bfc15eb780bd8ee72a89dc6701a50f5048e928bd87baa7b9a": {
"describe": {
"columns": [
+14
View File
@@ -2486,6 +2486,8 @@ paths:
required:
- content
- language
force_viewer_static_fields:
type: object
required:
- args
@@ -4758,6 +4760,18 @@ components:
type: object
additionalProperties:
type: boolean
required:
- id
- workspace_id
- path
- summary
- versions
- created_by
- created_at
- value
- policy
- execution_mode
- extra_perms
SlackToken:
type: object
+28 -8
View File
@@ -80,7 +80,7 @@ pub struct AppWithLastVersion {
pub type StaticFields = Map<String, Value>;
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
Anonymous,
@@ -88,7 +88,7 @@ pub enum ExecutionMode {
Viewer,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Policy {
pub on_behalf_of: Option<String>,
//paths:
@@ -208,8 +208,8 @@ async fn create_app(
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy)
VALUES ($1, $2, $3, $4) RETURNING id",
(workspace_id, path, summary, policy, versions)
VALUES ($1, $2, $3, $4, '{}') RETURNING id",
w_id,
app.path,
app.summary,
@@ -249,7 +249,7 @@ async fn create_app(
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, format!("app {} created", app.path)))
Ok((StatusCode::CREATED, app.path))
}
async fn delete_app(
@@ -371,13 +371,15 @@ async fn update_app(
Ok(format!("app {} updated (npath: {:?})", path, npath))
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone)]
pub struct ExecuteApp {
pub args: Map<String, serde_json::Value>,
// - script: script/<path>
// - flow: flow/<path>
pub path: Option<String>,
pub raw_code: Option<RawCode>,
// if set, the app is executed as viewer with the given static fields
pub force_viewer_static_fields: Option<StaticFields>,
}
fn digest(code: &str) -> String {
@@ -422,6 +424,24 @@ async fn execute_component(
let policy = serde_json::from_value::<Policy>(policy).map_err(to_anyhow)?;
let policy = if let Some(static_fields) = payload.clone().force_viewer_static_fields {
let mut hm = HashMap::new();
if let Some(path) = payload.path.clone() {
hm.insert(path, static_fields);
} else {
hm.insert(
format!(
"rawcode/{}",
digest(payload.raw_code.clone().unwrap().content.as_str())
),
static_fields,
);
}
Policy { execution_mode: ExecutionMode::Viewer, triggerables: hm, on_behalf_of: None }
} else {
policy
};
let (username, permissioned_as) = match policy.execution_mode {
ExecutionMode::Anonymous => {
let username = opt_authed
@@ -464,14 +484,14 @@ async fn execute_component(
};
let (job_payload, args) = match &payload {
ExecuteApp { args, raw_code: Some(raw_code), path: None } => {
ExecuteApp { args, raw_code: Some(raw_code), path: None, .. } => {
let content = &raw_code.content;
let payload = JobPayload::Code(raw_code.clone());
let path = digest(content);
let args = build_args(policy, path, args)?;
(payload, args)
}
ExecuteApp { args, raw_code: None, path: Some(path) } => {
ExecuteApp { args, raw_code: None, path: Some(path), .. } => {
let payload = if path.starts_with("script/") {
script_path_to_payload(path.strip_prefix("script/").unwrap(), &mut tx, &w_id)
.await?
+2 -1
View File
@@ -1309,6 +1309,7 @@ run();
.spawn()?
} else {
let mut args = Vec::new();
let script_path = format!("{job_dir}/main.ts");
args.push("run");
if lockfile.is_some() {
args.push("--lock=/tmp/lock.json");
@@ -1316,7 +1317,7 @@ run();
args.push("--unstable");
args.push("--v8-flags=--max-heap-size=2048");
args.push("-A");
args.push("/tmp/main.ts");
args.push(&script_path);
Command::new(deno_path)
.current_dir(job_dir)
.env_clear()
+52 -37
View File
@@ -51,6 +51,7 @@
"svelte": "^3.52.0",
"svelte-awesome": "^3.0.0",
"svelte-check": "^2.9.2",
"svelte-dnd-action": "^0.9.21",
"svelte-heros": "^2.3.5",
"svelte-highlight": "^6.2.1",
"svelte-markdown": "^0.2.3",
@@ -780,9 +781,9 @@
"optional": true
},
"node_modules/@zerodevx/svelte-toast": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.8.1.tgz",
"integrity": "sha512-XofCfaHj79uNiKc5leWUxO6LP/Xcawvi3B6Kb1gPCaBf/X5//HdZ/OIfG3z//QuKyFRUmEkDkoMzmbYWTUW2jw==",
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.8.2.tgz",
"integrity": "sha512-EDtZ/Hw37T/UWCQ5drhMss0J9vItYUSDivQ3+mET5My6No7YNiNQklj2bkE61UAzut2TjHJfOJNBZsj78ODFtw==",
"dev": true
},
"node_modules/accepts": {
@@ -2261,23 +2262,6 @@
"node": ">=12"
}
},
"node_modules/esbuild-linux-64": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.53.tgz",
"integrity": "sha512-pP/FA55j/fzAV7N9DF31meAyjOH6Bjuo3aSKPh26+RW85ZEtbJv9nhoxmGTd9FOqjx59Tc1ZbrJabuiXlMwuZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.53.tgz",
@@ -2482,6 +2466,23 @@
"node": ">=12"
}
},
"node_modules/esbuild/node_modules/esbuild-linux-64": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.53.tgz",
"integrity": "sha512-pP/FA55j/fzAV7N9DF31meAyjOH6Bjuo3aSKPh26+RW85ZEtbJv9nhoxmGTd9FOqjx59Tc1ZbrJabuiXlMwuZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -5881,6 +5882,12 @@
"svelte": "^3.24.0"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.21",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.21.tgz",
"integrity": "sha512-uSiaGiSY5KCTx5OtColSk/1qFFUAzGvnoQYDE5xfOmcyvf3lAI1IjPv0p0LRKrTudS9rGZYWPx44qsD09ER6QQ==",
"dev": true
},
"node_modules/svelte-heros": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/svelte-heros/-/svelte-heros-2.3.5.tgz",
@@ -6154,9 +6161,9 @@
"peer": true
},
"node_modules/tailwindcss": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.2.tgz",
"integrity": "sha512-c2GtSdqg+harR4QeoTmex0Ngfg8IIHNeLQH5yr2B9uZbZR1Xt1rYbjWOWTcj3YLTZhrmZnPowoQDbSRFyZHQ5Q==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.3.tgz",
"integrity": "sha512-Xt9D4PK4zuuQCEB8bwK9JUCKmTgUwyac/6b0/42Vqhgl6YJkep+Wf5wq+5uXYfmrupdAD0YY2NY1hyZp1HjRrg==",
"dev": true,
"dependencies": {
"arg": "^5.0.2",
@@ -7315,9 +7322,9 @@
"optional": true
},
"@zerodevx/svelte-toast": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.8.1.tgz",
"integrity": "sha512-XofCfaHj79uNiKc5leWUxO6LP/Xcawvi3B6Kb1gPCaBf/X5//HdZ/OIfG3z//QuKyFRUmEkDkoMzmbYWTUW2jw==",
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.8.2.tgz",
"integrity": "sha512-EDtZ/Hw37T/UWCQ5drhMss0J9vItYUSDivQ3+mET5My6No7YNiNQklj2bkE61UAzut2TjHJfOJNBZsj78ODFtw==",
"dev": true
},
"accepts": {
@@ -8299,6 +8306,16 @@
"esbuild-windows-32": "0.14.53",
"esbuild-windows-64": "0.14.53",
"esbuild-windows-arm64": "0.14.53"
},
"dependencies": {
"esbuild-linux-64": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.53.tgz",
"integrity": "sha512-pP/FA55j/fzAV7N9DF31meAyjOH6Bjuo3aSKPh26+RW85ZEtbJv9nhoxmGTd9FOqjx59Tc1ZbrJabuiXlMwuZQ==",
"dev": true,
"optional": true,
"peer": true
}
}
},
"esbuild-android-64": {
@@ -8357,14 +8374,6 @@
"optional": true,
"peer": true
},
"esbuild-linux-64": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.53.tgz",
"integrity": "sha512-pP/FA55j/fzAV7N9DF31meAyjOH6Bjuo3aSKPh26+RW85ZEtbJv9nhoxmGTd9FOqjx59Tc1ZbrJabuiXlMwuZQ==",
"dev": true,
"optional": true,
"peer": true
},
"esbuild-linux-arm": {
"version": "0.14.53",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.53.tgz",
@@ -10936,6 +10945,12 @@
"typescript": "*"
}
},
"svelte-dnd-action": {
"version": "0.9.21",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.21.tgz",
"integrity": "sha512-uSiaGiSY5KCTx5OtColSk/1qFFUAzGvnoQYDE5xfOmcyvf3lAI1IjPv0p0LRKrTudS9rGZYWPx44qsD09ER6QQ==",
"dev": true
},
"svelte-heros": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/svelte-heros/-/svelte-heros-2.3.5.tgz",
@@ -11122,9 +11137,9 @@
}
},
"tailwindcss": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.2.tgz",
"integrity": "sha512-c2GtSdqg+harR4QeoTmex0Ngfg8IIHNeLQH5yr2B9uZbZR1Xt1rYbjWOWTcj3YLTZhrmZnPowoQDbSRFyZHQ5Q==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.3.tgz",
"integrity": "sha512-Xt9D4PK4zuuQCEB8bwK9JUCKmTgUwyac/6b0/42Vqhgl6YJkep+Wf5wq+5uXYfmrupdAD0YY2NY1hyZp1HjRrg==",
"dev": true,
"requires": {
"arg": "^5.0.2",
+1
View File
@@ -41,6 +41,7 @@
"svelte": "^3.52.0",
"svelte-awesome": "^3.0.0",
"svelte-check": "^2.9.2",
"svelte-dnd-action": "^0.9.21",
"svelte-heros": "^2.3.5",
"svelte-highlight": "^6.2.1",
"svelte-markdown": "^0.2.3",
+9
View File
@@ -1 +1,10 @@
/// <reference types="@sveltejs/kit" />
declare type Item = import('svelte-dnd-action').Item
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action').DndEvent<ItemType>
declare namespace svelte.JSX {
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
import Required from './Required.svelte'
import Popover from './Popover.svelte'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app'
export let meta: Meta = {
ownerKind: 'user',
owner: '',
@@ -8,6 +8,8 @@
export let inputTransform = false
export let schema: Schema
export let args: Record<string, InputTransform | any> = {}
export let disabledArgs: string[] = []
export let editableSchema = false
export let isValid: boolean = true
export let extraLib: string = 'missing extraLib'
@@ -64,6 +66,7 @@
contentEncoding={schema.properties[argName].contentEncoding}
properties={schema.properties[argName].properties}
bind:itemsType={schema.properties[argName].items}
disabled={disabledArgs.includes(argName)}
{editableSchema}
/>
{/if}
@@ -1,31 +1,24 @@
<script lang="ts">
import { Job, JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { onDestroy } from 'svelte'
import type { Preview } from '$lib/gen/models/Preview'
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let isLoading = false
export let job: Job | undefined = undefined
export let workspaceOverride: string | undefined = undefined
export let notfound = false
const dispatch = createEventDispatcher()
$: workspace = workspaceOverride ?? $workspaceStore
let intervalId: NodeJS.Timer
let syncIteration: number = 0
let ITERATIONS_BEFORE_SLOW_REFRESH = 100
export async function runPreview(
path: string | undefined,
code: string,
lang: 'deno' | 'go' | 'python3' | 'bash',
args: Record<string, any>
): Promise<void> {
export async function abstractRun(fn: () => Promise<string>) {
try {
intervalId && clearInterval(intervalId)
if (isLoading && job) {
@@ -37,8 +30,49 @@
}
isLoading = true
const testId = await JobService.runScriptPreview({
workspace: workspace!,
const testId = await fn()
await watchJob(testId)
} catch (err) {
isLoading = false
throw err
}
}
export async function runScriptByPath(
path: string | undefined,
args: Record<string, any>
): Promise<void> {
abstractRun(() =>
JobService.runScriptByPath({
workspace: $workspaceStore!,
path: path ?? '',
requestBody: args
})
)
}
export async function runFlowByPath(
path: string | undefined,
args: Record<string, any>
): Promise<void> {
abstractRun(() =>
JobService.runFlowByPath({
workspace: $workspaceStore!,
path: path ?? '',
requestBody: args
})
)
}
export async function runPreview(
path: string | undefined,
code: string,
lang: 'deno' | 'go' | 'python3' | 'bash',
args: Record<string, any>
): Promise<void> {
abstractRun(() =>
JobService.runScriptPreview({
workspace: $workspaceStore!,
requestBody: {
path,
content: code,
@@ -46,11 +80,7 @@
language: lang as Preview.language
}
})
await watchJob(testId)
} catch (err) {
isLoading = false
throw err
}
)
}
export async function cancelJob() {
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { emptySchema } from '$lib/utils'
import { getContext } from 'svelte'
import type { AppEditorContext } from '../types'
import { Bar } from 'svelte-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
BarElement
} from 'chart.js'
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
BarElement
)
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
export const schema: Schema = emptySchema()
export const staticOutputs: string[] = []
const data = {
labels: ['Red', 'Blue', 'Yellow'],
datasets: [
{
label: '% of Votes',
data: [12, 19, 3],
backgroundColor: [
'rgba(255, 134,159,0.4)',
'rgba(98, 182, 239,0.4)',
'rgba(255, 218, 128,0.4)'
],
borderWidth: 2,
borderColor: ['rgba(255, 134, 159, 1)', 'rgba(98, 182, 239, 1)', 'rgba(255, 218, 128, 1)']
}
]
}
</script>
{#if $worldStore}
<Bar {data} options={{ responsive: true }} />
{/if}
@@ -0,0 +1,28 @@
<script lang="ts">
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { getContext } from 'svelte'
import type { AppEditorContext, ComponentInputsSpec } from '../types'
export let componentInputs: ComponentInputsSpec
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
$: inputResult =
componentInputs.result.id && componentInputs.result.name
? $worldStore?.connect<any>(componentInputs.result, (x) => {
update()
})
: undefined
let result: any
function update() {
result = inputResult?.peak()
}
export const staticOutputs: string[] = []
</script>
{#if $worldStore}
<DisplayResult {result} />
{/if}
@@ -0,0 +1,130 @@
<script lang="ts">
import { page } from '$app/stores'
import type { Schema } from '$lib/common'
import { Button } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import { AppService, type CompletedJob } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { faArrowsRotate, faFile } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import Icon from 'svelte-awesome'
import type { Output } from '../rx'
import type { AppEditorContext, InputsSpec } from '../types'
import { buildArgs, loadSchema, schemaToInputsSpec } from '../utils'
// Component props
export let id: string
export let inputs: InputsSpec
export let path: string | undefined = undefined
export let runType: 'script' | 'flow' | undefined = undefined
export const staticOutputs = ['loading', 'result']
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
let pagePath = $page.params.path
$: outputs = $worldStore?.outputsById[id] as {
result: Output<any>
loading: Output<boolean>
}
// Local state
let args: Record<string, any> = {}
let schema: Schema | undefined = undefined
let schemaClone: Schema | undefined = undefined
let isValid = true
let testIsLoading = false
let testJob: CompletedJob | undefined = undefined
let testJobLoader: TestJobLoader | undefined = undefined
$: if ($workspaceStore && path && runType) {
loadSchemaFromTriggerable($workspaceStore, path, runType)
}
$: if (inputs && schema !== undefined) {
if (Object.keys(schema.properties).length !== Object.keys(inputs).length) {
inputs = schemaToInputsSpec(schema)
}
reloadSchemaAndArgs()
}
// Load once
async function loadSchemaFromTriggerable(
workspace: string,
path: string,
runType: 'script' | 'flow'
) {
schema = await loadSchema(workspace, path, runType)
args = buildArgs(inputs, schema)
}
async function reloadSchemaAndArgs() {
schemaClone = JSON.parse(JSON.stringify(schema))
if (schemaClone !== undefined) {
args = buildArgs(inputs, schemaClone)
Object.keys(schemaClone.properties).forEach((propKey) => {
if (!Object.keys(args).includes(propKey)) {
delete schemaClone!.properties[propKey]
}
})
}
}
$: disabledArgs = Object.keys(inputs).reduce((a: string[], c: string) => {
if (inputs[c].type === 'static') {
a = [...a, c]
}
return a
}, [])
async function executeComponent() {
await testJobLoader?.abstractRun(() =>
AppService.executeComponent({
workspace: $workspaceStore!,
path: pagePath,
requestBody: {
path: `${runType}/${path}`,
args,
force_viewer_static_fields: {}
}
})
)
outputs?.loading.set(true)
}
</script>
<TestJobLoader
on:done={() => {
if (testJob) {
outputs?.result.set(testJob?.result)
outputs?.loading.set(false)
}
}}
bind:isLoading={testIsLoading}
bind:job={testJob}
bind:this={testJobLoader}
/>
{#if schemaClone !== undefined}
<SchemaForm schema={schemaClone} bind:args bind:isValid {disabledArgs} />
<Button
size="xs"
color="dark"
variant="border"
on:click={() => executeComponent()}
startIcon={{ icon: faFile }}
disabled={!isValid}
>
<div>
Submit
{#if testIsLoading}
<Icon data={faArrowsRotate} class="animate-spin ml-2" scale={0.8} />
{/if}
</div>
</Button>
{/if}
@@ -0,0 +1,109 @@
<script lang="ts">
import type {} from '$lib/common'
import { classNames } from '$lib/utils'
export let id: string
export let title: string
export let description: string | undefined = undefined
export let headers: string[]
export let data: Array<Record<string, any>>
export const staticOutputs: string[] = []
let query: string = ''
let page: number = 1
</script>
<div class="p-8 w-full">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">{title}</h1>
{#if description}
<p class="mt-2 text-sm text-gray-700">
{description}
</p>
{/if}
</div>
</div>
<div class="my-4 flex flex-col">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
{#each headers as header}
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header}
</th>
{/each}
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data as x}
<tr>
{#each headers as header}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{x[header]}
</td>
{/each}
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<a href="#" class="text-indigo-600 hover:text-indigo-900">
Edit
<span class="sr-only">, Lindsay Walton </span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
<nav>
<ul class="inline-flex -space-x-px">
<li>
<button
on:click={() => (page -= 1)}
class="text-sm py-2 px-4 text-gray-500 bg-white rounded-l-lg border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
>
Previous
</button>
</li>
{#each Array(5) as x, i}
<li>
<button
on:click={() => (page = i)}
class={classNames(
'text-sm py-2 px-4 text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700',
page === i ? 'bg-blue-100 font-bold' : 'bg-white'
)}
>
{i}
</button>
</li>
{/each}
<li>
<button
on:click={() => (page += 1)}
class="text-sm py-2 px-4 text-gray-500 bg-white rounded-r-lg border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
Next
</button>
</li>
</ul>
</nav>
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { onMount, setContext } from 'svelte'
import { Pane } from 'svelte-splitpanes'
import { writable } from 'svelte/store'
import { buildWorld, type World } from '../rx'
import type { App, AppEditorContext, AppSelection, ConnectingInput, EditorMode } from '../types'
import AppEditorHeader from './AppEditorHeader.svelte'
import SectionsEditor from './SectionsEditor.svelte'
import ComponentPanel from './settingsPanel/ComponentPanel.svelte'
import type { Schema } from '$lib/common'
import SectionPanel from './settingsPanel/SectionPanel.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
export let app: App
console.log(app)
const appStore = writable<App>(app)
const worldStore = writable<World | undefined>(undefined)
const staticOutputs = writable<Record<string, string[]>>({})
const selection = writable<AppSelection | undefined>(undefined)
const mode = writable<EditorMode>('dnd')
const schemas = writable<Schema[]>([])
const connectingInput = writable<ConnectingInput>({
opened: false,
input: undefined
})
setContext<AppEditorContext>('AppEditorContext', {
worldStore,
staticOutputs,
app: appStore,
selection,
mode,
schemas,
connectingInput
})
onMount(() => {
$worldStore = buildWorld($staticOutputs)
})
</script>
<AppEditorHeader title="Sample app" bind:mode={$mode} />
<SplitPanesWrapper>
<Pane minSize={20} maxSize={30} size={20}>
<ComponentList />
</Pane>
<Pane>
<SectionsEditor bind:sections={$appStore.sections} mode={$mode} />
</Pane>
<Pane minSize={20} maxSize={30} size={20}>
{#if $selection?.sectionIndex !== undefined && $selection?.componentIndex !== undefined}
<ComponentPanel
bind:component={$appStore.sections[$selection?.sectionIndex].components[
$selection?.componentIndex
]}
on:remove={() => {
if ($selection?.sectionIndex !== undefined && $selection?.componentIndex !== undefined) {
$appStore.sections[$selection?.sectionIndex].components.splice(
$selection?.componentIndex,
1
)
$appStore = $appStore
$selection = undefined
}
}}
/>
{/if}
{#if $selection?.sectionIndex !== undefined}
<SectionPanel
bind:section={$appStore.sections[$selection.sectionIndex]}
on:remove={() => {
if ($selection?.sectionIndex !== undefined) {
$appStore.sections.splice($selection?.sectionIndex, 1)
$appStore = $appStore
$selection = undefined
}
}}
/>
{/if}
</Pane>
</SplitPanesWrapper>
@@ -0,0 +1,58 @@
<script lang="ts">
import { page } from '$app/stores'
import Button from '$lib/components/common/button/Button.svelte'
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
import { AppService, Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { faArrowsLeftRight, faExternalLink, faHand } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import type { AppEditorContext, EditorMode } from '../types'
export let title: string
export let mode: EditorMode
const { app } = getContext<AppEditorContext>('AppEditorContext')
async function save() {
await AppService.updateApp({
workspace: $workspaceStore!,
path: $page.params.path,
requestBody: {
value: $app!,
summary: 'App summary',
policy: {
triggerables: {},
execution_mode: Policy.execution_mode.PUBLISHER,
on_behalf_of: `u/${$userStore?.username}`
}
}
})
console.log('App saved')
}
</script>
<div class="border-b h-12 flex flex-row justify-between py-2 px-4 items-center">
<span class="text-sm">{title}</span>
<div>
<ToggleButtonGroup bind:selected={mode}>
<ToggleButton position="left" value="dnd" startIcon={{ icon: faHand }} size="sm">
Component editor
</ToggleButton>
<ToggleButton
position="right"
value="width"
startIcon={{ icon: faArrowsLeftRight }}
size="sm"
>
Width editor
</ToggleButton>
</ToggleButtonGroup>
</div>
<div class="flex flex-row gap-2">
<Button color="dark" size="sm" variant="border" startIcon={{ icon: faExternalLink }}>
Publish
</Button>
<Button on:click={save} size="sm">Save</Button>
</div>
</div>
@@ -0,0 +1,74 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { classNames } from '$lib/utils'
import { getContext } from 'svelte'
import BarChartComponent from '../components/BarChartComponent.svelte'
import DisplayComponent from '../components/DisplayComponent.svelte'
import RunFormComponent from '../components/RunFormComponent.svelte'
import TableComponent from '../components/TableComponent.svelte'
import type { AppComponent, AppEditorContext } from '../types'
export let component: AppComponent
export let selected: boolean
const { staticOutputs, app, connectingInput } = getContext<AppEditorContext>('AppEditorContext')
function connectInput(output: string) {
if ($connectingInput) {
$connectingInput = {
opened: false,
input: {
id: component.id,
name: output,
type: 'output',
defaultValue: undefined
}
}
}
}
</script>
{#if component}
<div class="h-full flex flex-col w-full">
<span
class={classNames(
' text-white px-2 text-xs py-1 font-bold rounded-t-sm w-fit ',
selected ? 'bg-indigo-500' : 'bg-gray-500'
)}
>
{component.type}
</span>
<div
class={classNames(
'p-2 border overflow-auto cursor-pointer hover:bg-blue-100 h-full bg-white relative',
selected ? 'border-indigo-400' : 'border-gray-400'
)}
>
{#if $connectingInput.opened && $staticOutputs[component.id]}
<div
class="absolute top-0 bottom-0 left-0 right-0 bg-opacity-80 w-full h-full bg-gray-500 flex justify-center items-center flex-col gap-2"
>
{#each $staticOutputs[component.id] as output}
<Button color="dark" on:click={() => connectInput(output)}>
{output}
</Button>
{/each}
</div>
{/if}
{#if component.type === 'runformcomponent'}
<RunFormComponent
{...component}
bind:inputs={component.inputs}
bind:staticOutputs={$staticOutputs[component.id]}
/>
{:else if component.type === 'displaycomponent'}
<DisplayComponent {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{:else if component.type === 'barchartcomponent'}
<BarChartComponent {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{:else if component.type === 'tablecomponent'}
<TableComponent {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,232 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import type { AppComponent, AppEditorContext } from '../types'
import ComponentEditor from './ComponentEditor.svelte'
import { dndzone } from 'svelte-dnd-action'
import { flip } from 'svelte/animate'
import { classNames } from '$lib/utils'
import { getNextId } from '$lib/components/flows/flowStateUtils'
export let components: AppComponent[]
export let sectionIndex: number
export let columns: number
const flipDurationMs = 200
const { selection, connectingInput } = getContext<AppEditorContext>('AppEditorContext')
function handleDndConsider(event: CustomEvent<DndEvent<AppComponent>>) {
components = event.detail.items
}
function handleDndFinalize(event: CustomEvent<DndEvent<AppComponent>>) {
const totalWidth = components
.map((c) => c.width)
.filter(Boolean)
.reduce((a, b) => a + b, 0)
components = event.detail.items.map((item) => {
if (item.width === undefined) {
item.width = 100 - totalWidth
const id = getNextId(
components.map((c) => c.id).filter((id) => id !== event.detail.info.id)
)
item.id = id
}
return item
})
}
// HACK
onMount(() => {
// get all div elements with the id "component"
const divs = document.querySelectorAll<HTMLDivElement>('#component')
// loop through the divs
divs.forEach((div, index) => {
// set component width
div.style.width = `${Math.round(components[index].width)}%`
})
})
const numberToTailwindWidthMap = {
1: 'w-[1%]',
2: 'w-[2%]',
3: 'w-[3%]',
4: 'w-[4%]',
5: 'w-[5%]',
6: 'w-[6%]',
7: 'w-[7%]',
8: 'w-[8%]',
9: 'w-[9%]',
10: 'w-[10%]',
11: 'w-[11%]',
12: 'w-[12%]',
13: 'w-[13%]',
14: 'w-[14%]',
15: 'w-[15%]',
16: 'w-[16%]',
17: 'w-[17%]',
18: 'w-[18%]',
19: 'w-[19%]',
20: 'w-[20%]',
21: 'w-[21%]',
22: 'w-[22%]',
23: 'w-[23%]',
24: 'w-[24%]',
25: 'w-[25%]',
26: 'w-[26%]',
27: 'w-[27%]',
28: 'w-[28%]',
29: 'w-[29%]',
30: 'w-[30%]',
31: 'w-[31%]',
32: 'w-[32%]',
33: 'w-[33%]',
34: 'w-[34%]',
35: 'w-[35%]',
36: 'w-[36%]',
37: 'w-[37%]',
38: 'w-[38%]',
39: 'w-[39%]',
40: 'w-[40%]',
41: 'w-[41%]',
42: 'w-[42%]',
43: 'w-[43%]',
44: 'w-[44%]',
45: 'w-[45%]',
46: 'w-[46%]',
47: 'w-[47%]',
48: 'w-[48%]',
49: 'w-[49%]',
50: 'w-[50%]',
51: 'w-[51%]',
52: 'w-[52%]',
53: 'w-[53%]',
54: 'w-[54%]',
55: 'w-[55%]',
56: 'w-[56%]',
57: 'w-[57%]',
58: 'w-[58%]',
59: 'w-[59%]',
60: 'w-[60%]',
61: 'w-[61%]',
62: 'w-[62%]',
63: 'w-[63%]',
64: 'w-[64%]',
65: 'w-[65%]',
66: 'w-[66%]',
67: 'w-[67%]',
68: 'w-[68%]',
69: 'w-[69%]',
70: 'w-[70%]',
71: 'w-[71%]',
72: 'w-[72%]',
73: 'w-[73%]',
74: 'w-[74%]',
75: 'w-[75%]',
76: 'w-[76%]',
77: 'w-[77%]',
78: 'w-[78%]',
79: 'w-[79%]',
80: 'w-[80%]',
81: 'w-[81%]',
82: 'w-[82%]',
83: 'w-[83%]',
84: 'w-[84%]',
85: 'w-[85%]',
86: 'w-[86%]',
87: 'w-[87%]',
88: 'w-[88%]',
89: 'w-[89%]',
90: 'w-[90%]',
91: 'w-[91%]',
92: 'w-[92%]',
93: 'w-[93%]',
94: 'w-[94%]',
95: 'w-[95%]',
96: 'w-[96%]',
97: 'w-[97%]',
98: 'w-[98%]',
99: 'w-[99%]',
100: 'w-[100%]'
}
$: sum = components.reduce((acc, component) => acc + component.width, 0)
$: canDrop = components.length > 0 && components.length < columns
</script>
<div class="h-80 rounded-b-sm flex-row p-4 flex border-2 border-gray bg-white cursor-pointer ">
<div class="dotted-background h-full flex flex-row gap-1 w-full">
<div
class={classNames(
'flex gap-1',
canDrop ? numberToTailwindWidthMap[Math.round(sum)] : 'w-full'
)}
use:dndzone={{
items: components,
flipDurationMs,
type: 'component',
dropTargetStyle: {
outline: 'dashed blue',
outlineOffset: '2px'
},
dragDisabled: components.length === 0,
dropFromOthersDisabled: components.length === columns
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#if components.length > 0}
{#each components as component, componentIndex (component.id)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={numberToTailwindWidthMap[Math.round((100 * component.width) / sum)]}
animate:flip={{ duration: flipDurationMs }}
on:click|stopPropagation={() => {
if (!$connectingInput.opened) {
$selection = { componentIndex, sectionIndex }
}
}}
>
<ComponentEditor
bind:component
selected={componentIndex === $selection?.componentIndex &&
sectionIndex === $selection?.sectionIndex}
/>
</div>
{/each}
{:else}
<div class="flex w-full gap-1">
{#each Array(columns - components.length) as x}
<div
class="border flex justify-center flex-col items-center w-full bg-green-200 bg-opacity-50"
>
<div>Empty component</div>
</div>
{/each}
</div>
{/if}
</div>
{#if canDrop}
<div class={classNames('h-full flex gap-2', numberToTailwindWidthMap[Math.round(100 - sum)])}>
{#each Array(columns - components.length) as _}
<div
class="border flex justify-center flex-col items-center h-full w-full bg-green-200 bg-opacity-50"
>
<div>Empty component</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.dotted-background {
background-image: radial-gradient(circle at 1px 1px, #ccc 1px, transparent 0);
background-size: 40px 40px;
background-position: 20px 20px;
}
</style>
@@ -0,0 +1,264 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import type { AppEditorContext, AppSection, EditorMode } from '../types'
import { dndzone } from 'svelte-dnd-action'
import { flip } from 'svelte/animate'
import { getNextId } from '$lib/components/flows/flowStateUtils'
import Badge from '$lib/components/common/badge/Badge.svelte'
import ComponentsEditor from './ComponentsEditor.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ComponentEditor from './ComponentEditor.svelte'
import { classNames } from '$lib/utils'
import RunFormComponent from '../components/RunFormComponent.svelte'
import DisplayComponent from '../components/DisplayComponent.svelte'
export let sections: AppSection[]
console.log(sections)
export let mode: EditorMode = 'width'
const flipDurationMs = 200
const { selection, staticOutputs, app } = getContext<AppEditorContext>('AppEditorContext')
function handleSort(e) {
sections = e.detail.items
}
function addEmptySection() {
sections = [
...sections,
{
components: [],
columns: 3,
id: getNextId(sections.map((s) => s.id)),
title: 'New section',
description: 'section'
}
]
}
const numberToTailwindWidthMap = {
1: 'w-[1%]',
2: 'w-[2%]',
3: 'w-[3%]',
4: 'w-[4%]',
5: 'w-[5%]',
6: 'w-[6%]',
7: 'w-[7%]',
8: 'w-[8%]',
9: 'w-[9%]',
10: 'w-[10%]',
11: 'w-[11%]',
12: 'w-[12%]',
13: 'w-[13%]',
14: 'w-[14%]',
15: 'w-[15%]',
16: 'w-[16%]',
17: 'w-[17%]',
18: 'w-[18%]',
19: 'w-[19%]',
20: 'w-[20%]',
21: 'w-[21%]',
22: 'w-[22%]',
23: 'w-[23%]',
24: 'w-[24%]',
25: 'w-[25%]',
26: 'w-[26%]',
27: 'w-[27%]',
28: 'w-[28%]',
29: 'w-[29%]',
30: 'w-[30%]',
31: 'w-[31%]',
32: 'w-[32%]',
33: 'w-[33%]',
34: 'w-[34%]',
35: 'w-[35%]',
36: 'w-[36%]',
37: 'w-[37%]',
38: 'w-[38%]',
39: 'w-[39%]',
40: 'w-[40%]',
41: 'w-[41%]',
42: 'w-[42%]',
43: 'w-[43%]',
44: 'w-[44%]',
45: 'w-[45%]',
46: 'w-[46%]',
47: 'w-[47%]',
48: 'w-[48%]',
49: 'w-[49%]',
50: 'w-[50%]',
51: 'w-[51%]',
52: 'w-[52%]',
53: 'w-[53%]',
54: 'w-[54%]',
55: 'w-[55%]',
56: 'w-[56%]',
57: 'w-[57%]',
58: 'w-[58%]',
59: 'w-[59%]',
60: 'w-[60%]',
61: 'w-[61%]',
62: 'w-[62%]',
63: 'w-[63%]',
64: 'w-[64%]',
65: 'w-[65%]',
66: 'w-[66%]',
67: 'w-[67%]',
68: 'w-[68%]',
69: 'w-[69%]',
70: 'w-[70%]',
71: 'w-[71%]',
72: 'w-[72%]',
73: 'w-[73%]',
74: 'w-[74%]',
75: 'w-[75%]',
76: 'w-[76%]',
77: 'w-[77%]',
78: 'w-[78%]',
79: 'w-[79%]',
80: 'w-[80%]',
81: 'w-[81%]',
82: 'w-[82%]',
83: 'w-[83%]',
84: 'w-[84%]',
85: 'w-[85%]',
86: 'w-[86%]',
87: 'w-[87%]',
88: 'w-[88%]',
89: 'w-[89%]',
90: 'w-[90%]',
91: 'w-[91%]',
92: 'w-[92%]',
93: 'w-[93%]',
94: 'w-[94%]',
95: 'w-[95%]',
96: 'w-[96%]',
97: 'w-[97%]',
98: 'w-[98%]',
99: 'w-[99%]',
100: 'w-[100%]'
}
</script>
<div class="dotted-background h-full w-full p-8">
<div
class="flex-col flex justify-start gap-8 pt-8 mb-8"
use:dndzone={{
items: sections,
flipDurationMs,
type: 'section',
dragDisabled: mode === 'width',
dropTargetStyle: {
outline: 'dashed blue',
outlineOffset: '8px'
}
}}
on:consider={handleSort}
on:finalize={handleSort}
>
{#each sections as section, sectionIndex (section.id)}
{@const selected = $selection?.sectionIndex === sectionIndex}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<section
animate:flip={{ duration: flipDurationMs }}
on:click={() => {
$selection = { sectionIndex, componentIndex: undefined }
}}
>
{#if mode !== 'preview'}
<span
class={classNames(
'text-white px-2 text-sm py-1 font-bold',
selected ? 'bg-indigo-500' : 'bg-gray-500'
)}
>
Section {sectionIndex + 1}
<Badge>{section.id}</Badge>
</span>
{/if}
{#if mode === 'dnd'}
<ComponentsEditor
bind:components={section.components}
columns={section.columns}
{sectionIndex}
/>
{:else if mode === 'preview'}
<div class="w-full flex bg-white">
{#each section.components as component}
<div class={classNames(numberToTailwindWidthMap[Math.round(component.width)])}>
{#if component.type === 'runformcomponent'}
<RunFormComponent
{...component}
bind:staticOutputs={$staticOutputs[component.id]}
/>
{:else if component.type === 'displaycomponent'}
<DisplayComponent
{...component}
bind:staticOutputs={$staticOutputs[component.id]}
/>
{/if}
</div>
{/each}
</div>
{:else if mode === 'width'}
<div
class="h-80 w-full rounded-b-sm flex-row gap-4 p-4 flex border-2 border-gray-200 bg-white cursor-pointer "
>
<Splitpanes>
{#each section.components as component}
<Pane bind:size={component.width} minSize={20}>
<ComponentEditor bind:component selected={false} />
</Pane>
{/each}
{#if section.components.length < section.columns}
<Pane
size={100 - section.components.reduce((accu, curr) => accu + curr.width, 0)}
minSize={20}
class="gap-2 w-full flex flex-row"
>
{#each Array(section.columns - section.components.length) as _}
<div
class="border flex justify-center flex-col items-center w-full h-full bg-green-200 bg-opacity-50"
>
<div>Empty</div>
</div>
{/each}
</Pane>
{/if}
</Splitpanes>
</div>
{/if}
</section>
{/each}
</div>
{#if mode !== 'preview'}
<section>
<span class="bg-blue-500 text-white px-2 text-sm py-1 font-bold rounded-t-sm">
Empty section
</span>
<div class="h-96 border-2 border-blue-200 border-dashed bg-white flex">
<Button
btnClasses="m-auto"
color="dark"
size="sm"
startIcon={{ icon: faPlus }}
on:click={() => addEmptySection()}
>
Add
</Button>
</div>
</section>
{/if}
</div>
<style>
.dotted-background {
background-image: radial-gradient(circle at 1px 1px, #ccc 1px, transparent 0);
background-size: 40px 40px;
background-position: 20px 20px;
}
</style>
@@ -0,0 +1,105 @@
<script lang="ts">
import { flip } from 'svelte/animate'
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
import Icon from 'svelte-awesome'
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
import { faWpforms } from '@fortawesome/free-brands-svg-icons'
import { getNextId } from '$lib/components/flows/flowStateUtils'
const defaultProps = {
horizontalAlignement: 'center',
verticalAlignement: 'center',
title: 'My title',
description: 'My description',
configSchema: undefined,
inputs: {},
componentInputs: {}
}
let items = [
{
...defaultProps,
// Used by the dnd library, should be replaced by unique id
id: 'displaycomponent',
type: 'displaycomponent',
componentInputs: {
result: {
id: undefined,
name: undefined,
type: 'output',
defaultValue: undefined
}
}
},
{
...defaultProps,
// Used by the dnd library, should be replaced by unique id
id: 'runformcomponent',
type: 'runformcomponent'
}
]
const displayData = {
displaycomponent: {
name: 'Display component',
icon: faDisplay
},
runformcomponent: {
name: 'Run form',
icon: faWpforms
}
}
const flipDurationMs = 300
let shouldIgnoreDndEvents = false
function handleDndConsider(e) {
const { trigger, id } = e.detail.info
if (trigger === TRIGGERS.DRAG_STARTED) {
const idx = items.findIndex((item) => item.id === id)
e.detail.items = e.detail.items.filter((item) => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME])
e.detail.items.splice(idx, 0, {
...items[idx],
id: getNextId(e.detail.items.map((item) => item.id)),
// @ts-ignore
type: items[idx].type,
width: 100
})
items = e.detail.items
shouldIgnoreDndEvents = true
} else if (!shouldIgnoreDndEvents) {
items = e.detail.items
} else {
items = [...items]
}
}
function handleDndFinalize(e) {
if (!shouldIgnoreDndEvents) {
items = e.detail.items
} else {
items = [...items]
shouldIgnoreDndEvents = false
}
}
</script>
<section
use:dndzone={{ items, flipDurationMs, type: 'component' }}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
class="grid grid-cols-2 gap-2 p-2"
>
{#each items as item (item.id)}
<div
class="border shadow-sm h-24 p-2 flex flex-col gap-2 items-center justify-center bg-white rounded-md"
animate:flip={{ duration: flipDurationMs }}
>
<Icon data={displayData[item.type].icon} scale={1.6} />
<div class="text-xs">{displayData[item.type].name}</div>
</div>
{/each}
</section>
@@ -0,0 +1,31 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import type { ComponentInputsSpec } from '../../types'
import DynamicInputEditor from './DynamicInputEditor.svelte'
export let componentInputs: ComponentInputsSpec
let openedProp = Object.keys(componentInputs)[0]
</script>
<div class="w-full flex flex-col gap-4">
{#each Object.keys(componentInputs) as inputSpecKey}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'w-full text-xs font-bold border rounded-md py-1 px-2 cursor-pointer hover:bg-gray-800 hover:text-white transition-all',
openedProp !== inputSpecKey ? 'bg-gray-200 ' : 'bg-gray-600 text-gray-300'
)}
on:click={() => {
openedProp = inputSpecKey
}}
>
{inputSpecKey}
</div>
{#if inputSpecKey === openedProp}
<div class="flex flex-col w-full gap-2">
<DynamicInputEditor bind:input={componentInputs[inputSpecKey]} />
</div>
{/if}
{/each}
</div>
@@ -0,0 +1,113 @@
<script lang="ts">
import { ToggleButton, ToggleButtonGroup } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import PickScript from '$lib/components/flows/pickers/PickScript.svelte'
import {
faAlignCenter,
faAlignLeft,
faAlignRight,
faTrashAlt
} from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher, getContext } from 'svelte'
import Icon from 'svelte-awesome'
import type { AppComponent, AppEditorContext } from '../../types'
import PanelSection from './common/PanelSection.svelte'
import ComponentInputsSpecsEditor from './ComponentInputsSpecsEditor.svelte'
import InputsSpecsEditor from './InputsSpecsEditor.svelte'
import PickFlow from './PickFlow.svelte'
export let component: AppComponent | undefined
const { app } = getContext<AppEditorContext>('AppEditorContext')
const dispatch = createEventDispatcher()
</script>
{#if component}
{#key component.id}
<div class="flex flex-col w-full divide-y">
<span class="text-sm border-y w-full py-1 px-2 bg-gray-800 text-white">Component editor</span>
<PanelSection title="Inputs">
{#if component.type === 'runformcomponent'}
<InputsSpecsEditor bind:inputSpecs={component.inputs} />
{/if}
{#if component.type === 'runformcomponent' && component.path === undefined}
<span class="text-sm">Select a script or a flow to continue</span>
<PickScript
kind="script"
on:pick={({ detail }) => {
if (component && component.type === 'runformcomponent') {
component.path = detail.path
component.runType = 'script'
}
}}
/>
<PickFlow
on:pick={({ detail }) => {
if (component && component.type === 'runformcomponent') {
component.path = detail.path
component.runType = 'flow'
}
}}
/>
{/if}
{#if component.type === 'displaycomponent' && component.componentInputs}
<ComponentInputsSpecsEditor bind:componentInputs={component.componentInputs} />
{/if}
</PanelSection>
<PanelSection title="Content">
<div class="w-full text-xs font-bold">Title</div>
<input type="text" class="w-full" bind:value={component.title} />
<div class="w-full text-xs font-bold">Description</div>
<textarea type="text" class="w-full" rows="2" bind:value={component.description} />
</PanelSection>
<PanelSection title="Alignement">
<div class="w-full text-xs font-bold">Horizontal alignement</div>
<ToggleButtonGroup bind:selected={component.horizontalAlignement}>
<ToggleButton position="left" value="left" size="xs">
<Icon data={faAlignLeft} />
</ToggleButton>
<ToggleButton position="center" value="center" size="xs">
<Icon data={faAlignCenter} />
</ToggleButton>
<ToggleButton position="right" value="right" size="xs">
<Icon data={faAlignRight} />
</ToggleButton>
</ToggleButtonGroup>
<div class="w-full text-xs font-bold">Vertical alignement</div>
<ToggleButtonGroup bind:selected={component.verticalAlignement}>
<ToggleButton position="left" value="left" size="xs">
<Icon data={faAlignLeft} />
</ToggleButton>
<ToggleButton position="center" value="center" size="xs">
<Icon data={faAlignCenter} />
</ToggleButton>
<ToggleButton position="right" value="right" size="xs">
<Icon data={faAlignRight} />
</ToggleButton>
</ToggleButtonGroup>
<div class="w-full text-xs font-bold">Width (%)</div>
<input value={Math.round(component.width)} type="number" class="w-full" disabled />
</PanelSection>
<PanelSection title="Danger zone">
<Button
size="xs"
color="red"
variant="border"
startIcon={{ icon: faTrashAlt }}
on:click={() => dispatch('remove')}
>
Delete component
</Button>
</PanelSection>
</div>
{/key}
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import type { AppEditorContext, DynamicInput } from '../../types'
import { Badge, Button } from '$lib/components/common'
import { faLink } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
export let input: DynamicInput
const { connectingInput, selection } = getContext<AppEditorContext>('AppEditorContext')
function applyConnection() {
if (!$connectingInput.opened && $connectingInput.input !== undefined) {
input = $connectingInput.input
$selection = $selection
$connectingInput = {
opened: false,
input: undefined
}
}
}
$: $connectingInput && applyConnection()
</script>
{#if input.id && input.name}
<div class="flex justify-between w-full">
<span class="text-xs font-bold">Status</span>
<Badge color="green">Connected</Badge>
</div>
<div class="flex justify-between w-full">
<span class="text-xs font-bold">Component</span>
<Badge color="indigo">{input.id}</Badge>
</div>
<div class="flex justify-between w-full">
<span class="text-xs font-bold">Field name</span>
<Badge color="indigo">{input.name}</Badge>
</div>
<Button
size="xs"
startIcon={{ icon: faLink }}
color="red"
on:click={() => {
input.id = undefined
input.name = undefined
}}
>
Clear connection
</Button>
{:else}
<div class="flex justify-between w-full">
<span class="text-xs font-bold">Status</span>
<Badge color="dark-yellow">Not connected</Badge>
</div>
<Button
size="xs"
startIcon={{ icon: faLink }}
color="dark"
on:click={() => {
$connectingInput = {
opened: true,
input: undefined
}
}}
>
Connect this input to an ouput
</Button>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
import type { AppInputTransform } from '../../types'
import DynamicInputEditor from './DynamicInputEditor.svelte'
import StaticInputEditor from './StaticInputEditor.svelte'
export let appInputTransform: AppInputTransform
</script>
{#if appInputTransform.type === 'static'}
<StaticInputEditor bind:input={appInputTransform} />
{:else if appInputTransform.type === 'output'}
<DynamicInputEditor bind:input={appInputTransform} />
{:else if appInputTransform.type === 'user'}
<span>Not implemented</span>
{/if}
@@ -0,0 +1,44 @@
<script lang="ts">
import { ToggleButton, ToggleButtonGroup } from '$lib/components/common'
import { classNames } from '$lib/utils'
import { faBolt, faLink, faUser } from '@fortawesome/free-solid-svg-icons'
import type { InputsSpec } from '../../types'
import InputsSpecEditor from './InputsSpecEditor.svelte'
export let inputSpecs: InputsSpec
let openedProp = Object.keys(inputSpecs)[0]
</script>
<div class="w-full flex flex-col gap-4">
{#each Object.keys(inputSpecs) as inputSpecKey}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'w-full text-xs font-bold border rounded-md py-1 px-2 cursor-pointer hover:bg-gray-800 hover:text-white transition-all',
openedProp !== inputSpecKey ? 'bg-gray-200 ' : 'bg-gray-600 text-gray-300'
)}
on:click={() => {
openedProp = inputSpecKey
}}
>
{inputSpecKey}
</div>
{#if inputSpecKey === openedProp}
<div class="flex flex-col w-full gap-2">
<ToggleButtonGroup bind:selected={inputSpecs[inputSpecKey].type}>
<ToggleButton position="left" value="static" startIcon={{ icon: faBolt }} size="xs">
Static
</ToggleButton>
<ToggleButton position="center" value="output" startIcon={{ icon: faLink }} size="xs">
Dynamic
</ToggleButton>
<ToggleButton position="right" value="user" startIcon={{ icon: faUser }} size="xs">
User
</ToggleButton>
</ToggleButtonGroup>
<InputsSpecEditor bind:appInputTransform={inputSpecs[inputSpecKey]} />
</div>
{/if}
{/each}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import ItemPicker from '$lib/components/ItemPicker.svelte'
import { faUserGroup } from '@fortawesome/free-solid-svg-icons'
import { FlowService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import FlowScriptPicker from '$lib/components/flows/pickers/FlowScriptPicker.svelte'
type Item = { summary: String; path: String; version?: String }
let itemPicker: ItemPicker
const dispatch = createEventDispatcher()
async function loadItems(): Promise<Item[]> {
return await FlowService.listFlows({ workspace: $workspaceStore! })
}
</script>
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, summary) => {
dispatch('pick', { path, summary })
}}
itemName="Flow"
extraField="summary"
{loadItems}
/>
<FlowScriptPicker
label={`Pick flow from workspace`}
icon={faUserGroup}
iconColor="text-blue-500"
on:click={() => itemPicker.openDrawer()}
/>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Alert, ToggleButton, ToggleButtonGroup } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import type { AppSection } from '../../types'
import PanelSection from './common/PanelSection.svelte'
export let section: AppSection | undefined
const dispatch = createEventDispatcher()
let deleteConfirmedCallback: (() => void) | undefined = undefined
$: open = Boolean(deleteConfirmedCallback)
function deleteSection(event: CustomEvent<PointerEvent>) {
if (
section &&
Array.isArray(section.components) &&
section?.components.length > 0 &&
!event.detail.shiftKey
) {
deleteConfirmedCallback = () => {
dispatch('remove')
deleteConfirmedCallback = undefined
}
} else {
dispatch('remove')
}
}
</script>
{#if section}
<div class="flex flex-col w-full divide-y">
<span class="text-sm border-y w-full py-1 px-2 bg-gray-800 text-white">Section editor</span>
<PanelSection title="Columns">
<ToggleButtonGroup bind:selected={section.columns}>
<ToggleButton position="left" value={1} disabled={section.components.length >= 2} size="xs">
1
</ToggleButton>
<ToggleButton
position="center"
value={2}
disabled={section.components.length === 3}
size="xs"
>
2
</ToggleButton>
<ToggleButton position="right" value={3} size="xs">3</ToggleButton>
</ToggleButtonGroup>
</PanelSection>
<PanelSection title="Danger zone">
<Button
size="xs"
variant="border"
color="red"
startIcon={{ icon: faTrashAlt }}
on:click={deleteSection}
>
Delete section
</Button>
</PanelSection>
</div>
{/if}
<ConfirmationModal
{open}
title="Remove section"
confirmationText="Remove"
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this section?</span>
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing a resource to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
@@ -0,0 +1,9 @@
<script lang="ts">
import type { EditorConfig, StaticInput } from '../../types'
import Toggle from '$lib/components/Toggle.svelte'
export let input: StaticInput
</script>
<Toggle bind:checked={input.visible} options={{ right: 'Visible' }} />
<input bind:value={input.value} />
@@ -0,0 +1,2 @@
<script lang="ts">
</script>
@@ -0,0 +1,8 @@
<script lang="ts">
export let title: string
</script>
<div class="p-4 flex flex-col gap-2 items-start">
<div class="text-sm font-bold">{title}</div>
<slot />
</div>
+113
View File
@@ -0,0 +1,113 @@
import type { AppInputTransform, DynamicInput, InputsSpec, StaticInput } from './types'
export interface Subscriber<T> {
next(v: T)
}
export interface Observable<T> {
subscribe(x: Subscriber<T>)
}
export interface Output<T> extends Observable<T> {
set(x: T, force?: boolean): void
}
export interface Input<T> extends Subscriber<T> {
peak(): T | undefined
}
export type World = {
outputsById: Record<string, Record<string, Output<any>>>
connect: <T>(inputSpec: AppInputTransform, next: (x: T) => void) => Input<T>
}
export function buildWorld(components: Record<string, string[]>) {
const newWorld = buildObservableWorld()
const outputsById: Record<string, Record<string, Output<any>>> = {}
for (const [k, outputs] of Object.entries(components)) {
outputsById[k] = {}
for (const o of outputs) {
outputsById[k][o] = newWorld.newOutput(k, o)
}
}
return { outputsById, connect: newWorld.connect }
}
export function buildObservableWorld() {
const observables: Record<string, Output<any>> = {}
function connect<T>(inputSpec: AppInputTransform, next: (x: T) => void): Input<T> {
if (inputSpec.type === 'static') {
return {
peak: () => inputSpec.value,
next: () => {}
}
} else if (inputSpec.type === 'output') {
const input = cachedInput(next)
let obs = observables[`${inputSpec.id}.${inputSpec.name}`]
if (!obs) {
throw Error('Observable at ' + inputSpec.id + '.' + inputSpec.name + ' not found')
}
obs.subscribe(input)
return input
} else if (inputSpec.type === 'user') {
return {
peak: () => inputSpec.value,
next: () => {}
}
} else {
throw Error('Unknown input type ' + inputSpec)
}
}
function newOutput<T>(id: string, name: string): Output<T> {
const output = settableOutput<T>()
observables[`${id}.${name}`] = output
return output
}
return {
connect,
newOutput
}
}
export function cachedInput<T>(nextParan: (x: T) => void): Input<T> {
let value: T | undefined = undefined
function peak(): T | undefined {
return value
}
function next(x: T): void {
value = x
nextParan(x)
}
return {
peak,
next
}
}
export function settableOutput<T>(): Output<T> {
let value: T | undefined = undefined
const subscribers: Subscriber<T>[] = []
function subscribe(x: Subscriber<T>) {
if (!subscribers.includes(x)) {
subscribers.push(x)
}
}
function set(x: T, force: boolean = false) {
if (value != x || force) {
value = x
subscribers.forEach((x) => x.next(value!))
}
}
return {
subscribe,
set
}
}
+126
View File
@@ -0,0 +1,126 @@
import type { Schema, SchemaProperty } from '$lib/common'
import type { Policy } from '$lib/gen'
import type { Writable } from 'svelte/store'
import type { World } from './rx'
export type UserInput = {
type: 'user'
schemaProperty: SchemaProperty
// Default value override
defaultValue: any
value: any
}
export type DynamicInput = {
type: 'output'
id: FieldID | undefined
name: string | undefined
// Before any connection occures
defaultValue: any
}
export type StaticInput = {
type: 'static'
value: any
visible?: boolean
}
export type AppInputTransform = DynamicInput | StaticInput | UserInput
// Inner inputs, (search, filter, page, inputs of a script or flow)
export type InputsSpec = Record<FieldID, AppInputTransform>
export type ComponentInputsSpec = Record<FieldID, DynamicInput>
export type TextInputComponent = {
type: 'textinputcomponent'
}
export type RunFormComponent = {
type: 'runformcomponent'
path?: string
runType?: 'script' | 'flow'
}
export type BarChartComponent = {
type: 'barchartcomponent'
inputs: {}
}
export type TableComponent = {
type: 'tablecomponent'
inputs: {}
path: string
runType: 'script' | 'flow'
title: string
description: string | undefined
headers: string[]
data: Array<Record<string, any>>
}
export type DisplayComponent = {
type: 'displaycomponent'
}
export type AppComponent =
| (
| RunFormComponent
| DisplayComponent
| TextInputComponent
| BarChartComponent
| TableComponent
) & {
id: ComponentID
title: string
description: string
width: number
horizontalAlignement?: 'left' | 'center' | 'right'
verticalAlignement?: 'top' | 'center' | 'bottom'
configSchema: Schema | undefined
inputs: InputsSpec
componentInputs: ComponentInputsSpec
}
type SectionID = string
export type AppSection = {
title: string
description: string
components: AppComponent[]
id: SectionID
columns: 1 | 2 | 3
}
export type App = {
sections: AppSection[]
title: string
}
export type AppSelection = { sectionIndex: number; componentIndex: number | undefined }
export type ConnectingInput = {
opened: boolean
input?: DynamicInput
}
export type AppEditorContext = {
worldStore: Writable<World | undefined>
staticOutputs: Writable<Record<string, string[]>>
app: Writable<App>
selection: Writable<AppSelection | undefined>
mode: Writable<EditorMode>
schemas: Writable<Schema[]>
connectingInput: Writable<ConnectingInput>
}
export type EditorMode = 'width' | 'dnd' | 'preview'
type FieldID = string
type ComponentID = string
export type EditorConfig = {
staticInputDisabled: boolean
outputInputDisabled: boolean
userInputEnabled: boolean
visibiltyEnabled: boolean
}
+75
View File
@@ -0,0 +1,75 @@
import type { InputsSpec } from './types'
import type { Schema } from '$lib/common'
import { FlowService, ScriptService } from '$lib/gen'
type Args = Record<string, any>
export function buildArgs(
inputSpecs: InputsSpec,
schema: Schema,
includeHidden: boolean = false
): Args {
const obj = Object.keys(schema.properties).reduce((acc, key) => {
let input = inputSpecs[key]
if (!input) {
input = {
type: 'static',
value: '',
visible: true
}
}
if (input.type === 'static' && (input.visible || includeHidden)) {
acc[key] = input.value
}
if (input.type === 'output') {
acc[key] = input.defaultValue
}
if (input.type === 'user') {
acc[key] = schema.properties[key].default
}
return acc
}, {})
return obj
}
export async function loadSchema(
workspace: string,
path: string,
runType: 'script' | 'flow'
): Promise<Schema> {
if (runType === 'script') {
const script = await ScriptService.getScriptByPath({
workspace,
path
})
return script.schema
} else {
const flow = await FlowService.getFlowByPath({
workspace,
path
})
return flow.schema
}
}
export function schemaToInputsSpec(schema: Schema): InputsSpec {
return Object.keys(schema.properties).reduce((accu, key) => {
const property = schema.properties[key]
accu[key] = {
type: 'static',
defaultValue: property.default,
value: undefined,
visible: true
}
return accu
}, {})
}
@@ -95,7 +95,7 @@
<svelte:element
this={href ? 'a' : 'button'}
bind:this={element}
on:click={onClick}
on:click|stopPropagation={onClick}
on:focus
on:blur
{...buttonProps}
@@ -1,24 +1,27 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { faRing } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import Button from '../button/Button.svelte'
import type { ToggleButtonContext } from './ToggleButtonGroup.svelte'
export let value: string
export let value: any
export let position: 'left' | 'center' | 'right'
const { select, selected } = getContext<ToggleButtonContext>('ToggleButtonGroup')
</script>
<button
type="button"
<Button
{...$$props}
on:click={() => select(value)}
class={classNames(
'py-1 px-2 text-sm font-medium border-gray-200 ',
position === 'left' ? 'rounded-l-lg border' : '',
position === 'center' ? 'border-t border-b' : '',
position === 'right' ? 'rounded-r-md border border-l-0' : '',
$selected.includes(value) ? 'text-white bg-gray-900 ' : 'text-gray-900 bg-white '
btnClasses={classNames(
'border-gray-200 focus:ring-0 w-full',
position === 'left' ? 'rounded-none rounded-l-lg border' : '',
position === 'center' ? 'rounded-none border-t border-b' : '',
position === 'right' ? 'rounded-none rounded-r-md !border border-l-0' : ''
)}
color={$selected === value ? 'dark' : 'light'}
variant="contained"
>
<slot />
</button>
</Button>
@@ -1,7 +1,7 @@
<script context="module" lang="ts">
export type ToggleButtonContext = {
selected: Writable<string[]>
select: (value: string) => void
selected: Writable<any>
select: (value: any) => void
}
</script>
@@ -9,31 +9,19 @@
import { setContext } from 'svelte'
import { writable, type Writable } from 'svelte/store'
export let exclusive: boolean = true
export let selected: string[] = []
export let selected: any
const selectedContent = writable(selected)
setContext<ToggleButtonContext>('ToggleButtonGroup', {
selected: selectedContent,
select: (value: string) => {
if (exclusive) {
selectedContent.set([value])
} else {
selectedContent.update((selected) => {
const index = selected.findIndex((val: string) => val === value)
if (index !== -1) {
selected.splice(index, 1)
return selected
} else {
return [value, ...selected]
}
})
}
select: (value: any) => {
selectedContent.set(value)
selected = value
}
})
</script>
<div class="inline-flex rounded-md shadow-sm" role="group">
<div class="flex w-full rounded-md shadow-sm" role="group">
<slot />
</div>
@@ -3,7 +3,7 @@
import Tab from '$lib/components/common/tabs/Tab.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Editor from '$lib/components/Editor.svelte'
import EditorBar, { EDITOR_BAR_WIDTH_THRESHOLD } from '$lib/components/EditorBar.svelte'
import EditorBar from '$lib/components/EditorBar.svelte'
import ModulePreview from '$lib/components/ModulePreview.svelte'
import { createScriptFromInlineScript, fork } from '$lib/components/flows/flowStateUtils'
import { flowStore } from '$lib/components/flows/flowStore'
@@ -14,7 +14,7 @@ import {
NEVER_TESTED_THIS_FAR,
numberToChars
} from './utils'
import { Mutex } from 'async-mutex';
import { Mutex } from 'async-mutex'
export async function loadFlowModuleState(flowModule: FlowModule): Promise<FlowModuleState> {
try {
@@ -30,21 +30,32 @@ export async function loadFlowModuleState(flowModule: FlowModule): Promise<FlowM
}
}
export const idMutex = new Mutex();
export const idMutex = new Mutex()
export function getNextId(currentKeys: string[]): string {
const max = currentKeys.reduce((acc, key) => {
if (key === 'failure' || key.includes('branch') || key.includes('loop')) {
return acc
} else {
const num = charsToNumber(key)
return Math.max(acc, num + 1)
}
}, 0)
return numberToChars(max)
}
// Computes the next available id
export function nextId(): string {
const flowState = get(flowStateStore)
const max = Object.keys(flowState)
.reduce((acc, key) => {
if (key === 'failure' || key.includes('branch') || key.includes('loop')) {
return acc
} else {
const num = charsToNumber(key)
return Math.max(acc, num + 1)
}
}, 0)
const max = Object.keys(flowState).reduce((acc, key) => {
if (key === 'failure' || key.includes('branch') || key.includes('loop')) {
return acc
} else {
const num = charsToNumber(key)
return Math.max(acc, num + 1)
}
}, 0)
return numberToChars(max)
}
@@ -124,7 +135,9 @@ export async function createBranchAll(id: string): Promise<[FlowModule, FlowModu
return [branchesFlowModules, flowModuleState]
}
export async function fork(flowModule: FlowModule): Promise<[FlowModule & { value: RawScript }, FlowModuleState]> {
export async function fork(
flowModule: FlowModule
): Promise<[FlowModule & { value: RawScript }, FlowModuleState]> {
if (flowModule.value.type !== 'script') {
throw new Error('Can only fork a script module')
}
@@ -136,7 +149,10 @@ export async function fork(flowModule: FlowModule): Promise<[FlowModule & { valu
return [forkedFlowModule, flowModuleState]
}
async function createInlineScriptModuleFromPath(path: string, id: string): Promise<FlowModule & { value: RawScript }> {
async function createInlineScriptModuleFromPath(
path: string,
id: string
): Promise<FlowModule & { value: RawScript }> {
const { content, language } = await getScriptByPath(path)
return {
@@ -1,7 +1,6 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import type { IconDefinition } from '@fortawesome/free-brands-svg-icons'
import Icon from 'svelte-awesome'
export let disabled: boolean = false
export let icon: IconDefinition
@@ -24,7 +24,7 @@
} from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import { check } from 'svelte-awesome/icons'
import { Badge, Button } from '../common'
import { Badge } from '../common'
const SMALL_ICON_SCALE = 0.7
+17 -3
View File
@@ -44,7 +44,10 @@ export function displayDaysAgo(dateString: string): string {
if (dAgo == 0) {
return `yday at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
} else if (dAgo > 7) {
return `${dAgo + 1} days ago at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
return `${dAgo + 1} days ago at ${date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}`
} else {
return displayDate(dateString)
}
@@ -56,8 +59,9 @@ export function displayDate(dateString: string | undefined): string {
if (date.toString() === 'Invalid Date') {
return ''
} else {
return `${date.getFullYear()}/${date.getMonth() + 1
}/${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
return `${date.getFullYear()}/${
date.getMonth() + 1
}/${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
}
}
@@ -557,3 +561,13 @@ export async function copyToClipboard(value: string, sendToast = true): Promise<
sendUserToast(success ? 'Copied to clipboard!' : "Couldn't copy to clipboard", !success)
return success
}
export function pluralize(quantity: number, word: string, customPlural?: string) {
if (quantity <= 1) {
return `${quantity} ${word}`
} else if (customPlural) {
return `${quantity} ${customPlural}}`
} else {
return `${quantity} ${word}s`
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
goto('/user/login')
}
beforeNavigate((newNavigationState) => {
beforeNavigate(() => {
menuOpen = false
})
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { goto } from '$app/navigation'
import type { App } from '$lib/components/apps/types'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { DrawerContent, Skeleton } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Path from '$lib/components/Path.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { AppService, ListableApp, Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { faEdit, faPlus } from '@fortawesome/free-solid-svg-icons'
let path: string = ''
let initialPath = ''
let pathError = ''
let apps: ListableApp[] | undefined = undefined
async function createApp() {
const appJson: App = {
sections: [],
title: 'New app'
}
const policy = {
triggerables: {},
execution_mode: Policy.execution_mode.PUBLISHER,
on_behalf_of: `u/${$userStore?.username}`
}
try {
const appId = await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: appJson,
path,
summary: 'App summary',
policy
}
})
goto(`/apps/edit/${appId}`)
} catch (e) {
sendUserToast('Error creating app', e)
}
}
async function loadApps(): Promise<void> {
apps = await AppService.listApps({ workspace: $workspaceStore! })
}
let drawerOpen = false
function closeDrawer() {
drawerOpen = false
}
$: {
if ($workspaceStore && $userStore) {
loadApps()
}
}
</script>
<Drawer bind:open={drawerOpen} size="800px">
<DrawerContent title="Add an app" on:close={() => closeDrawer()}>
<Path bind:error={pathError} bind:path {initialPath} namePlaceholder="my_app" kind="app">
<div slot="ownerToolkit">
App permissions depend on their path. Select the group <span class="font-mono">all</span>
to share it, and <span class="font-mono">user</span> to keep it private.
<a href="https://docs.windmill.dev/docs/reference/namespaces">docs</a>
</div>
</Path>
<Button on:click={() => createApp()}>Create app</Button>
</DrawerContent>
</Drawer>
<CenteredPage>
<PageHeader title="Apps">
<Button size="sm" startIcon={{ icon: faPlus }} on:click={() => (drawerOpen = true)}>
New app
</Button>
</PageHeader>
<div class="p-4 border ">
{#if !apps}
<div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-3 mt-2">
{#each new Array(3) as _}
<Skeleton layout={[[8.5]]} />
{/each}
</div>
{:else if apps?.length == 0}
<p class="text-xs text-gray-600 italic mt-2">No scripts yet</p>
{:else if apps}
<div class="grid md:grid-cols-2 gap-4 sm:grid-cols-1 xl:grid-cols-3 mt-2">
{#each apps as { summary, path, extra_perms }}
<a
class="border p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between"
href="/apps/get/{path}"
>
<div class="flex flex-col gap-1">
<a href="/apps/get/{path}" class="px-6">
<div class="font-semibold text-gray-700">
{!summary || summary.length == 0 ? path : summary}
</div>
<p class="text-gray-700 text-xs">
{path}
</p>
</a>
<div class="flex flex-wrap items-center gap-2 mt-1 px-6">
<SharedBadge canWrite={true} extraPerms={extra_perms} />
</div>
</div>
<div class="flex flex-row-reverse items-end w-full gap-2 pr-2 mt-2">
<div>
<Button
variant="border"
size="xs"
startIcon={{ icon: faEdit }}
href="/apps/edit/{path}"
>
Edit
</Button>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
</CenteredPage>
@@ -0,0 +1,29 @@
<script lang="ts">
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import { AppService, AppWithLastVersion } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { page } from '$app/stores'
let app: AppWithLastVersion | undefined = undefined
let path = $page.params.path
async function loadApp(): Promise<void> {
app = await AppService.getAppByPath({
path,
workspace: $workspaceStore!
})
console.log(app)
}
$: {
if ($workspaceStore) {
loadApp()
}
}
</script>
{#if app}
<div class="h-screen">
<AppEditor app={app.value} />
</div>
{/if}
@@ -0,0 +1,35 @@
<script context="module">
export function load({ params }) {
return {
stuff: { title: `Script ${params.hash}` }
}
}
</script>
<script lang="ts">
import { page } from '$app/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Skeleton } from '$lib/components/common'
import { AppService, AppWithLastVersion } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
let app: AppWithLastVersion | undefined = undefined
async function loadApp() {
app = await AppService.getAppByPath({ workspace: $workspaceStore!, path: $page.params.path })
}
$: if ($workspaceStore) {
loadApp()
}
</script>
<Skeleton loading={app == undefined} layout={[10]} />
<CenteredPage>
<a href="/apps">Back to apps</a>
{#if app}
<a href="/apps/edit/{$page.params.path}">Edit</a>
<div>{JSON.stringify(app, null, 4)} </div>
{/if}
</CenteredPage>
+133 -93
View File
@@ -1,15 +1,10 @@
const plugin = require('tailwindcss/plugin');
const plugin = require('tailwindcss/plugin')
/** @type {import('tailwindcss').Config} */
const config = {
content: [
'./src/**/*.{html,js,svelte,ts}'
],
safelist: [
'hljs',
'splitpanes__pane',
'splitpanes__splitter'
],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['hljs', 'splitpanes__pane', 'splitpanes__splitter'],
safelist: ['hljs', 'splitpanes__pane', 'splitpanes__splitter'],
theme: {
colors: {
current: 'currentcolor',
@@ -25,7 +20,7 @@ const config = {
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
900: '#111827'
},
red: {
50: '#fef2f2',
@@ -37,7 +32,7 @@ const config = {
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
900: '#7f1d1d'
},
orange: {
100: '#ffedd5',
@@ -46,8 +41,7 @@ const config = {
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#c2410c',
800: '#c2410c'
},
yellow: {
50: '#fefce8',
@@ -59,7 +53,7 @@ const config = {
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12',
900: '#713f12'
},
green: {
50: '#f0fdf4',
@@ -71,7 +65,7 @@ const config = {
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
900: '#14532d'
},
blue: {
50: '#eff6ff',
@@ -83,7 +77,7 @@ const config = {
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
900: '#1e3a8a'
},
indigo: {
100: '#e0e7ff',
@@ -91,13 +85,22 @@ const config = {
300: '#a5b4fc',
500: '#6366f1',
800: '#3730a3',
900: '#312e81',
900: '#312e81'
}
},
fontFamily: {
// add double quotes if there is space in font name
main: ['Inter', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace']
mono: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace'
]
},
extend: {
maxHeight: {
@@ -111,7 +114,6 @@ const config = {
'1/2': '50%',
'2/3': '66%',
'3/4': '75%'
},
minHeight: {
'1/2': '50vh'
@@ -120,16 +122,16 @@ const config = {
'2/3': '66vh'
},
transitionProperty: {
'height': 'height'
height: 'height'
},
fontSize: {
'2xs': '0.7rem'
},
screens: {
'fhd': '1900px',
'qhd': '2500px',
'4k': '3800px',
},
fhd: '1900px',
qhd: '2500px',
'4k': '3800px'
}
}
},
@@ -138,105 +140,137 @@ const config = {
require('@tailwindcss/typography'),
plugin(({ addBase, addComponents, addUtilities, theme }) => {
addBase({
'html': {
html: {
fontFamily: theme('fontFamily.main'),
fontSize: theme('fontSize.base'),
fontWeight: theme('fontWeight.normal'),
color: theme('colors.gray.900'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: theme('fontSize.lg'),
},
fontSize: theme('fontSize.lg')
}
},
'h1': {
h1: {
fontSize: '24px',
fontWeight: theme('fontWeight.extrabold'),
lineHeight: '1.05',
color: theme('colors.gray.800'),
[`@media (min-width: ${theme('screens.lg')})`]: {
fontSize: '26px',
fontSize: '26px'
},
[`@media (min-width: ${theme('screens.fhd')})`]: {
fontSize: '29px',
fontSize: '29px'
},
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '34px',
},
fontSize: '34px'
}
},
'h2': {
h2: {
fontSize: '20px',
fontWeight: theme('fontWeight.extrabold'),
lineHeight: '1.1',
color: theme('colors.gray.700'),
[`@media (min-width: ${theme('screens.fhd')})`]: {
fontSize: '22px',
fontSize: '22px'
},
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '25px',
},
fontSize: '25px'
}
},
'h3': {
h3: {
fontSize: '18px',
fontWeight: theme('fontWeight.bold'),
lineHeight: '1.2',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.fhd')})`]: {
fontSize: '20px',
fontSize: '20px'
},
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '22px',
},
fontSize: '22px'
}
},
'h4': {
h4: {
fontSize: '18px',
fontWeight: theme('fontWeight.semibold'),
lineHeight: '1.3',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '20px',
},
fontSize: '20px'
}
},
'h5': {
h5: {
fontSize: '16px',
fontWeight: theme('fontWeight.semibold'),
lineHeight: '1.5',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '18px',
},
fontSize: '18px'
}
},
'h6': {
h6: {
fontSize: '16px',
fontWeight: theme('fontWeight.medium'),
lineHeight: '1.5',
color: theme('colors.gray.600'),
[`@media (min-width: ${theme('screens.qhd')})`]: {
fontSize: '18px',
},
},
'button': {
fontWeight: theme('fontWeight.semibold'),
},
'a': {
color: theme('colors.blue.500')
},
'input,input[type="text"],input[type="email"],input[type="url"],input[type="password"],input[type="number"],input[type="date"],input[type="datetime-local"],input[type="month"],input[type="search"],input[type="tel"],input[type="time"],input[type="week"],textarea,textarea[type="text"],select': {
display: 'block',
fontSize: theme('fontSize.sm'),
width: '100%',
padding: `${theme('spacing.1')} ${theme('spacing.2')}`,
border: `1px solid ${theme('colors.gray.300')}`,
borderRadius: theme('borderRadius.md'),
boxShadow: theme('boxShadow.sm'),
'&:focus': {
'--tw-ring-color': theme('colors.indigo.100'),
'--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
},
'&:disabled,[disabled]': {
backgroundColor: theme('colors.gray.100') + ' !important'
fontSize: '18px'
}
},
button: {
fontWeight: theme('fontWeight.semibold')
},
a: {
color: theme('colors.blue.500')
},
'input,input[type="text"],input[type="email"],input[type="url"],input[type="password"],input[type="number"],input[type="date"],input[type="datetime-local"],input[type="month"],input[type="search"],input[type="tel"],input[type="time"],input[type="week"],textarea,textarea[type="text"],select':
{
display: 'block',
fontSize: theme('fontSize.sm'),
width: '100%',
padding: `${theme('spacing.1')} ${theme('spacing.2')}`,
border: `1px solid ${theme('colors.gray.300')}`,
borderRadius: theme('borderRadius.md'),
boxShadow: theme('boxShadow.sm'),
'&:focus': {
'--tw-ring-color': theme('colors.indigo.100'),
'--tw-ring-offset-shadow':
'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow':
'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
boxShadow:
'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
},
'&:disabled,[disabled]': {
backgroundColor: theme('colors.gray.100') + ' !important'
}
},
button: {
fontWeight: theme('fontWeight.semibold')
},
a: {
color: theme('colors.blue.500')
},
'input,input[type="text"],input[type="email"],input[type="url"],input[type="password"],input[type="number"],input[type="date"],input[type="datetime-local"],input[type="month"],input[type="search"],input[type="tel"],input[type="time"],input[type="week"],textarea,select':
{
display: 'block',
fontSize: theme('fontSize.sm'),
width: '100%',
padding: `${theme('spacing.1')} ${theme('spacing.2')}`,
border: `1px solid ${theme('colors.gray.300')}`,
borderRadius: theme('borderRadius.md'),
boxShadow: theme('boxShadow.sm'),
'&:focus': {
'--tw-ring-color': theme('colors.indigo.100'),
'--tw-ring-offset-shadow':
'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow':
'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
boxShadow:
'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)'
},
'&:disabled,[disabled]': {
backgroundColor: theme('colors.gray.100') + ' !important'
}
},
'button:disabled,button[disabled=true],a:disabled,a[disabled=true]': {
pointerEvents: 'none',
cursor: 'default',
@@ -248,8 +282,8 @@ const config = {
fontSize: theme('fontSize.sm') + ' !important',
lineHeight: theme('lineHeight.4') + ' !important',
whiteSpace: 'pre-wrap'
},
});
}
})
addComponents({
'.table-custom': {
'& th': {
@@ -261,24 +295,24 @@ const config = {
textAlign: 'left',
fontWeight: theme('fontWeight.semibold'),
color: theme('colors.gray.900'),
textTransform: 'capitalize',
textTransform: 'capitalize'
},
'& td': {
paddingRight: theme('spacing.2'),
paddingTop: theme('spacing.2'),
paddingBottom: theme('spacing.2'),
fontSize: theme('fontSize.sm'),
color: theme('colors.gray.700'),
color: theme('colors.gray.700')
},
'& tbody > :not([hidden]) ~ :not([hidden])': {
borderTop: `1px solid ${theme('colors.gray.200')}`
},
}
},
'.commit-hash': {
fontSize: theme('fontSize.2xs'),
color: theme('colors.gray.500'),
backgroundColor: theme('colors.gray.200'),
fontFamily: theme('fontFamily.mono'),
fontFamily: theme('fontFamily.mono')
},
'.input-error': {
borderColor: `${theme('colors.red.500')} !important`
@@ -292,23 +326,25 @@ const config = {
'.animate-skeleton': {
animation: theme('animation.pulse'),
backgroundColor: theme('colors.blue.100'),
borderRadius: theme('borderRadius.DEFAULT'),
borderRadius: theme('borderRadius.DEFAULT')
},
'.text-blue-gradient': {
color: 'transparent',
backgroundClip: 'text',
backgroundImage: `linear-gradient(to right, ${theme('colors.blue.600')}, ${theme('colors.blue.500')})`
backgroundImage: `linear-gradient(to right, ${theme('colors.blue.600')}, ${theme(
'colors.blue.500'
)})`
},
'.splitpanes__pane': {
backgroundColor: theme('colors.white') + ' !important',
overflow: 'auto !important',
overflow: 'auto !important'
},
'.splitpanes__splitter': {
backgroundColor: theme('colors.gray.300') + ' !important',
margin: '0 !important',
border: 'none !important',
'&::before': {
backgroundColor: '#00000060 !important',
backgroundColor: '#00000060 !important'
},
'&::after': {
backgroundColor: '#3f83f850 !important',
@@ -321,7 +357,7 @@ const config = {
'--splitter-hover-adjustment': '-2px'
},
'&:hover::after': {
opacity: '1',
opacity: '1'
}
},
'.splitpanes--vertical>.splitpanes__splitter': {
@@ -329,13 +365,13 @@ const config = {
'&::before': {
left: '1px !important',
width: '1px !important',
marginLeft: '0 !important',
marginLeft: '0 !important'
},
'&::after': {
top: '0 !important',
height: '100% !important',
left: 'var(--splitter-hover-adjustment) !important',
width: 'var(--splitter-hover-size) !important',
width: 'var(--splitter-hover-size) !important'
}
},
'.splitpanes--horizontal>.splitpanes__splitter': {
@@ -343,16 +379,16 @@ const config = {
'&::before': {
top: '1px !important',
height: '1px !important',
marginTop: '0 !important',
marginTop: '0 !important'
},
'&::after': {
top: 'var(--splitter-hover-adjustment) !important',
height: 'var(--splitter-hover-size) !important',
left: '0 !important',
width: '100% !important',
width: '100% !important'
}
}
});
})
addUtilities({
'.separator': {
backgroundColor: '#ddd !important'
@@ -360,15 +396,15 @@ const config = {
'.center-center': {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignItems: 'center'
},
'.ellipsize': {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
textOverflow: 'ellipsis'
},
/** Set the '-webkit-line-clamp' property to the desired number of lines.
*
*
* Eg.: `class="ellipsize-multi-line [-webkit-line-clamp:3]"`
*/
'.ellipsize-multi-line': {
@@ -386,13 +422,17 @@ const config = {
'scrollbar-width': 'none',
'&::-webkit-scrollbar': {
display: 'none',
width: '0px',
},
width: '0px'
}
}
})
})
],
darkMode: 'class',
purge: [
'./src/**/*.{html,js,svelte,ts}',
'./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'
]
}
module.exports = config