Compare commits

...

26 Commits

Author SHA1 Message Date
Faton Ramadani
a3373edddf Merge branch 'main' into app-components-outputs 2023-03-13 15:25:03 +01:00
Faton Ramadani
71da2f67e6 feat(frontend): improving connection 2023-03-13 15:24:47 +01:00
Ruben Fiszel
75f87e7e11 feat(frontend): disabled for action buttons can now depend on row 2023-03-13 14:36:42 +01:00
Ádám Kovács
3e5a179eb8 fix(frontend): General fixes and updates (#1281)
* fix(frontend): App icon select double click issue

* fix(frontend): Update script metadata page

* fix(frontend): Set dropdown default icon to vertical dots

* fix(frontend): Clean up

* fix(frontend): Update table styles

* fix(frontend): Add spacing to secondary menu items

* fix(frontend): Scale down full path

* fix(frontend): Table loading state

* fix(frontend): Hide script kind setting by default
2023-03-13 14:17:57 +01:00
Ruben Fiszel
c082c6350e fix runs reload 2023-03-13 13:00:23 +01:00
Ruben Fiszel
cfd489a550 feat(frontend): improve drag-n-drop behavior 2023-03-13 12:44:39 +01:00
Ruben Fiszel
1f4ae53fb4 integrate svelte-grid in codebase 2023-03-13 10:01:02 +01:00
Faton Ramadani
0f4da49886 Merge branch 'main' into app-components-outputs 2023-03-13 09:01:48 +01:00
Faton Ramadani
ae81d7ee2c feat(frontend): wip 2023-03-13 09:01:36 +01:00
Ruben Fiszel
0dcbf270da small app fixes 2023-03-12 21:32:41 +01:00
Faton Ramadani
82c139ed09 feat(frontend): Copy, Cut and Paste (#1279)
* feat(frontend): add copy, paste and cut

* feat(frontend): simplify code

* feat(frontend): add apple modifiers

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2023-03-12 20:58:06 +01:00
Faton Ramadani
98f8b845a6 feat(frontend): wip 2023-03-12 19:12:54 +01:00
Faton Ramadani
f521fb63fa feat(frontend): working animations 2023-03-12 14:17:25 +01:00
Faton Ramadani
fce464434b feat(frontend): working animations 2023-03-12 14:09:27 +01:00
Faton Ramadani
ee18804c87 feat(frontend): wip 2023-03-12 11:25:03 +01:00
Ruben Fiszel
0789bef120 chore(main): release 1.75.0 (#1278)
* chore(main): release 1.75.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-03-11 20:54:54 +01:00
Ruben Fiszel
1a7dc0a3bd more cli fixes 2023-03-11 20:53:34 +01:00
Ruben Fiszel
ce323709a9 fix(cli): many small fixes 2023-03-11 20:16:56 +01:00
Ruben Fiszel
61a5e1f1ac feat(frontend): make runs filters synced with query args 2023-03-11 17:13:49 +01:00
Ruben Fiszel
3b44f9a72c feat: add filter jobs by args or result 2023-03-11 15:30:46 +01:00
Ruben Fiszel
b349308ff7 handle better linked variables renaming 2023-03-11 11:25:47 +01:00
Faton Ramadani
6b5963f9d2 feat(frontend): merge main 2023-03-11 00:14:49 +01:00
Ruben Fiszel
f87b722a21 apps improvements 2023-03-10 23:27:55 +01:00
Faton Ramadani
d00a80a8f6 feat(frontend): hierarchical output panel WIP 2023-03-10 21:59:13 +01:00
Ruben Fiszel
0e9be7f300 fix for app viewer 2023-03-10 20:26:49 +01:00
Ruben Fiszel
8681e83b57 feat(apps): add resource picker 2023-03-10 20:01:00 +01:00
132 changed files with 2828 additions and 1578 deletions

View File

@@ -1,6 +1,23 @@
# Changelog
## [1.75.0](https://github.com/windmill-labs/windmill/compare/v1.74.2...v1.75.0) (2023-03-11)
### Features
* add filter jobs by args or result ([3b44f9a](https://github.com/windmill-labs/windmill/commit/3b44f9a72ca0466a44963a4b9657a0ee59b44753))
* **apps:** add resource picker ([8681e83](https://github.com/windmill-labs/windmill/commit/8681e83b574141acbf7e5a389a9e8a4f340336d1))
* **bash:** add default argument handling for bash ([1d5c194](https://github.com/windmill-labs/windmill/commit/1d5c194f09ffba963d52e418c5954843d84ae337))
* **frontend-apps:** add variable picker for static string input on apps ([bc440f8](https://github.com/windmill-labs/windmill/commit/bc440f8d4154ce464c0e027d93b7a0a3b76d782e))
* **frontend:** make runs filters synced with query args ([61a5e1f](https://github.com/windmill-labs/windmill/commit/61a5e1f1accc988628b785b3b9be04c4ea719874))
### Bug Fixes
* **backend:** add killpill for lines reading ([7c825c2](https://github.com/windmill-labs/windmill/commit/7c825c212dd0f1e8be427eabd9a9756303241d1b))
* **cli:** many small fixes ([ce32370](https://github.com/windmill-labs/windmill/commit/ce323709a94d27fb24214719180ea1aafc66d646))
## [1.74.2](https://github.com/windmill-labs/windmill/compare/v1.74.1...v1.74.2) (2023-03-09)

84
backend/Cargo.lock generated
View File

@@ -1123,9 +1123,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84"
checksum = "531ac96c6ff5fd7c62263c5e3c67a603af4fcaee2e1a0ae5565ba3a11e69e549"
dependencies = [
"futures-channel",
"futures-core",
@@ -1138,9 +1138,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5"
checksum = "164713a5a0dcc3e7b4b1ed7d3b433cabc18025386f9339346e8daf15963cf7ac"
dependencies = [
"futures-core",
"futures-sink",
@@ -1148,15 +1148,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608"
checksum = "86d7a0c1aa76363dac491de0ee99faf6941128376f1cf96f07db7603b7de69dd"
[[package]]
name = "futures-executor"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e"
checksum = "1997dd9df74cdac935c76252744c1ed5794fac083242ea4fe77ef3ed60ba0f83"
dependencies = [
"futures-core",
"futures-task",
@@ -1176,9 +1176,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb8371b6fb2aeb2d280374607aeabfc99d95c72edfe51692e42d3d7f0d08531"
checksum = "89d422fa3cbe3b40dca574ab087abb5bc98258ea57eea3fd6f1fa7162c778b91"
[[package]]
name = "futures-lite"
@@ -1197,9 +1197,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70"
checksum = "3eb14ed937631bd8b8b8977f2c198443447a8355b6e3ca599f38c975e5a963b6"
dependencies = [
"proc-macro2",
"quote",
@@ -1208,21 +1208,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364"
checksum = "ec93083a4aecafb2a80a885c9de1f0ccae9dbd32c2bb54b0c3a65690e0b8d2f2"
[[package]]
name = "futures-task"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366"
checksum = "fd65540d33b37b16542a0438c12e6aeead10d4ac5d05bd3f805b8f35ab592879"
[[package]]
name = "futures-util"
version = "0.3.26"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1"
checksum = "3ef6b17e481503ec85211fed8f39d1970f128935ca1f814cd32ac4a6842e84ab"
dependencies = [
"futures-channel",
"futures-core",
@@ -1503,9 +1503,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "hyper"
version = "0.14.24"
version = "0.14.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c"
checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899"
dependencies = [
"bytes",
"futures-channel",
@@ -2953,7 +2953,7 @@ dependencies = [
[[package]]
name = "rustpython-ast"
version = "0.2.0"
source = "git+https://github.com/RustPython/RustPython#87728c44527272ece739cddbb4942ad7e176dd79"
source = "git+https://github.com/RustPython/RustPython#415cdb1ef9b832fbca858a05f6894087e133dd31"
dependencies = [
"num-bigint",
"rustpython-compiler-core",
@@ -2962,7 +2962,7 @@ dependencies = [
[[package]]
name = "rustpython-compiler-core"
version = "0.2.0"
source = "git+https://github.com/RustPython/RustPython#87728c44527272ece739cddbb4942ad7e176dd79"
source = "git+https://github.com/RustPython/RustPython#415cdb1ef9b832fbca858a05f6894087e133dd31"
dependencies = [
"bitflags",
"bstr",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "rustpython-parser"
version = "0.2.0"
source = "git+https://github.com/RustPython/RustPython#87728c44527272ece739cddbb4942ad7e176dd79"
source = "git+https://github.com/RustPython/RustPython#415cdb1ef9b832fbca858a05f6894087e133dd31"
dependencies = [
"ahash",
"anyhow",
@@ -3667,9 +3667,9 @@ dependencies = [
[[package]]
name = "swc_common"
version = "0.29.35"
version = "0.29.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d515be281f603cb97afaa89896aca5e5c748fde11c7f926e35cdaa8ff8da705"
checksum = "5005cd73617e18592faa31298225b26f1c407b84a681d67efb735c3d3458e101"
dependencies = [
"ahash",
"ast_node",
@@ -4052,9 +4052,9 @@ checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622"
[[package]]
name = "toml_edit"
version = "0.19.4"
version = "0.19.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1eb0622d28f4b9c90adc4ea4b2b46b47663fde9ac5fafcb14a1369d5508825"
checksum = "7082a95d48029677a28f181e5f6422d0c8339ad8396a39d3f33d62a90c1f6c30"
dependencies = [
"indexmap",
"toml_datetime",
@@ -4274,7 +4274,7 @@ checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
[[package]]
name = "typify"
version = "0.0.11-dev"
source = "git+https://github.com/oxidecomputer/typify#18e7faf1957812626e3bf459e20fbe16785de2d4"
source = "git+https://github.com/oxidecomputer/typify#d1cae0f7d5115782c9557a46396dae786ad5f592"
dependencies = [
"typify-impl",
"typify-macro",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "typify-impl"
version = "0.0.11-dev"
source = "git+https://github.com/oxidecomputer/typify#18e7faf1957812626e3bf459e20fbe16785de2d4"
source = "git+https://github.com/oxidecomputer/typify#d1cae0f7d5115782c9557a46396dae786ad5f592"
dependencies = [
"heck",
"log",
@@ -4301,7 +4301,7 @@ dependencies = [
[[package]]
name = "typify-macro"
version = "0.0.11-dev"
source = "git+https://github.com/oxidecomputer/typify#18e7faf1957812626e3bf459e20fbe16785de2d4"
source = "git+https://github.com/oxidecomputer/typify#d1cae0f7d5115782c9557a46396dae786ad5f592"
dependencies = [
"proc-macro2",
"quote",
@@ -4742,7 +4742,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"axum",
@@ -4769,7 +4769,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"argon2",
@@ -4824,7 +4824,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"base64 0.21.0",
"chrono",
@@ -4839,7 +4839,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"chrono",
"serde",
@@ -4852,7 +4852,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"axum",
@@ -4877,7 +4877,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"serde",
"serde_json",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"itertools",
@@ -4900,7 +4900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"itertools",
@@ -4912,7 +4912,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"itertools",
@@ -4927,7 +4927,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"deno_core",
@@ -4941,7 +4941,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"chrono",
@@ -4964,7 +4964,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.74.2"
version = "1.75.0"
dependencies = [
"anyhow",
"async-recursion",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.74.2"
version = "1.75.0"
authors.workspace = true
edition.workspace = true
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "1.74.2"
version = "1.75.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"

View File

@@ -2984,20 +2984,6 @@
},
"query": "UPDATE group_ SET summary = $1 WHERE name = $2 AND workspace_id = $3"
},
"83fc9bf32b9b40987feff61fea82e20613e74efb7bf8ba52485566667ccf2284": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
}
},
"query": "UPDATE resource SET path = $1 WHERE path = $2 AND workspace_id = $3"
},
"8543f029d9784234e4c6a6dcd7b03e62d544b98be261334ee210594e0bb839f2": {
"describe": {
"columns": [
@@ -3168,6 +3154,21 @@
},
"query": "INSERT INTO usage (id, is_workspace, month_, usage) \n VALUES ($1, false, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 0) \n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1 \n RETURNING usage.usage"
},
"8c0131a9cc61f2daa258d49767242bcaab6bb34a977ff7fb0c18aa9202d11f47": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Text"
]
}
},
"query": "UPDATE resource SET path = $1, value = $2 WHERE path = $3 AND workspace_id = $4"
},
"8c11511a74a41a65f448249a00ebe6964a61d00c2f7b4875a55e64741bf1f0ca": {
"describe": {
"columns": [
@@ -4697,6 +4698,27 @@
},
"query": "SELECT null FROM queue WHERE id = $1 FOR UPDATE"
},
"be1de9116b18a40681cb8c5ec7578fd7c5f7ce77f7af63f766c2899d85fe0bef": {
"describe": {
"columns": [
{
"name": "value",
"ordinal": 0,
"type_info": "Jsonb"
}
],
"nullable": [
true
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2"
},
"be7a99a5bb6858323ca61dd51077010f51ba58ae76b9a413339255024dcb524d": {
"describe": {
"columns": [],

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.74.2
version: 1.75.0
title: Windmill API
contact:
@@ -3168,12 +3168,14 @@ paths:
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/Success"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/Suspended"
- $ref: "#/components/parameters/Running"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/ResultFilter"
responses:
"200":
description: All available queued jobs
@@ -3198,10 +3200,12 @@ paths:
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/Success"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/ResultFilter"
- name: is_skipped
description: is the job skipped
in: query
@@ -3235,9 +3239,11 @@ paths:
- $ref: "#/components/parameters/ScriptExactPath"
- $ref: "#/components/parameters/ScriptStartPath"
- $ref: "#/components/parameters/ScriptExactHash"
- $ref: "#/components/parameters/CreatedBefore"
- $ref: "#/components/parameters/CreatedAfter"
- $ref: "#/components/parameters/StartedBefore"
- $ref: "#/components/parameters/StartedAfter"
- $ref: "#/components/parameters/JobKinds"
- $ref: "#/components/parameters/ArgsFilter"
- $ref: "#/components/parameters/ResultFilter"
- name: is_skipped
description: is the job skipped
in: query
@@ -4614,15 +4620,15 @@ components:
in: query
schema:
type: string
CreatedBefore:
name: created_before
StartedBefore:
name: started_before
description: filter on created before (inclusive) timestamp
in: query
schema:
type: string
format: date-time
CreatedAfter:
name: created_after
StartedAfter:
name: started_after
description: filter on created after (exclusive) timestamp
in: query
schema:
@@ -4646,6 +4652,19 @@ components:
in: query
schema:
type: boolean
ArgsFilter:
name: args
description: filter on jobs containing those args as a json subset (@> in postgres)
in: query
schema:
type: string
ResultFilter:
name: result
description: filter on jobs containing those result as a json subset (@> in postgres)
in: query
schema:
type: string
After:
name: after
description: filter on created after (exclusive) timestamp

View File

@@ -16,10 +16,9 @@ use axum::{
routing::{delete, get, post},
Json, Router,
};
use itertools::Itertools;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{self, Error, JsonResult, Result},
error::{self, to_anyhow, Error, JsonResult, Result},
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
@@ -262,13 +261,26 @@ async fn update_folder(
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
if let Some(display_name) = ng.display_name {
sqlb.set("display_name", display_name);
sqlb.set("display_name", "?".bind(&display_name));
}
if let Some(owners) = ng.owners {
sqlb.set_str("owners", format!("{{{}}}", owners.into_iter().join(",")));
sqlb.set(
"owners",
"?".bind(&format!(
"{{{}}}",
owners
.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(","),
)),
);
}
if let Some(extra_perms) = ng.extra_perms {
sqlb.set_str("extra_perms", extra_perms.to_string());
sqlb.set(
"extra_perms",
"?".bind(&serde_json::to_string(&extra_perms).map_err(to_anyhow)?),
);
}
sqlb.returning("*");

View File

@@ -327,13 +327,15 @@ pub struct ListQueueQuery {
pub script_path_exact: Option<String>,
pub script_hash: Option<String>,
pub created_by: Option<String>,
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
pub created_after: Option<chrono::DateTime<chrono::Utc>>,
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
pub running: Option<bool>,
pub parent_job: Option<String>,
pub order_desc: Option<bool>,
pub job_kinds: Option<String>,
pub suspended: Option<bool>,
// filter by matching a subset of the args using base64 encoded json subset
pub args: Option<String>,
}
fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> SqlBuilder {
@@ -362,11 +364,11 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
if let Some(pj) = &lq.parent_job {
sqlb.and_where_eq("parent_job", "?".bind(pj));
}
if let Some(dt) = &lq.created_before {
sqlb.and_where_lt("created_at", format!("to_timestamp({})", dt.timestamp()));
if let Some(dt) = &lq.started_before {
sqlb.and_where_le("started_at", format!("to_timestamp({})", dt.timestamp()));
}
if let Some(dt) = &lq.created_after {
sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp()));
if let Some(dt) = &lq.started_after {
sqlb.and_where_ge("started_at", format!("to_timestamp({})", dt.timestamp()));
}
if let Some(s) = &lq.suspended {
@@ -376,6 +378,7 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
sqlb.and_where_eq("suspend", 0);
}
}
if let Some(jk) = &lq.job_kinds {
sqlb.and_where_in(
"job_kind",
@@ -383,6 +386,10 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
);
}
if let Some(args) = &lq.args {
sqlb.and_where("args @> ?".bind(&args.replace("'", "''")));
}
sqlb
}
@@ -455,13 +462,14 @@ async fn list_jobs(
script_path_exact: lq.script_path_exact,
script_hash: lq.script_hash,
created_by: lq.created_by,
created_before: lq.created_before,
created_after: lq.created_after,
started_before: lq.started_before,
started_after: lq.started_after,
running: None,
parent_job: lq.parent_job,
order_desc: Some(true),
job_kinds: lq.job_kinds,
suspended: lq.suspended,
args: lq.args,
},
&[
"'QueuedJob' as typ",
@@ -1136,15 +1144,15 @@ where
struct InPayload {
payload: Option<String>,
}
fn decode_payload<D: DeserializeOwned, T: AsRef<[u8]>>(t: T) -> anyhow::Result<D> {
let vec = base64::engine::general_purpose::URL_SAFE
.decode(t)
.context("invalid base64")?;
serde_json::from_slice(vec.as_slice()).context("invalid json")
}
}
}
fn decode_payload<D: DeserializeOwned, T: AsRef<[u8]>>(t: T) -> anyhow::Result<D> {
let vec = base64::engine::general_purpose::URL_SAFE
.decode(t)
.context("invalid base64")?;
serde_json::from_slice(vec.as_slice()).context("invalid json")
}
pub async fn run_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
@@ -1687,11 +1695,11 @@ fn list_completed_jobs_query(
if let Some(pj) = &lq.parent_job {
sqlb.and_where_eq("parent_job", "?".bind(pj));
}
if let Some(dt) = &lq.created_before {
sqlb.and_where_lt("created_at", format!("to_timestamp({})", dt.timestamp()));
if let Some(dt) = &lq.started_before {
sqlb.and_where_le("started_at", format!("to_timestamp({})", dt.timestamp()));
}
if let Some(dt) = &lq.created_after {
sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp()));
if let Some(dt) = &lq.started_after {
sqlb.and_where_ge("started_at", format!("to_timestamp({})", dt.timestamp()));
}
if let Some(sk) = &lq.is_skipped {
sqlb.and_where_eq("is_skipped", sk);
@@ -1706,6 +1714,15 @@ fn list_completed_jobs_query(
);
}
if let Some(args) = &lq.args {
sqlb.and_where("args @> ?".bind(&args.replace("'", "''")));
}
if let Some(result) = &lq.result {
sqlb.and_where("result @> ?".bind(&result.replace("'", "''")));
}
tracing::info!("{:?}", sqlb.sql());
sqlb
}
#[derive(Deserialize, Clone)]
@@ -1714,8 +1731,8 @@ pub struct ListCompletedQuery {
pub script_path_exact: Option<String>,
pub script_hash: Option<String>,
pub created_by: Option<String>,
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
pub created_after: Option<chrono::DateTime<chrono::Utc>>,
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
pub success: Option<bool>,
pub parent_job: Option<String>,
pub order_desc: Option<bool>,
@@ -1723,6 +1740,10 @@ pub struct ListCompletedQuery {
pub is_skipped: Option<bool>,
pub is_flow_step: Option<bool>,
pub suspended: Option<bool>,
// filter by matching a subset of the args using base64 encoded json subset
pub args: Option<String>,
// filter by matching a subset of the result using base64 encoded json subset
pub result: Option<String>,
}
async fn list_completed_jobs(

View File

@@ -26,6 +26,7 @@ use axum::{
Json, Router,
};
use hyper::StatusCode;
use serde_json::Value;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{Error, JsonResult, Result},
@@ -378,9 +379,26 @@ async fn update_variable(
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
}
let mut v = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_one(&mut tx)
.await?;
if let Some(old_v) = v {
v = Some(replace_path(
old_v,
&format!("$var:{path}"),
&format!("$var:{npath}"),
))
}
sqlx::query!(
"UPDATE resource SET path = $1 WHERE path = $2 AND workspace_id = $3",
"UPDATE resource SET path = $1, value = $2 WHERE path = $3 AND workspace_id = $4",
npath,
v,
path,
w_id
)
@@ -419,6 +437,23 @@ async fn update_variable(
Ok(format!("variable {} updated (npath: {:?})", path, npath))
}
fn replace_path(v: serde_json::Value, path: &str, npath: &str) -> Value {
match v {
Value::Object(v) => Value::Object(
v.into_iter()
.map(|(k, v)| (k, replace_path(v, path, npath)))
.collect(),
),
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| replace_path(v, path, npath))
.collect(),
),
Value::String(s) if s == path => Value::String(npath.to_owned()),
_ => v,
}
}
pub async fn build_crypt<'c>(
db: &mut Transaction<'c, Postgres>,
w_id: &str,

View File

@@ -1073,6 +1073,7 @@ struct ScriptMetadata {
schema: Option<Schema>,
is_template: bool,
lock: Vec<String>,
kind: String,
}
enum ArchiveImpl {
@@ -1235,6 +1236,7 @@ async fn tarball_workspace(
description: script.description,
schema: script.schema,
is_template: script.is_template,
kind: script.kind.to_string(),
lock,
};
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();

View File

@@ -7,7 +7,7 @@
*/
use std::{
fmt::Display,
fmt::{self, Display},
hash::{Hash, Hasher},
};
@@ -103,6 +103,18 @@ pub enum ScriptKind {
Approval,
}
impl Display for ScriptKind {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
fmt.write_str(match self {
ScriptKind::Trigger => "trigger",
ScriptKind::Failure => "failure",
ScriptKind::Script => "script",
ScriptKind::Approval => "approval",
})?;
Ok(())
}
}
#[derive(Serialize)]
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
pub struct Script {

View File

@@ -28,14 +28,18 @@ export class AppFile implements Resource, PushDiffs {
remotePath: string,
diffs: Difference[],
): Promise<void> {
if (await AppService.existsApp({ workspace, path: remotePath })) {
let app: AppWithLastVersion | undefined = undefined;
try {
app = await AppService.getAppByPath({ workspace, path: remotePath });
} catch (e) {}
if (app) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing app...`,
`Applying ${diffs.length} diffs to existing app... ${remotePath}`,
),
);
const changeset: {
path?: string | undefined;
summary?: string | undefined;
value?: any;
policy?: Policy | undefined;
@@ -46,7 +50,7 @@ export class AppFile implements Resource, PushDiffs {
(
diff.path[0] !== "value" && diff.path[0] !== "policy" && (
diff.path.length !== 1 ||
!["path", "summary"].includes(
!["summary"].includes(
diff.path[0] as string,
)
)
@@ -61,6 +65,13 @@ export class AppFile implements Resource, PushDiffs {
}
}
if ((!changeset?.policy || JSON.stringify(changeset?.policy) == JSON.stringify(app.policy))
&& (!changeset?.value || JSON.stringify(changeset?.value) == JSON.stringify(app.value))
&& (!changeset?.summary || changeset.summary == app.summary)) {
console.log(colors.yellow(`No changes to push for app ${remotePath}, skipping`))
return;
}
const hasChanges = Object.values(changeset).some((v) =>
v !== null && typeof v !== "undefined"
);
@@ -87,19 +98,10 @@ export class AppFile implements Resource, PushDiffs {
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let existing: AppWithLastVersion | undefined;
try {
existing = await AppService.getAppByPath({
workspace: workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
}

View File

@@ -94,6 +94,7 @@ export class FlowFile implements Resource, PushDiffs {
...changeset,
...base_changeset,
}
await FlowService.updateFlow({
workspace: workspace,
path: remotePath,
@@ -114,20 +115,11 @@ export class FlowFile implements Resource, PushDiffs {
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let remote: Flow | undefined;
try {
remote = await FlowService.getFlowByPath({
workspace,
path: remotePath,
});
} catch {
remote = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(remote ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
}

View File

@@ -1,4 +1,4 @@
import { colors, Command, Folder, FolderService, microdiff } from "./deps.ts";
import { colors, Command, FolderService, microdiff } from "./deps.ts";
import { requireLogin, resolveWorkspace, validatePath } from "./context.ts";
import {
Difference,
@@ -22,7 +22,9 @@ export class FolderFile implements Resource, PushDiffs {
owners: Array<string> | undefined;
@property(map(() => String, () => Boolean, { shape: MapShape.Object }))
extra_perms: Map<string, boolean> | undefined;
@property(() => String)
display_name: string| undefined;
async push(workspace: string, remotePath: string): Promise<void> {
if (remotePath.startsWith("/")) {
remotePath = remotePath.substring(1);
@@ -31,16 +33,10 @@ export class FolderFile implements Resource, PushDiffs {
remotePath = remotePath.substring(2);
}
let existing: Folder | undefined;
try {
existing = await FolderService.getFolder({ workspace, name: remotePath });
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
@@ -66,22 +62,24 @@ export class FolderFile implements Resource, PushDiffs {
if (exists) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing folder...`,
`Applying ${diffs.length} diffs to existing folder... ${remotePath}`,
),
);
const changeset: {
owners?: string[] | undefined;
extra_perms?: any;
display_name?: string | undefined;
} = {};
for (const diff of diffs) {
if (
diff.type !== "REMOVE" &&
(
diff.path.length !== 1 ||
!["owners", "extra_perms"].includes(diff.path[0] as string)
!["owners", "extra_perms", "display_name"].includes(diff.path[0] as string)
)
) {
console.log(diff.path)
throw new Error("Invalid folder diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
@@ -97,11 +95,10 @@ export class FolderFile implements Resource, PushDiffs {
if (!hasChanges) {
return;
}
await FolderService.updateFolder({
workspace: workspace,
name: remotePath,
requestBody: changeset,
requestBody: {...changeset, extra_perms: changeset.extra_perms ? Object.fromEntries(this.extra_perms?.entries() ?? []) : undefined}
});
} else {
console.log(colors.bold.yellow("Creating new folder: " + remotePath));

View File

@@ -13,7 +13,7 @@ import sync from "./sync.ts";
import { tryResolveVersion } from "./context.ts";
import { GlobalOptions } from "./types.ts";
const VERSION = "v1.74.2";
const VERSION = "v1.75.0";
let command: any = new Command()
.name("wmill")

View File

@@ -13,7 +13,6 @@ import {
EditResourceType,
microdiff,
ResourceService,
ResourceType,
Table,
} from "./deps.ts";
import { Any, decoverto, model, property } from "./decoverto.ts";
@@ -26,19 +25,10 @@ export class ResourceTypeFile implements ResourceI, PushDiffs {
description?: string;
async push(workspace: string, remotePath: string): Promise<void> {
let existing: ResourceType | undefined;
try {
existing = await ResourceService.getResourceType({
workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
this.pushDiffs(
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
@@ -65,8 +55,8 @@ export class ResourceTypeFile implements ResourceI, PushDiffs {
return;
}
console.log(
colors.yellow(
`Applying ${diffs.length} diffs to existing resource type...`,
colors.yellow.bold(
`Applying ${diffs.length} diffs to existing resource type... ${remotePath}`,
),
);
const changeset: EditResourceType = {};

View File

@@ -43,7 +43,7 @@ export class ResourceFile implements Resource2, PushDiffs {
})
) {
console.log(
colors.yellow(`Applying ${diffs.length} diffs to existing resource...`),
colors.yellow.bold(`Applying ${diffs.length} diffs to existing resource... ${remotePath}`),
);
const changeset: EditResource = {
@@ -51,11 +51,7 @@ export class ResourceFile implements Resource2, PushDiffs {
};
for (const diff of diffs) {
if (diff.path[0] === "is_oauth") {
console.log(
colors.yellow(
"! is_oauth has been removed in newer versions. Ignoring.",
),
);
//is_oauth is not updatable
continue;
}
if (
@@ -64,9 +60,10 @@ export class ResourceFile implements Resource2, PushDiffs {
diff.path[0] !== "value" && (
diff.path.length !== 1 ||
diff.path[0] !== "description"
)
) && diff.path[0] !== "resource_type"
)
) {
console.log(colors.red("Invalid variable diff with path " + diff.path));
throw new Error("Invalid folder diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
@@ -110,19 +107,10 @@ export class ResourceFile implements Resource2, PushDiffs {
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let existing: Resource | undefined;
try {
existing = await ResourceService.getResource({
workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
}

View File

@@ -42,6 +42,7 @@ export class ScriptFile {
},
toPlain: (data) => data,
})
@property(() => String)
kind?: "script" | "failure" | "trigger" | "command" | "approval";
constructor(summary: string, description: string) {
@@ -105,11 +106,21 @@ export async function handleFile(path: string, content: string, workspace: strin
} catch { }
const language = inferContentTypeFromFilePath(path);
let remote = undefined
try {
const remote = await ScriptService.getScriptByPath({
remote = await ScriptService.getScriptByPath({
workspace,
path: remotePath,
});
} catch { }
if (remote) {
if (typed.description === remote.description && content === remote.content && typed.summary === remote.summary && typed.is_template === remote.is_template && typed.kind == remote.kind && remote?.lock == typed.lock?.join('\n') && JSON.stringify(typed.schema) == JSON.stringify(remote.schema)) {
console.log(colors.yellow(`No change to push for script ${remotePath}, skipping`))
return true
}
await ScriptService.createScript({
workspace,
requestBody: {
@@ -123,10 +134,11 @@ export async function handleFile(path: string, content: string, workspace: strin
lock: typed.lock,
parent_hash: remote.hash,
schema: typed.schema,
},
}
});
console.log(colors.yellow.bold(`Creating script with a parent ${remotePath}`))
} catch {
} else {
// no parent hash
await ScriptService.createScript({
workspace: workspace,
@@ -144,7 +156,6 @@ export async function handleFile(path: string, content: string, workspace: strin
},
});
console.log(colors.yellow.bold(`Creating script without parent ${remotePath}`))
}
return true
}

View File

@@ -33,8 +33,8 @@ import { ResourceFile } from "./resource.ts";
import { FlowFile } from "./flow.ts";
import { VariableFile } from "./variable.ts";
import { handleFile } from "./script.ts";
import { equal } from "https://deno.land/x/equal/mod.ts";
import { diffCharacters } from "https://deno.land/x/diff/mod.ts";
import { equal } from "https://deno.land/x/equal@v1.5.0/mod.ts";
import { diffCharacters } from "https://deno.land/x/diff@v0.3.5/mod.ts";
type DynFSElement = {
isDirectory: boolean;
path: string;
@@ -179,13 +179,13 @@ async function elementsToMap(els: DynFSElement, ignore: (path: string, isDirecto
return map;
}
async function compareDynFSElement(
els1: DynFSElement, els2: DynFSElement,
els1: DynFSElement, els2: DynFSElement | undefined,
ignore: (path: string, isDirectory: boolean) => boolean,
raw: boolean
): Promise<Change[]> {
const [m1, m2] = raw ? [await elementsToMap(els1, ignore), {}] :
await Promise.all([elementsToMap(els1, ignore), elementsToMap(els2, ignore)]);
const [m1, m2] = els2
? await Promise.all([elementsToMap(els1, ignore), elementsToMap(els2, ignore)])
: [await elementsToMap(els1, ignore), {}];
const changes: Change[] = [];
@@ -266,8 +266,8 @@ async function pull(
console.log(colors.gray("Computing the files to update locally to match remote (taking .wmillignore into account)"));
const remote = ZipFSElement((await downloadZip(workspace))!)
const local = await FSFSElement(path.join(Deno.cwd(), opts.raw ? "" : ".wmill"))
const changes = await compareDynFSElement(remote, local, await ignoreF(), opts.raw)
const local = opts.raw ? undefined : await FSFSElement(path.join(Deno.cwd(), opts.raw ? "" : ".wmill"))
const changes = await compareDynFSElement(remote, local, await ignoreF())
console.log(`remote -> local: ${changes.length} changes to apply`);
@@ -289,7 +289,7 @@ async function pull(
try {
const currentLocal = await Deno.readTextFile(target)
if (currentLocal !== change.before) {
if (currentLocal !== change.before && currentLocal !== change.after) {
console.log(colors.red(`Conflict detected on ${change.path}\nBoth local and remote have been modified.`))
if (opts.failConflicts) {
conflicts.push({ local: currentLocal, change, path: change.path })
@@ -459,7 +459,6 @@ function removeSuffix(str: string, suffix: string) {
async function push(opts: GlobalOptions & { raw: boolean, yes: boolean, skipPull: boolean, failConflicts: boolean }) {
if (!opts.raw) {
if (!opts.skipPull) {
console.log(colors.gray("You need to be up-to-date before pushing, pulling first."))
@@ -474,9 +473,9 @@ async function push(opts: GlobalOptions & { raw: boolean, yes: boolean, skipPull
console.log(colors.gray("Computing the files to update on the remote to match local (taking .wmillignore into account)"));
const remote = ZipFSElement((await downloadZip(workspace))!)
const remote = opts.raw ? undefined : ZipFSElement((await downloadZip(workspace))!)
const local = await FSFSElement(path.join(Deno.cwd(), ""))
const changes = await compareDynFSElement(local, remote, await ignoreF(), opts.raw)
const changes = await compareDynFSElement(local, remote, await ignoreF())
console.log(`remote <- local: ${changes.length} changes to apply`);
if (changes.length > 0) {
@@ -575,7 +574,7 @@ async function push(opts: GlobalOptions & { raw: boolean, yes: boolean, skipPull
break;
}
try {
Deno.remove(stateTarget)
await Deno.remove(stateTarget)
} catch { }
}
}
@@ -616,7 +615,7 @@ async function push(opts: GlobalOptions & { raw: boolean, yes: boolean, skipPull
await file.pushDiffs(workspace, remotePath, diffs);
} catch (e) {
console.error("Failing to apply diffs to " + remotePath)
console.error(e.body)
console.error(JSON.stringify(e))
}
}
}
@@ -635,7 +634,7 @@ const command = new Command()
"Push any local changes and apply them remotely. Use --raw for usage without local state tracking.",
)
.option("--fail-conflicts", "Error on conflicts (both remote and local have changes on the same item)")
.option("--skip-pull", "Push without pulling first")
.option("--skip-pull", "Push without pulling first (you have pulled prior)")
.option("--yes", "Push without needing confirmation")
.option("--raw", "Push without using state, just overwrite.")
.action(push as any);

View File

@@ -11,7 +11,6 @@ import {
colors,
Command,
EditVariable,
ListableVariable,
microdiff,
Table,
VariableService,
@@ -67,7 +66,7 @@ export class VariableFile implements Resource, PushDiffs {
if (await VariableService.existsVariable({ workspace, path: remotePath })) {
console.log(
colors.bold.yellow(
`Applying ${diffs.length} diffs to existing variable...`,
`Applying ${diffs.length} diffs to existing variable... ${remotePath}`,
),
);
const changeset: EditVariable = {};
@@ -76,11 +75,12 @@ export class VariableFile implements Resource, PushDiffs {
diff.type !== "REMOVE" &&
(
diff.path.length !== 1 ||
!["path", "value", "is_secret", "description"].includes(
!["path", "value", "is_secret", "description", "account", "is_oauth"].includes(
diff.path[0] as string,
)
)
) {
console.log(colors.red("Invalid variable diff with path " + diff.path));
throw new Error("Invalid variable diff with path " + diff.path);
}
if (diff.type === "CREATE" || diff.type === "CHANGE") {
@@ -96,6 +96,7 @@ export class VariableFile implements Resource, PushDiffs {
if (!hasChanges) {
return;
}
await VariableService.updateVariable({
workspace,
path: remotePath,
@@ -103,7 +104,6 @@ export class VariableFile implements Resource, PushDiffs {
requestBody: changeset,
});
console.log(changeset);
} else {
console.log(colors.yellow.bold("Creating new variable..."));
await VariableService.createVariable({
@@ -121,19 +121,10 @@ export class VariableFile implements Resource, PushDiffs {
}
}
async push(workspace: string, remotePath: string): Promise<void> {
let existing: ListableVariable | undefined;
try {
existing = await VariableService.getVariable({
workspace: workspace,
path: remotePath,
});
} catch {
existing = undefined;
}
await this.pushDiffs(
workspace,
remotePath,
microdiff(existing ?? {}, this, { cyclesFix: false }),
microdiff({}, this, { cyclesFix: false }),
);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "windmill",
"version": "1.74.2",
"version": "1.75.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "windmill",
"version": "1.74.2",
"version": "1.75.0",
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
@@ -48,7 +48,6 @@
"@types/vscode": "~1.74.0",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.48.0",
"@windmill-labs/svelte-grid": "^5.1.6",
"@zerodevx/svelte-toast": "^0.8.1",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",
@@ -1601,12 +1600,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@windmill-labs/svelte-grid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/@windmill-labs/svelte-grid/-/svelte-grid-5.1.6.tgz",
"integrity": "sha512-wXHIG7XeMxwxnfaHajhjvz9c0u5uGgiQtTk/QxAVyViGdshsclfIfIWZF113QQBKCKp2jY/zP1cj64BrqioRoQ==",
"dev": true
},
"node_modules/@zeit/schemas": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.21.0.tgz",
@@ -9284,12 +9277,6 @@
"eslint-visitor-keys": "^3.3.0"
}
},
"@windmill-labs/svelte-grid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/@windmill-labs/svelte-grid/-/svelte-grid-5.1.6.tgz",
"integrity": "sha512-wXHIG7XeMxwxnfaHajhjvz9c0u5uGgiQtTk/QxAVyViGdshsclfIfIWZF113QQBKCKp2jY/zP1cj64BrqioRoQ==",
"dev": true
},
"@zeit/schemas": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.21.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "windmill",
"version": "1.74.2",
"version": "1.75.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -27,7 +27,6 @@
"@types/vscode": "~1.74.0",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.48.0",
"@windmill-labs/svelte-grid": "^5.1.6",
"@zerodevx/svelte-toast": "^0.8.1",
"autoprefixer": "^10.4.13",
"cssnano": "^5.1.14",

View File

@@ -1,79 +1,2 @@
/// <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
}
}
declare module '@windmill-labs/svelte-grid' {
import type { SvelteComponentTyped } from 'svelte'
export interface Size {
w: number
h: number
}
export interface Positon {
x: number
y: number
}
interface ItemLayout extends Size, Positon {
fixed?: boolean
resizable?: boolean
draggable?: boolean
customDragger?: boolean
customResizer?: boolean
min?: Size
max?: Size
}
export type Item<T> = T & { [width: number]: ItemLayout; data: any }
export type FilledItem<T> = T & { [width: number]: Required<ItemLayout>; data: any }
export interface Props<T> {
fillSpace?: boolean
items: FilledItem<T>[]
rowHeight: number
cols: [number, number][]
gap?: [number, number]
fastStart?: boolean
throttleUpdate?: number
throttleResize?: number
onTopId?: string
scroller?: undefined
sensor?: number
parentWidth?: number
}
export interface Slots<T> {
default: { item: ItemLayout; dataItem: Item<T> }
}
export default class Grid<T = {}> extends SvelteComponentTyped<
Props<T>,
{
pointerup: CustomEvent<{ id: string }>
mount: CustomEvent<>
},
Slots<T>
> { }
}
declare module '@windmill-labs/svelte-grid/build/helper/index.mjs' {
import { ItemLayout } from '@windmill-labs/svelte-grid'
const x: {
normalize(items: any[], col: any): unknown[]
adjust(items: any[], col: any): unknown[]
findSpace(item: any, items: any, cols: any): unknown
item<T>(obj: ItemLayout): Required<ItemLayout>
}
export default x
}

View File

@@ -81,7 +81,7 @@
$: key =
apiTokenApps[resource_type]?.key ??
(args != undefined
? Object.keys(args).filter((x) => ['token', 'password', 'api_key'].includes(x))[0]
? Object.keys(args).filter((x) => ['token', 'password', 'api_key', 'key'].includes(x))[0]
: undefined)
let scopes: string[] = []
@@ -259,6 +259,7 @@
args['token'] == '' &&
args['password'] == '' &&
args['api_key'] == '' &&
args['key'] == '' &&
key != undefined) ||
(step == 3 && pathError != '') ||
!isValid
@@ -396,7 +397,7 @@
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder="{resource_type}"
namePlaceholder={resource_type}
kind="resource"
/>
@@ -433,7 +434,7 @@
{:else}
<Path
initialPath=""
namePlaceholder="{resource_type}"
namePlaceholder={resource_type}
bind:error={pathError}
bind:path
kind="resource"

View File

@@ -2,9 +2,10 @@
import type { DropdownItem } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import Icon from 'svelte-awesome'
import { MoreHorizontal } from 'lucide-svelte'
import { MoreVertical } from 'lucide-svelte'
import { Button, Menu } from './common'
import { goto } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
type Alignment = 'start' | 'end'
type Side = 'top' | 'bottom'
@@ -13,15 +14,24 @@
export let dropdownItems: DropdownItem[]
export let name: string | undefined = undefined
export let placement: Placement = 'bottom-start'
export let btnClasses = '!text-blue-500 bg-transparent'
export let btnClasses = ''
$: buttonClass = twMerge('!border-0 bg-transparent !p-[6px]', btnClasses)
const dispatch = createEventDispatcher()
</script>
<Menu {placement} let:close>
<Button nonCaptureEvent color="light" size="xs" {btnClasses} {...$$restProps} slot="trigger">
<Button
nonCaptureEvent
color="dark"
variant="border"
size="xs"
btnClasses={buttonClass}
{...$$restProps}
slot="trigger"
>
{#if !$$slots.default}
<MoreHorizontal size={20} />
<MoreVertical size={20} />
{:else}
<slot />
{/if}

View File

@@ -3,6 +3,8 @@
import { workspaceStore } from '$lib/stores'
export let name: string
export let tabular = false
export let order = ['scripts', 'flows', 'apps', 'schedules', 'variables', 'resources']
$: $workspaceStore && loadUsage()
@@ -13,10 +15,18 @@
}
</script>
<div class="flex flex-col text-xs text-gray-600">
{#each Object.entries(usage) as [k, v]}
<div>
{k}: {v}
</div>
{#if tabular}
{#each order as key}
<td class="text-center">
{usage[key] ?? ''}
</td>
{/each}
</div>
{:else}
<div class="flex flex-col text-xs text-gray-600">
{#each Object.entries(usage) as [k, v]}
<div>
{k}: {v}
</div>
{/each}
</div>
{/if}

View File

@@ -234,6 +234,14 @@
name,
requestBody: { username: member_name }
})
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name
}
})
loadGroup()
}}>remove</button
>

View File

@@ -11,7 +11,7 @@
<span class="flex items-center space-x-2 mb-2">
<h1>{title}</h1>
{#if tooltip}
<Tooltip>{tooltip}</Tooltip>
<Tooltip scale={0.9}>{tooltip}</Tooltip>
{/if}
</span>
{:else}

View File

@@ -23,6 +23,7 @@
import FolderEditor from './FolderEditor.svelte'
import GroupEditor from './GroupEditor.svelte'
import { random_adj } from './random_positive_adjetive'
import Badge from './common/badge/Badge.svelte'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app'
let meta: Meta | undefined = undefined
@@ -306,7 +307,7 @@
{#if meta != undefined}
<div class="flex gap-4 shrink">
<label class="block">
<span class="text-gray-700 text-sm whitespace-nowrap">&nbsp;</span>
<span class="text-gray-700 text-sm whitespace-nowrap">Owner</span>
<ToggleButtonGroup
class="mt-0.5"
@@ -409,8 +410,26 @@
{/if}
</div>
<div class="flex-row flex justify-between w-full">
<div><span class="font-mono text-sm break-all">{path}</span></div>
<div class="flex flex-col w-full mt-4">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-gray-300 !text-gray-600 !w-[70px] !h-[24px] rounded-r-none"
>
Full path
</Badge>
<input
type="text"
readonly
value={path}
size={path?.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
on:focus={({ currentTarget }) => {
currentTarget.select()
}}
/>
<!-- <span class="font-mono text-sm break-all">{path}</span> -->
</div>
<div class="text-red-600 text-2xs">{error}</div>
</div>
</div>

View File

@@ -88,7 +88,7 @@
}}
items={collection}
class="text-clip grow min-w-0"
placeholder="{resourceType} resource"
placeholder="{resourceType ?? 'any'} resource"
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={SELECT_INPUT_DEFAULT_STYLE.containerStyles}
/>

View File

@@ -45,7 +45,7 @@
label: 'Failed',
data:
failed?.map((job) => ({
x: job.created_at as any,
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
@@ -57,7 +57,7 @@
label: 'Successful',
data:
success?.map((job) => ({
x: job.created_at as any,
x: job.started_at as any,
y: job.duration_ms,
id: job.id,
path: job.script_path
@@ -71,7 +71,10 @@
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
dispatch('zoom', { min: new Date(chart.scales.x.min), max: new Date(chart.scales.x.max) })
dispatch('zoom', {
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
},
zoom: {
@@ -80,7 +83,10 @@
},
mode: 'x' as 'x',
onZoom: ({ chart }) => {
dispatch('zoom', { min: new Date(chart.scales.x.min), max: new Date(chart.scales.x.max) })
dispatch('zoom', {
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
}
}
@@ -88,6 +94,22 @@
function getPath(x: any): string {
return x.path
}
$: minTime = getMinTime(jobs)
function addSeconds(date: Date, seconds: number): Date {
date.setTime(date.getTime() + seconds * 1000)
return date
}
function getMinTime(jobs: CompletedJob[] | undefined): Date {
return addSeconds(new Date(jobs?.[jobs?.length - 1]?.started_at ?? new Date().toString()), -15)
}
$: maxTime = getMaxTime(jobs)
function getMaxTime(jobs: CompletedJob[] | undefined): Date {
return addSeconds(new Date(jobs?.[0]?.started_at ?? new Date().toString()), 15)
}
</script>
<Scatter
@@ -115,7 +137,8 @@
display: false
},
type: 'time',
min: jobs?.[jobs?.length - 1]?.created_at ?? new Date().toString()
min: minTime,
max: maxTime
},
y: {
grid: {

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import { Script, ScriptService } from '$lib/gen'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { inferArgs } from '$lib/infer'
import { initialCode, isInitialCode } from '$lib/script_helpers'
import { initialCode } from '$lib/script_helpers'
import { userStore, workspaceStore } from '$lib/stores'
import { emptySchema, encodeState, sendUserToast, setQueryWithoutLoad } from '$lib/utils'
import Path from './Path.svelte'
import RadioButton from './RadioButton.svelte'
import ScriptEditor from './ScriptEditor.svelte'
import ScriptSchema from './ScriptSchema.svelte'
import CenteredPage from './CenteredPage.svelte'
import { dirtyStore } from './common/confirmationModal/dirtyStore'
import { Button, ButtonPopup, ButtonPopupItem, Kbd } from './common'
import { faChevronDown, faChevronUp, faPen, faSave } from '@fortawesome/free-solid-svg-icons'
import { faPen, faSave } from '@fortawesome/free-solid-svg-icons'
import Breadcrumb from './common/breadcrumb/Breadcrumb.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import type { SupportedLanguage } from '$lib/common'
import Tooltip from './Tooltip.svelte'
import SettingSection from './SettingSection.svelte'
export let script: Script
export let initialPath: string = ''
@@ -31,14 +31,34 @@
['Go', Script.language.GO],
['Bash', Script.language.BASH]
]
let viewScriptKind = script.kind !== Script.kind.SCRIPT
const scriptKindOptions: { value: Script.kind; title: string; desc?: string }[] = [
{
value: Script.kind.SCRIPT,
title: 'Action'
},
{
value: Script.kind.TRIGGER,
title: 'Trigger',
desc: 'First module of flows to trigger them based on external changes. These kind of scripts are usually running on a schedule to periodically look for changes.'
},
{
value: Script.kind.APPROVAL,
title: 'Approval',
desc: 'Send notifications externally to ask for approval to continue a flow.'
},
{
value: Script.kind.FAILURE,
title: 'Error Handler',
desc: 'Handle errors in flows after all retry attempts have been exhausted.'
}
]
let pathError = ''
let summaryC: HTMLInputElement | undefined = undefined
let pathC: Path | undefined = undefined
let loadingSave = false
$: setQueryWithoutLoad($page.url, 'state', encodeState(script))
$: setQueryWithoutLoad($page.url, [{ key: 'state', value: encodeState(script) }])
$: step = Number($page.url.searchParams.get('step')) || 1
if (script.content == '') {
@@ -53,7 +73,6 @@
script.content = initialCode(language, kind, template)
}
let loadingSave = false
async function editScript(leave: boolean): Promise<void> {
loadingSave = true
try {
@@ -250,7 +269,8 @@
}}
disabled={lockedLanguage}
>
<LanguageIcon {lang} /><span class="ml-2 py-4">{label}</span>
<LanguageIcon {lang} />
<span class="ml-2 py-2">{label}</span>
</Button>
{/each}
<Button
@@ -265,7 +285,7 @@
initContent(script.language, script.kind, template)
}}
>
<LanguageIcon lang="pgsql" /><span class="ml-2 py-4">PostgreSQL</span>
<LanguageIcon lang="pgsql" /><span class="ml-2 py-2">PostgreSQL</span>
</Button>
<!-- <Button
size="sm"
@@ -278,65 +298,41 @@
initContent(script.language, script.kind, template)
}}
>
<LanguageIcon lang="mysql" /><span class="ml-2 py-4">MySQL</span>
<LanguageIcon lang="mysql" /><span class="ml-2 py-2">MySQL</span>
</Button> -->
</div>
<div class="mt-16 mb-4">
<Button
color="light"
size="sm"
endIcon={{ icon: viewScriptKind ? faChevronUp : faChevronDown }}
on:click={() => (viewScriptKind = !viewScriptKind)}
>
Tag this script as having a specific purpose inside flows
</Button>
</div>
{#if viewScriptKind}
<div class="max-w-lg">
<RadioButton
label="Script Type"
options={[
['Action', Script.kind.SCRIPT],
[
{
title: 'Trigger',
desc: `First module of flows to trigger them based on watching changes external periodically using an internal state`
},
Script.kind.TRIGGER
],
[
{
title: 'Approval',
desc: `Send notification externally to ask for approval to continue a flow`
},
Script.kind.APPROVAL
],
[
{
title: 'Error Handler',
desc: `Handle errors for flows after all retries attempts have been exhausted`
},
Script.kind.FAILURE
]
// ['Command Handler', Script.kind.COMMAND]
]}
on:change={(e) => {
if (isInitialCode(script.content)) {
<SettingSection
title="Script kind"
element="h3"
tooltip="Tag this script as having a specific purpose inside flows. If it won't be used in flows,
you don't have to worry about this."
accordion
>
<div class="flex flex-wrap gap-2">
{#each scriptKindOptions as { value, title, desc }}
{@const isPicked = script.kind === value}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'dark'}
btnClasses="font-medium {isPicked ? '!bg-blue-50/75' : ''}"
on:click={() => {
template = 'script'
initContent(script.language, e.detail, template)
}
}}
bind:value={script.kind}
/>
script.kind = value
initContent(script.language, value, template)
}}
disabled={lockedLanguage}
>
{title}
{#if desc}
<Tooltip class="mb-0.5 ml-1">
{desc}
</Tooltip>
{/if}
</Button>
{/each}
</div>
{/if}
<!-- <div class="ml-3">
<Toggle
bind:checked={script.is_template}
options={{ right: 'Save as a workspace template' }}
/>
</div> -->
</SettingSection>
</CenteredPage>
{:else if step === 2}
<ScriptEditor

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import type { IntRange } from '../common'
import Tooltip from './Tooltip.svelte'
export let title: string
export let tooltip: string
export let element: `h${IntRange<1, 6>}` = 'h2'
export let accordion = false
let showContent = !accordion
</script>
<div class="border-b [&:has(button:hover)]:border-gray-400 duration-200 pb-1 mt-8 mb-2">
{#if accordion}
<button
class="flex w-full justify-between items-center"
on:click={() => (showContent = !showContent)}
>
<svelte:element this={element}>
{title}
<Tooltip scale={0.9} class="mb-0.5">
{tooltip}
</Tooltip>
</svelte:element>
<span class="rounded-full hover:bg-gray-100 focus:bg-gray-100 p-1">
<ChevronDown size={22} class="rotate-0 duration-300 {showContent ? '!rotate-180' : ''}" />
</span>
</button>
{:else}
<svelte:element this={element}>
{title}
<Tooltip scale={0.9} class="mb-0.5">
{tooltip}
</Tooltip>
</svelte:element>
{/if}
</div>
{#if showContent}
<div transition:slide={{ duration: 300 }}>
<slot />
</div>
{/if}

View File

@@ -2,13 +2,18 @@
import { ChevronDown, ChevronUp } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import Button from './common/button/Button.svelte'
import Tooltip from './Tooltip.svelte'
export let text: string
export let tooltip: string | undefined = undefined
export let view = false
</script>
<Button color="light" on:click={() => (view = !view)} variant="border"
>{text}
{#if tooltip}
<Tooltip wrapperClass="mx-1">{tooltip}</Tooltip>
{/if}
{#if !view}<ChevronDown />{:else}<ChevronUp />{/if}</Button
>
{#if view}

View File

@@ -18,5 +18,7 @@
data={faInfoCircle}
{scale}
/>
<span slot="text"> <slot /> </span>
<svelte:fragment slot="text">
<slot />
</svelte:fragment>
</Popover>

View File

@@ -127,6 +127,7 @@
<InputValue
{id}
input={configuration.disabled}
extraContext={extraQueryParams}
bind:value={disabled}
bind:error={errors.disabled}
/>

View File

@@ -12,6 +12,7 @@
export let value: T
export let id: string | undefined = undefined
export let error: string = ''
export let extraContext: Record<string, any> = {}
let lastInput = input ? JSON.parse(JSON.stringify(input)) : undefined
@@ -72,16 +73,19 @@
}
function computeGlobalContext() {
return Object.fromEntries(
Object.entries($worldStore?.outputsById ?? {})
.filter(([k, _]) => k != id)
.map(([key, value]) => {
return [
key,
Object.fromEntries(Object.entries(value ?? {}).map((x) => [x[0], x[1].peak()]))
]
})
)
return {
...Object.fromEntries(
Object.entries($worldStore?.outputsById ?? {})
.filter(([k, _]) => k != id)
.map(([key, value]) => {
return [
key,
Object.fromEntries(Object.entries(value ?? {}).map((x) => [x[0], x[1].peak()]))
]
})
),
...extraContext
}
}
export function getValue(input: AppInput) {

View File

@@ -43,6 +43,8 @@
: []
if (defaultValue) {
value = JSON.stringify(defaultValue)
} else if (listItems.length > 0) {
value = listItems[0]?.value
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Tab, Tabs } from '$lib/components/common'
import { getContext, onMount } from 'svelte'
import { initOutput } from '../../editor/appUtils'
import SubGridEditor from '../../editor/SubGridEditor.svelte'
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
@@ -27,6 +28,12 @@
let noPadding: boolean | undefined = undefined
let tabHeight: number = 0
$: outputs = $worldStore
? initOutput($worldStore, id, {
selectedTabIndex: 0
})
: undefined
function handleTabSelection() {
const selectedIndex = tabs?.indexOf(selected)
outputs?.selectedTabIndex.set(selectedIndex, true)
@@ -76,10 +83,6 @@
$: selected && handleTabSelection()
$: outputs = $worldStore?.outputsById[id] as {
selectedTabIndex: Output<number>
}
$: selectedIndex = tabs?.indexOf(selected) ?? -1
$: css = concatCustomCss($app.css?.tabscomponent, customCss)
@@ -88,7 +91,7 @@
<InputValue {id} input={configuration.noPadding} bind:value={noPadding} />
<div>
<div bind:clientHeight={tabHeight} on:pointerdown|stopPropagation>
<div bind:clientHeight={tabHeight}>
<Tabs bind:selected class={css?.tabRow?.class} style={css?.tabRow?.style}>
{#each tabs ?? [] as res}
<Tab

View File

@@ -158,144 +158,148 @@
/>
{/if}
{#if !$userStore?.operator}
{#if initialMode !== 'preview'}
<AppEditorHeader {policy} {fromHub} />
{/if}
{#if $appStore}
{#if initialMode !== 'preview'}
<AppEditorHeader {policy} {fromHub} />
{/if}
{#if previewing}
<SplitPanesWrapper>
<div
class={twMerge('h-full w-full', $appStore.css?.['app']?.['viewer']?.class)}
style={$appStore.css?.['app']?.['viewer']?.style}
>
<AppPreview
workspace={$workspaceStore ?? ''}
summary={$summaryStore}
app={$appStore}
appPath={path}
{breakpoint}
{policy}
isEditor
{context}
noBackend={false}
/>
</div>
</SplitPanesWrapper>
{:else}
<SplitPanesWrapper>
<Splitpanes class="max-w-full overflow-hidden">
<Pane size={$connectingInput?.opened ? 40 : 15} minSize={5} maxSize={33}>
<ContextPanel />
</Pane>
<Pane size={64}>
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={$connectingInput?.opened ? 100 : 70}>
<div
on:pointerdown={(e) => {
$selectedComponent = undefined
$focusedGrid = undefined
}}
class={twMerge(
'bg-gray-100 h-full w-full',
$appStore.css?.['app']?.['viewer']?.class
)}
style={$appStore.css?.['app']?.['viewer']?.style}
>
{#if previewing}
<SplitPanesWrapper>
<div
class={twMerge('h-full w-full', $appStore.css?.['app']?.['viewer']?.class)}
style={$appStore.css?.['app']?.['viewer']?.style}
>
<AppPreview
workspace={$workspaceStore ?? ''}
summary={$summaryStore}
app={$appStore}
appPath={path}
{breakpoint}
{policy}
isEditor
{context}
noBackend={false}
/>
</div>
</SplitPanesWrapper>
{:else}
<SplitPanesWrapper>
<Splitpanes class="max-w-full overflow-hidden">
<Pane size={15} minSize={5} maxSize={33}>
<ContextPanel />
</Pane>
<Pane size={64}>
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={70}>
<div
class={classNames(
'relative mx-auto w-full h-full overflow-auto',
app.fullscreen ? '' : 'max-w-6xl'
on:pointerdown={(e) => {
$selectedComponent = undefined
$focusedGrid = undefined
}}
class={twMerge(
'bg-gray-100 h-full w-full',
$appStore.css?.['app']?.['viewer']?.class
)}
style={$appStore.css?.['app']?.['viewer']?.style}
>
{#if $appStore.grid}
<ComponentNavigation />
<div
class={classNames(
'relative mx-auto w-full h-full overflow-auto',
app.fullscreen ? '' : 'max-w-6xl'
)}
>
{#if $appStore.grid}
<ComponentNavigation />
<div on:pointerdown|stopPropagation class={width}>
<GridEditor {policy} />
<div on:pointerdown|stopPropagation class={width}>
<GridEditor {policy} />
</div>
<div id="app-editor-top-level-drawer" />
{/if}
</div>
</div>
</Pane>
<Pane size={$connectingInput?.opened ? 0 : 30}>
<div class="relative h-full w-full">
<InlineScriptsPanel />
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</Pane>
<Pane size={21} minSize={5} maxSize={33}>
<div class="relative flex flex-col h-full">
<Tabs bind:selected={selectedTab} wrapperClass="!h-[40px]" class="!h-full">
<Tab value="insert" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faPlus} />
<span>Insert</span>
</div>
</Tab>
<Tab value="settings" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faSliders} />
<span>Settings</span>
</div>
</Tab>
<Tab value="css" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faCode} />
<span>CSS</span>
</div>
</Tab>
<div slot="content" class="h-full overflow-y-auto pb-4">
<TabContent class="overflow-auto h-full" value="settings">
{#if $selectedComponent !== undefined}
<SettingsPanel />
{:else}
<div class="min-w-[150px] text-sm text-gray-500 text-center py-8 px-2">
Select a component to see the settings&nbsp;for&nbsp;it
</div>
<div id="app-editor-top-level-drawer" />
{/if}
</div>
</TabContent>
<TabContent value="insert">
<ComponentList />
</TabContent>
<TabContent value="css">
<CssSettings />
</TabContent>
</div>
</Pane>
<Pane size={$connectingInput?.opened ? 0 : 30}>
<div class="relative h-full w-full">
<InlineScriptsPanel />
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</Pane>
<Pane size={21} minSize={5} maxSize={33}>
<div class="relative flex flex-col h-full">
<Tabs bind:selected={selectedTab} wrapperClass="!h-[40px]" class="!h-full">
<Tab value="insert" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faPlus} />
<span>Insert</span>
</div>
</Tab>
<Tab value="settings" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faSliders} />
<span>Settings</span>
</div>
</Tab>
<Tab value="css" size="xs">
<div class="m-1 center-center gap-2">
<Icon data={faCode} />
<span>CSS</span>
</div>
</Tab>
<div slot="content" class="h-full overflow-y-auto pb-4">
<TabContent class="overflow-auto" value="settings">
{#if $selectedComponent !== undefined}
<SettingsPanel />
{:else}
<div class="min-w-[150px] text-sm text-gray-500 text-center py-8 px-2">
Select a component to see the settings&nbsp;for&nbsp;it
</Tabs>
{#if $connectingInput.opened}
<div
class="fixed top-32 p-2 z-50 flex justify-center items-center"
transition:fly|local={{ duration: 100, y: -100 }}
>
<Alert title="Connecting" type="info">
<div class="flex gap-2 flex-col">
Click on the output of the component you want to connect to on the left panel.
<div>
<Button
color="blue"
variant="border"
size="xs"
on:click={() => {
$connectingInput.opened = false
$connectingInput.input = undefined
}}
>
Stop connecting
</Button>
</div>
</div>
{/if}
</TabContent>
<TabContent value="insert">
<ComponentList />
</TabContent>
<TabContent value="css">
<CssSettings />
</TabContent>
</div>
</Tabs>
{#if $connectingInput.opened}
<div
class="fixed top-32 p-2 z-50 flex justify-center items-center"
transition:fly|local={{ duration: 100, y: -100 }}
>
<Alert title="Connecting" type="info">
<div class="flex gap-2 flex-col">
Click on the output of the component you want to connect to on the left panel.
<div>
<Button
color="blue"
variant="border"
size="xs"
on:click={() => {
$connectingInput.opened = false
$connectingInput.input = undefined
}}
>
Stop connecting
</Button>
</div>
</div>
</Alert>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</Alert>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
{/if}
{:else}
App is null
{/if}
{:else}
App editor not available to operators

View File

@@ -18,6 +18,7 @@
import DisplayResult from '$lib/components/DisplayResult.svelte'
import Dropdown from '$lib/components/Dropdown.svelte'
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
import { idMutex } from '$lib/components/flows/flowStateUtils'
import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
@@ -220,18 +221,23 @@
return
}
loading.save = true
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: $page.params.path,
requestBody: {
value: $app!,
summary: $summary,
policy
}
})
loading.save = false
sendUserToast('App saved')
try {
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: $page.params.path,
requestBody: {
value: $app!,
summary: $summary,
policy
}
})
sendUserToast('App saved')
loading.save = false
} catch (e) {
loading.save = false
throw e
}
}
let selectedJobId: string | undefined = undefined
@@ -242,7 +248,10 @@
$: selectedJobId && testJobLoader?.watchJob(selectedJobId)
$: hasErrors = Object.keys($errorByComponent).length > 0
let lock = false
function onKeyDown(event: KeyboardEvent) {
if (lock) return
lock = true
switch (event.key) {
case 'Z':
if (event.ctrlKey) {
@@ -253,6 +262,7 @@
case 'z':
if (event.ctrlKey) {
$app = undo(history, $app)
event.preventDefault()
}
break
@@ -281,6 +291,7 @@
// break
// }
}
lock = false
}
</script>
@@ -471,8 +482,8 @@
<input type="text" placeholder="App summary" class="text-sm w-full" bind:value={$summary} />
</div>
<UndoRedo
undoProps={{ disabled: $history.index === 0 }}
redoProps={{ disabled: $history.index === $history.history.length - 1 }}
undoProps={{ disabled: $history?.index === 0 }}
redoProps={{ disabled: $history && $history?.index === $history.history.length - 1 }}
on:undo={() => {
$app = undo(history, $app)
}}
@@ -486,12 +497,12 @@
<ToggleButton position="left" value="dnd" size="xs">
<div class="inline-flex gap-1 items-center">
<Pencil size={14} />
<span class="hidden md:inline">Editor</span>
<span class="hidden lg:inline">Editor</span>
</div>
</ToggleButton>
<ToggleButton position="right" value="preview" size="xs">
<div class="inline-flex gap-1 items-center">
<Eye size={14} /> <span class="hidden md:inline">Preview</span>
<Eye size={14} /> <span class="hidden lg:inline">Preview</span>
</div>
</ToggleButton>
</ToggleButtonGroup>
@@ -508,42 +519,46 @@
</div>
<div class="hidden lg:block">
<ToggleButtonGroup bind:selected={$app.fullscreen}>
<ToggleButton position="left" value={false} size="xs">
<div class="flex gap-1 justify-start items-center">
<AlignHorizontalSpaceAround size={14} />
<Tooltip light class="mb-0.5">
The max width is 1168px and the content stay centered instead of taking the full page
width
</Tooltip>
</div>
</ToggleButton>
<ToggleButton position="right" value={true} size="xs">
<Expand size={14} />
</ToggleButton>
</ToggleButtonGroup>
{#if $app}
<ToggleButtonGroup bind:selected={$app.fullscreen}>
<ToggleButton position="left" value={false} size="xs">
<div class="flex gap-1 justify-start items-center">
<AlignHorizontalSpaceAround size={14} />
<Tooltip light class="mb-0.5">
The max width is 1168px and the content stay centered instead of taking the full
page width
</Tooltip>
</div>
</ToggleButton>
<ToggleButton position="right" value={true} size="xs">
<Expand size={14} />
</ToggleButton>
</ToggleButtonGroup>
{/if}
</div>
</div>
{#if $focusedGrid !== undefined}
<Badge color="indigo">
<div class="flex flex-row gap-2 justify-center items-center">
<div>{`Sub grid: ${$focusedGrid.parentComponentId} (${$focusedGrid.subGridIndex})`}</div>
<button
on:click={() => {
$selectedComponent = undefined
$focusedGrid = undefined
}}
>
<X size={14} />
</button>
</div>
</Badge>
<div class="hidden lg:block">
<Badge color="indigo">
<div class="flex flex-row gap-2 justify-center items-center">
<div>{`Sub grid: ${$focusedGrid.parentComponentId} (${$focusedGrid.subGridIndex})`}</div>
<button
on:click={() => {
$selectedComponent = undefined
$focusedGrid = undefined
}}
>
<X size={14} />
</button>
</div>
</Badge>
</div>
{/if}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
<Dropdown
placement="bottom-end"
btnClasses="!text-gray-700 !bg-transparent hover:!bg-gray-400/20 !p-[6px] hidden lg:block"
btnClasses="hidden lg:block"
dropdownItems={[
{
displayName: 'JSON',
@@ -568,9 +583,7 @@
}
}
]}
>
<MoreVertical size={20} />
</Dropdown>
/>
<span class="hidden md:inline">
<Button on:click={() => (inputsDrawerOpen = true)} color="light" size="xs" variant="border">
<span class="flex gap-2">
@@ -587,7 +600,7 @@
variant="border"
startIcon={{ icon: faBug }}
>
<span class="hidden md:inline">Debug Runs</span>
<span class="hidden xl:inline">Debug Runs</span>
</Button>
</span>
<AppExportButton bind:this={appExport} />
@@ -598,7 +611,7 @@
variant="border"
startIcon={{ icon: faExternalLink }}
>
<span class="hidden md:inline">Publish</span>
<span class="hidden xl:inline">Publish</span>
</Button>
{#if appPath == ''}
<Button

View File

@@ -4,6 +4,7 @@
import { buildWorld, type World } from '../rx'
import type {
App,
AppEditorContext,
AppViewerContext,
ConnectingInput,
EditorBreakpoint,
@@ -64,6 +65,12 @@
parentWidth: writable(0)
})
setContext<AppEditorContext>('AppEditorContext', {
history: undefined,
componentControl: writable({}),
pickVariableCallback: writable(undefined)
})
let mounted = false
onMount(() => {

View File

@@ -41,13 +41,14 @@
{#if pointerdown || selected || hover}
<button
title="Position locking"
title="Lock Position"
class={classNames(
'text-gray-800 px-1 text-2xs py-0.5 font-bold w-fit shadow border border-gray-300 absolute -top-1 right-[2.5rem] z-50 cursor-pointer',
' hover:bg-gray-300',
selected ? 'bg-gray-200/80' : 'bg-gray-200/80'
)}
on:click={() => dispatch('lock')}
on:pointerdown|stopPropagation
>
{#if locked}
<Anchor aria-label="Unlock position" size={14} class="text-orange-500" />
@@ -56,15 +57,16 @@
{/if}
</button>
<button
title="Position locking"
title="Expand"
class={classNames(
'text-gray-800 px-1 text-2xs py-0.5 font-bold w-fit shadow border border-gray-300 absolute -top-1 right-[0.5rem] z-50 cursor-pointer',
'text-gray-800 px-1 text-2xs py-0.5 font-bold w-fit shadow border border-gray-300 absolute -top-1 right-[4.5rem] z-50 cursor-pointer',
' hover:bg-gray-300',
selected ? 'bg-gray-200/80' : 'bg-gray-200/80'
)}
on:click={() => dispatch('expand')}
on:pointerdown|stopPropagation
>
<Expand aria-label="Lock position" size={14} />
<Expand aria-label="Expand position" size={14} />
</button>
{/if}
@@ -73,7 +75,7 @@
title="Move"
on:mousedown|stopPropagation|capture
class={classNames(
'text-gray-600 px-1 text-2xs py-0.5 font-bold rounded-t-sm w-fit absolute border border-gray-300 -top-1 shadow right-[4.5rem] z-50 cursor-move',
'text-gray-600 px-1 text-2xs py-0.5 font-bold rounded-t-sm w-fit absolute border border-gray-300 -top-1 shadow right-[0.5rem] z-50 cursor-move',
'bg-gray-200/80'
)}
>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { getContext, afterUpdate } from 'svelte'
import type { App, AppEditorContext, AppViewerContext, GridItem } from '../types'
import Grid from '@windmill-labs/svelte-grid'
import { classNames } from '$lib/utils'
import { columnConfiguration, disableDrag, enableDrag, isFixed, toggleFixed } from '../gridUtils'
import { twMerge } from 'tailwind-merge'
@@ -13,6 +12,7 @@
import { deepEqual } from 'fast-equals'
import { push } from '$lib/history'
import { expandGriditem, findGridItem } from './appUtils'
import Grid from '../svelte-grid/Grid.svelte'
export let policy: Policy
@@ -108,13 +108,17 @@
let pointerdown = false
let lastapp: App | undefined = undefined
const onpointerdown = () => {
lastapp = JSON.parse(JSON.stringify($app))
pointerdown = true
if (history) {
lastapp = JSON.parse(JSON.stringify($app))
pointerdown = true
}
}
const onpointerup = () => {
pointerdown = false
if (!deepEqual(lastapp, $app)) {
push(history, lastapp, false, true)
if (history) {
pointerdown = false
if (!deepEqual(lastapp, $app)) {
push(history, lastapp, false, true)
}
}
}
@@ -211,11 +215,13 @@
if (gridItem) {
toggleFixed(gridItem)
}
$app = $app
}}
on:expand={() => {
push(history, $app)
$selectedComponent = gridComponent.data.id
expandGriditem($app.grid, gridComponent, $breakpoint)
$app = { ...$app }
$app = $app
}}
/>
</div>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
import Grid from '@windmill-labs/svelte-grid'
import { twMerge } from 'tailwind-merge'
import { columnConfiguration, isFixed, toggleFixed } from '../gridUtils'
import type { AppEditorContext, AppViewerContext, GridItem } from '../types'
import Component from './component/Component.svelte'
import { expandGriditem, findGridItem } from './appUtils'
import { push } from '$lib/history'
import Grid from '../svelte-grid/Grid.svelte'
export let containerHeight: number
let classes = ''
@@ -63,6 +63,7 @@
if (fComponent) {
fComponent = toggleFixed(fComponent)
}
$app = $app
}
// @ts-ignore
@@ -136,11 +137,11 @@
if (!parentGridItem) {
return
}
$selectedComponent = gridComponent.data.id
push(history, $app)
expandGriditem(subGrid, gridComponent, $breakpoint, parentGridItem)
$app = { ...$app }
$app = $app
}}
/>
</div>

View File

@@ -1,9 +1,10 @@
import { getNextId } from '$lib/components/flows/flowStateUtils'
import type { App, EditorBreakpoint, FocusedGrid, GridItem } from '../types'
import type { App, ConnectingInput, EditorBreakpoint, FocusedGrid, GridItem } from '../types'
import { getRecommendedDimensionsByComponent, type AppComponent } from './component'
import gridHelp from '@windmill-labs/svelte-grid/src/utils/helper'
import { gridColumns } from '../gridUtils'
import { allItems } from '../utils'
import type { Output, World } from '../rx'
import gridHelp from '../svelte-grid/utils/helper'
function findGridItemById(
root: GridItem[],
@@ -18,7 +19,7 @@ function findGridItemById(
return undefined
}
export function findGridItemParentId(app: App, id: string): string | undefined {
export function findGridItemParentGrid(app: App, id: string): string | undefined {
const gridItem = app.grid.find((x) => x.id === id)
if (gridItem) {
return undefined
@@ -27,12 +28,23 @@ export function findGridItemParentId(app: App, id: string): string | undefined {
const subGrid = app.subgrids[key]
const gridItem = subGrid.find((x) => x.id === id)
if (gridItem) {
return key.split('-')[0]
return key
}
}
}
}
export function isIdInsideGriditem(app: App, gridItem: GridItem, id: string | undefined): boolean {
const path: string[] = []
let currentId = id
while (currentId) {
path.push(currentId)
currentId = findGridItemParentGrid(app, currentId)?.split('-')[0]
}
return path.includes(gridItem.id)
}
export function findGridItem(app: App, id: string): GridItem | undefined {
return findGridItemById(app.grid, app.subgrids, id)
}
@@ -46,10 +58,6 @@ export function getNextGridItemId(app: App): string {
}
export function createNewGridItem(grid: GridItem[], id: string, data: AppComponent): GridItem {
const appComponent = data
appComponent.id = id
const newComponent = {
resizable: true,
draggable: true,
@@ -57,7 +65,8 @@ export function createNewGridItem(grid: GridItem[], id: string, data: AppCompone
y: 0
}
let newData: AppComponent = JSON.parse(JSON.stringify(appComponent))
let newData: AppComponent = JSON.parse(JSON.stringify(data))
newData.id = id
const newItem: GridItem = {
data: newData,
@@ -65,7 +74,7 @@ export function createNewGridItem(grid: GridItem[], id: string, data: AppCompone
}
gridColumns.forEach((column) => {
const rec = getRecommendedDimensionsByComponent(appComponent.type, column)
const rec = getRecommendedDimensionsByComponent(newData.type, column)
newItem[column] = {
...newComponent,
@@ -84,6 +93,16 @@ export function createNewGridItem(grid: GridItem[], id: string, data: AppCompone
return newItem
}
export function getGridItems(app: App, focusedGrid: FocusedGrid | undefined): GridItem[] {
if (!focusedGrid) {
return app.grid
} else {
const { parentComponentId, subGridIndex } = focusedGrid
const key = `${parentComponentId}-${subGridIndex ?? 0}`
return app?.subgrids?.[key] ?? []
}
}
export function insertNewGridItem(
app: App,
data: AppComponent,
@@ -96,18 +115,6 @@ export function insertNewGridItem(
app.subgrids = {}
}
if (!focusedGrid) {
const newItem = createNewGridItem(app.grid, id, data)
app.grid.push(newItem)
} else {
const { parentComponentId, subGridIndex } = focusedGrid
const key = `${parentComponentId}-${subGridIndex ?? 0}`
const subGrid = app.subgrids[key] ?? []
subGrid.push(createNewGridItem(subGrid, id, data))
app.subgrids[key] = subGrid
}
// We only want to set subgrids when we are not moving
if (!keepId) {
for (let i = 0; i < (data.numberOfSubgrids ?? 0); i++) {
@@ -115,6 +122,17 @@ export function insertNewGridItem(
}
}
const key = focusedGrid
? `${focusedGrid?.parentComponentId}-${focusedGrid?.subGridIndex ?? 0}`
: undefined
let grid = focusedGrid ? app.subgrids[key!] : app.grid
const newItem = createNewGridItem(grid, id, data)
grid.push(newItem)
if (focusedGrid) {
app.subgrids[key!] = grid
}
return id
}
@@ -169,28 +187,6 @@ export function deleteGridItem(
return components
}
export function duplicateGridItem(
app: App,
parent: string | undefined,
id: string
): string | undefined {
const gridItem = findGridItem(app, id)
if (gridItem) {
const newId = getNextGridItemId(app)
const newItem = JSON.parse(JSON.stringify(gridItem))
newItem.id = newId
newItem.data.id = newId
let focusedGrid = parent
? { parentComponentId: parent.split('-')[0], subGridIndex: Number(parent.split('-')[1]) }
: undefined
return insertNewGridItem(app, newItem.data, focusedGrid)
}
return undefined
}
type AvailableSpace = {
left: number
right: number
@@ -206,7 +202,7 @@ export function findAvailableSpace(
): AvailableSpace | undefined {
if (gridItem) {
const breakpoint = editorBreakpoint === 'sm' ? 3 : 12
const maxHeight = parentGridItem ? parentGridItem[breakpoint].h - 1 : 12
const maxHeight = parentGridItem ? parentGridItem[breakpoint].h - 1 : 16
const maxWidth = 12
const availableSpace = {
@@ -297,6 +293,25 @@ function isOverlapping(item1: any, item2: any) {
)
}
type Outputtable<Type> = {
-readonly [Property in keyof Type]: Output<Type[Property]>
}
export function initOutput<I extends Record<string, any>>(
world: World,
id: string,
init: I
): Outputtable<I> {
const output = world.outputsById[id] as Outputtable<I>
if (init) {
for (const key in init) {
if (output && output[key] && output[key].peak() == undefined) {
output[key].set(init[key] as any)
}
}
}
return output
}
export function expandGriditem(
grid: GridItem[],
gridComponent: GridItem,
@@ -311,10 +326,60 @@ export function expandGriditem(
const { left, right, top, bottom } = availableSpace
const width = $breakpoint === 'sm' ? 3 : 12
const previousGridItem = JSON.parse(JSON.stringify(gridComponent[width]))
const item = gridComponent[width]
gridComponent[width].x = previousGridItem.x - left
gridComponent[width].y = previousGridItem.y - top
gridComponent[width].w = previousGridItem.w + left + right
gridComponent[width].h = previousGridItem.h + top + bottom
item.x = item.x - left
item.y = item.y - top
item.w = item.w + left + right
item.h = item.h + top + bottom
}
export function sortGridItemsPosition(
gridItems: GridItem[],
breakpoint: EditorBreakpoint
): GridItem[] {
return gridItems.sort((a: GridItem, b: GridItem) => {
const width = breakpoint === 'lg' ? 12 : 3
const aX = a[width].x
const aY = a[width].y
const bX = b[width].x
const bY = b[width].y
if (aY < bY) {
return -1
} else if (aY > bY) {
return 1
} else {
if (aX < bX) {
return -1
} else if (aX > bX) {
return 1
} else {
return 0
}
}
})
}
export function connectInput(
connectingInput: ConnectingInput,
componentId: string,
path: string
): ConnectingInput {
if (connectingInput) {
connectingInput = {
opened: false,
input: {
connection: {
componentId,
path
},
type: 'connected'
},
hoveredComponent: undefined
}
}
return connectingInput
}

View File

@@ -222,7 +222,7 @@
recomputeIds={component.recomputeIds}
{render}
/>
{:else if component.type === 'selectcomponent'}
{:else if component.type === 'selectcomponent' || component.type === 'resourceselectcomponent'}
<AppSelect
id={component.id}
verticalAlignment={component.verticalAlignment}

View File

@@ -1,23 +1,34 @@
<script lang="ts">
import { getContext } from 'svelte'
import {
findGridItem,
findGridItemParentGrid,
getAllSubgridsAndComponentIds,
insertNewGridItem,
sortGridItemsPosition
} from '../appUtils'
import type { AppEditorContext, AppViewerContext, EditorBreakpoint, GridItem } from '../../types'
import { findGridItemParentId } from '../appUtils'
import { push } from '$lib/history'
import { sendUserToast } from '$lib/utils'
const { app, selectedComponent, breakpoint, focusedGrid } =
getContext<AppViewerContext>('AppViewerContext')
const { componentControl } = getContext<AppEditorContext>('AppEditorContext')
const { componentControl, history } = getContext<AppEditorContext>('AppEditorContext')
let tempGridItem: GridItem | undefined = undefined
let copiedGridItem: GridItem | undefined = undefined
function getSortedGridItemsOfChildren(): GridItem[] {
if (!$focusedGrid) {
return sortGridItems($app.grid, $breakpoint)
return sortGridItemsPosition($app.grid, $breakpoint)
}
if (!$app.subgrids) {
return []
}
return sortGridItems(
return sortGridItemsPosition(
$app.subgrids[`${$focusedGrid.parentComponentId}-${$focusedGrid.subGridIndex}`],
$breakpoint
)
@@ -25,14 +36,14 @@
function getSortedGridItemsOfGrid(): GridItem[] {
if ($app.grid.find((item) => item.id === $selectedComponent)) {
return sortGridItems($app.grid, $breakpoint)
return sortGridItemsPosition($app.grid, $breakpoint)
}
if (!$app.subgrids) {
return []
}
return sortGridItems(
return sortGridItemsPosition(
Object.values($app.subgrids ?? {}).find((grid) =>
grid.find((item) => item.id === $selectedComponent)
) ?? [],
@@ -79,7 +90,7 @@
return
}
const sortedGridItems = sortGridItems(subgrid, $breakpoint)
const sortedGridItems = sortGridItemsPosition(subgrid, $breakpoint)
if (sortedGridItems) {
$selectedComponent = sortedGridItems[0].id
@@ -88,22 +99,89 @@
}
}
function handleEscape(event: KeyboardEvent) {
$selectedComponent = undefined
$focusedGrid = undefined
event.preventDefault()
}
function handleArrowUp(event: KeyboardEvent) {
if (!$selectedComponent) return
let parentId = findGridItemParentGrid($app, $selectedComponent)?.split('-')[0]
if (parentId) {
$selectedComponent = parentId
} else {
$selectedComponent = undefined
$focusedGrid = undefined
}
}
function handleCopy(event: KeyboardEvent) {
if (!$selectedComponent) {
return
}
tempGridItem = undefined
copiedGridItem = findGridItem($app, $selectedComponent)
}
function handleCut(event: KeyboardEvent) {
if (!$selectedComponent) {
return
}
push(history, $app)
const gridItem = findGridItem($app, $selectedComponent)
copiedGridItem = gridItem
if (!gridItem) {
return
}
// Store the grid item in a temp variable so we can paste it later
tempGridItem = gridItem
}
function handlePaste(event: KeyboardEvent) {
push(history, $app)
if (tempGridItem) {
if (
$focusedGrid &&
getAllSubgridsAndComponentIds($app, tempGridItem.data)[0].includes(
`${$focusedGrid.parentComponentId}-${$focusedGrid.subGridIndex}`
)
) {
sendUserToast('Cannot paste a component into itself', true)
return
}
let parentGrid = findGridItemParentGrid($app, tempGridItem.data.id)
const grid = parentGrid ? $app.subgrids![parentGrid] : $app.grid
let idx = grid.findIndex((item) => {
return item.id == tempGridItem!.data.id
})
if (idx > -1) {
grid.splice(idx, 1)
}
insertNewGridItem($app, tempGridItem.data, $focusedGrid, true)
copiedGridItem = tempGridItem
tempGridItem = undefined
} else if (copiedGridItem) {
insertNewGridItem($app, copiedGridItem.data, $focusedGrid, false)
}
$app = $app
}
function keydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
$selectedComponent = undefined
event.preventDefault()
handleEscape(event)
break
case 'ArrowUp': {
if (!$selectedComponent) return
let parentId = findGridItemParentId($app, $selectedComponent)
if (parentId) {
$selectedComponent = parentId
} else {
$selectedComponent = undefined
$focusedGrid = undefined
}
handleArrowUp(event)
break
}
@@ -122,35 +200,28 @@
break
}
case 'c':
if (event.ctrlKey || event.metaKey) {
handleCopy(event)
}
break
case 'v':
if (event.ctrlKey || event.metaKey) {
handlePaste(event)
}
break
case 'x':
if (event.ctrlKey || event.metaKey) {
handleCut(event)
}
break
default:
break
}
}
function sortGridItems(gridItems: GridItem[], breakpoint: EditorBreakpoint): GridItem[] {
return gridItems.sort((a: GridItem, b: GridItem) => {
const width = breakpoint === 'lg' ? 12 : 3
const aX = a[width].x
const aY = a[width].y
const bX = b[width].x
const bY = b[width].y
if (aY < bY) {
return -1
} else if (aY > bY) {
return 1
} else {
if (aX < bX) {
return -1
} else if (aX > bX) {
return 1
} else {
return 0
}
}
})
}
</script>
<svelte:window on:keydown={keydown} />

View File

@@ -1,4 +1,3 @@
import type { Size } from '@windmill-labs/svelte-grid'
import type { IntRange } from '../../../../common'
import type { NARROW_GRID_COLUMNS, WIDE_GRID_COLUMNS } from '../../gridUtils'
import { defaultAlignement } from '../componentsPanel/componentDefaultProps'
@@ -36,6 +35,7 @@ import {
AtSignIcon
} from 'lucide-svelte'
import type { BaseAppComponent } from '../../types'
import type { Size } from '../../svelte-grid/types'
type BaseComponent<T extends string> = {
type: T
@@ -74,7 +74,8 @@ export type AggridComponent = BaseComponent<'aggridcomponent'>
export type DisplayComponent = BaseComponent<'displaycomponent'>
export type ImageComponent = BaseComponent<'imagecomponent'>
export type InputComponent = BaseComponent<'inputcomponent'>
export type SelectComponent = BaseComponent<'selectcomponent'>
export type SelectComponent = BaseComponent<'resourceselectcomponent'>
export type ResourceSelectComponent = BaseComponent<'selectcomponent'>
export type MultiSelectComponent = BaseComponent<'multiselectcomponent'>
export type CheckboxComponent = BaseComponent<'checkboxcomponent'>
export type RadioComponent = BaseComponent<'radiocomponent'>
@@ -116,6 +117,7 @@ export type AppComponent = BaseAppComponent &
| PieChartComponent
| ScatterChartComponent
| SelectComponent
| ResourceSelectComponent
| MultiSelectComponent
| CheckboxComponent
| FormComponent
@@ -911,7 +913,7 @@ Hello \${ctx.username}
}
},
multiselectcomponent: {
name: 'MultiSelect',
name: 'Multi Select',
icon: List,
dims: '2:1-3:1',
data: {
@@ -940,6 +942,36 @@ Hello \${ctx.username}
softWrap: true
}
},
resourceselectcomponent: {
name: 'Resource Select',
icon: List,
dims: '2:1-3:1',
data: {
verticalAlignment: 'center',
id: '',
type: 'resourceselectcomponent',
componentInput: undefined,
configuration: {
items: {
type: 'static',
fieldType: 'array',
subFieldType: 'labeledresource',
value: []
},
placeholder: {
type: 'static',
fieldType: 'text',
value: 'Select an item',
onlyStatic: true
}
},
customCss: {
input: { style: '' }
} as const,
card: false,
softWrap: true
}
},
numberinputcomponent: {
name: 'Number',
icon: Binary,

View File

@@ -32,6 +32,7 @@ const inputs: ComponentSet = {
'fileinputcomponent',
'checkboxcomponent',
'selectcomponent',
'resourceselectcomponent',
'multiselectcomponent',
]
} as const

View File

@@ -23,7 +23,13 @@
<label class="block pb-2">
<div class="text-xs font-medium pb-0.5"> Style </div>
<div class="relative">
<input type="text" class="!pr-7" bind:value={value.style} on:focus />
<input
on:keydown|stopPropagation
type="text"
class="!pr-7"
bind:value={value.style}
on:focus
/>
{#if value?.style}
<button
transition:fade|local={{ duration: 100 }}
@@ -41,7 +47,13 @@
<label class="block">
<div class="text-xs font-medium pb-0.5"> Tailwind classes </div>
<div class="relative">
<input type="text" class="!pr-7" bind:value={value.class} on:focus />
<input
on:keydown|stopPropagation
type="text"
class="!pr-7"
bind:value={value.class}
on:focus
/>
{#if value?.class}
<button
transition:fade|local={{ duration: 100 }}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { components } from '../component'
import { getContext } from 'svelte'
import type { AppViewerContext, GridItem } from '../../types'
import ComponentOutputViewer from './ComponentOutputViewer.svelte'
import { classNames } from '$lib/utils'
import { ChevronDown, ChevronRight, Lock } from 'lucide-svelte'
import { connectInput, isIdInsideGriditem } from '../appUtils'
import { slide } from 'svelte/transition'
import SubGridOutput from './SubGridOutput.svelte'
export let gridItem: GridItem
export let first: boolean = false
export let nested: boolean = false
export let parentId: string | undefined = undefined
export let expanded: boolean = false
const { app, staticOutputs, selectedComponent, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
const name = getComponentNameById(gridItem.id)
function getComponentNameById(componentId: string) {
if (gridItem?.data?.type) {
return components[gridItem?.data.type].name
} else if (componentId == 'ctx') {
return 'Context'
} else if (componentId.startsWith('bg_')) {
return 'Background'
} else {
return 'Table action'
}
}
let manuallyOpened = false
$: if (expanded) {
manuallyOpened = true
} else {
manuallyOpened = false
}
$: subGrids = Array.from({ length: gridItem.data.numberOfSubgrids }).map(
(_, i) => `${gridItem.id}-${i}`
)
$: insideGrid = isIdInsideGriditem($app, gridItem, $selectedComponent)
$: isSelected = $selectedComponent === gridItem.id
$: opened = insideGrid || isSelected || manuallyOpened
function onHeaderClick() {
if (manuallyOpened) {
if (parentId) {
$selectedComponent = parentId
} else {
$selectedComponent = undefined
}
manuallyOpened = false
} else {
$selectedComponent = gridItem.id
manuallyOpened = true
}
}
</script>
{#if $staticOutputs[gridItem.id] || gridItem.data.numberOfSubgrids > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'flex items-center justify-between p-1 cursor-pointer hover:bg-indigo-100 hover:text-indigo-500 border-b',
isSelected ? 'bg-indigo-200' : 'bg-white',
first ? 'border-t' : '',
nested ? 'border-l' : ''
)}
on:click={onHeaderClick}
>
<div
class={classNames(
'text-2xs ml-0.5 font-bold px-2 py-0.5 rounded-sm',
isSelected ? 'bg-indigo-500 text-white' : ' bg-indigo-50'
)}
>
{gridItem.id}
</div>
<div class="text-2xs font-bold flex flex-row gap-2 items-center">
{getComponentNameById(gridItem.id)}
{#if !opened && !manuallyOpened}
<ChevronRight size={14} />
{:else if manuallyOpened}
<Lock size={14} class="text-orange-600" />
{:else}
<ChevronDown size={14} />
{/if}
</div>
</div>
{#if opened}
<div class={classNames('border-b', nested ? 'border-l' : '')} transition:slide|local>
<div class="py-1">
<ComponentOutputViewer
componentId={gridItem.id}
outputs={$staticOutputs[gridItem.id]}
on:select={({ detail }) => {
$connectingInput = connectInput($connectingInput, gridItem.id, detail)
}}
/>
</div>
<div>
<SubGridOutput {name} {expanded} {subGrids} parentId={gridItem.id} />
</div>
</div>
{/if}
{/if}

View File

@@ -30,6 +30,4 @@
{#if Object.keys(object).length > 0}
<ObjectViewer json={object} on:select topBrackets={false} />
{:else}
<div class="text-xs text-gray-500 px-4">No outputs</div>
{/if}

View File

@@ -1,34 +1,19 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { classNames } from '$lib/utils'
import { X } from 'lucide-svelte'
import { Maximize, Minimize, X } from 'lucide-svelte'
import { getContext } from 'svelte'
import { flip } from 'svelte/animate'
import { fade } from 'svelte/transition'
import type { AppViewerContext } from '../../types'
import { findGridItem } from '../appUtils'
import { connectInput, findGridItem, sortGridItemsPosition } from '../appUtils'
import { components } from '../component'
import PanelSection from '../settingsPanel/common/PanelSection.svelte'
import ComponentOutput from './ComponentOutput.svelte'
import ComponentOutputViewer from './ComponentOutputViewer.svelte'
const { connectingInput, staticOutputs, worldStore, selectedComponent, app } =
const { staticOutputs, app, breakpoint, connectingInput, selectedComponent } =
getContext<AppViewerContext>('AppViewerContext')
function connectInput(componentId: string, path: string) {
if ($connectingInput) {
$connectingInput = {
opened: false,
input: {
connection: {
componentId,
path
},
type: 'connected'
},
hoveredComponent: undefined
}
}
}
function getComponentNameById(componentId: string) {
const component = findGridItem($app, componentId)
@@ -42,12 +27,18 @@
return 'Table action'
}
}
function toggleExpanded() {
expanded = !expanded
}
let search = ''
let expanded = false
$: panels = [['ctx', ['email', 'username', 'query', 'hash']] as [string, string[]]].concat(
Object.entries($staticOutputs)
)
let search = ''
// filter out outputs that don't match the search by name (computed by getComponentNameById) and id
// The output should be [string, string[]][]
$: filteredPanels = panels.filter(([componentId, outputs]) => {
@@ -60,99 +51,54 @@
</script>
<PanelSection noPadding titlePadding="px-4 pt-2 pb-0.5" title="Outputs">
<div class="overflow-auto h-full min-w-[150px] w-full relative flex flex-col">
<div class="sticky z-50 top-0 left-0 w-full bg-white px-2 pb-2">
<div class="relative">
<input
bind:value={search}
class="px-2 py-1 border border-gray-300 rounded-sm {search ? 'pr-8' : ''}"
placeholder="Search outputs..."
/>
{#if search}
<button
class="absolute right-2 top-1/2 transform -translate-y-1/2 hover:bg-gray-200 rounded-full p-0.5"
on:click|stopPropagation|preventDefault={() => (search = '')}
>
<X size="14" />
</button>
{/if}
</div>
</div>
<div class="relative p-2">
{#each filteredPanels as [componentId, outputs] (componentId)}
<div
animate:flip={{ duration: 300 }}
in:fade|local={{ duration: 100, delay: 50 }}
out:fade|local={{ duration: 100 }}
class="pb-2"
>
{#if outputs.length > 0 && $worldStore?.outputsById[componentId]}
{@const name = getComponentNameById(componentId)}
<div>
<div
class="flex {$connectingInput?.opened
? 'bg-white z-50'
: ''} flex-row justify-between w-full"
>
<button
on:click|stopPropagation|preventDefault={$connectingInput.opened
? undefined
: () => ($selectedComponent = componentId)}
class={classNames(
'px-2 text-2xs py-0.5 border-t border-x font-bold rounded-t-sm w-fit',
$selectedComponent === componentId
? ' bg-indigo-500/90 text-white border-indigo-500/90'
: 'bg-gray-100 text-gray-500 border-gray-200'
)}
>
{componentId}
</button>
<span
class={classNames(
'px-1 text-2xs py-0.5 font-semibold rounded-t-sm w-fit',
'bg-gray-700 text-white'
)}
>
{name}
</span>
</div>
<div
class={classNames(
$connectingInput?.opened ? 'bg-white z-50' : '',
`w-full py-2 grow border relative break-all `,
$selectedComponent === componentId ? 'border border-indigo-500/90 ' : '',
$connectingInput.hoveredComponent === componentId
? 'outline outline-indigo-500/90'
: ''
)}
>
{#key $selectedComponent}
{#key $connectingInput?.opened}
<ComponentOutputViewer
outputs={$connectingInput?.opened && $selectedComponent === componentId
? name == 'Table'
? ['search']
: []
: outputs}
{componentId}
on:select={({ detail }) => {
connectInput(componentId, detail)
}}
/>
{/key}
{/key}
</div>
</div>
<div style="z-index:1000;" class="bg-white">
<div class="overflow-auto h-full min-w-[150px] w-full relative flex flex-col">
<div class="sticky z-50 top-0 left-0 w-full bg-white px-2 pb-2">
<div class="relative">
<input
bind:value={search}
class="px-2 py-1 border border-gray-300 rounded-sm {search ? 'pr-8' : ''}"
placeholder="Search outputs..."
/>
{#if search}
<button
class="absolute right-2 top-1/2 transform -translate-y-1/2 hover:bg-gray-200 rounded-full p-0.5"
on:click|stopPropagation|preventDefault={() => (search = '')}
>
<X size="14" />
</button>
{/if}
</div>
{:else}
<div
in:fade|local={{ duration: 50, delay: 100 }}
out:fade|local={{ duration: 50 }}
class="absolute left-0 top-0 w-full text-sm text-gray-500 text-center py-4 px-2"
>
No outputs found
</div>
</div>
<div class="p-1 ">
<Button on:click={toggleExpanded} color="light" size="xs">
{#if !expanded}
<Maximize size="14" />
{:else}
<Minimize size="14" />
{/if}
</Button>
</div>
<div
class={classNames(
'text-2xs ml-0.5 font-bold px-2 py-0.5 rounded-sm',
$selectedComponent === 'ctx' ? 'bg-indigo-500 text-white' : ' bg-indigo-50'
)}
>
ctx
</div>
<ComponentOutputViewer
componentId={'ctx'}
outputs={['email', 'username', 'query', 'hash']}
on:select={({ detail }) => {
$connectingInput = connectInput($connectingInput, 'ctx', detail)
}}
/>
{#each sortGridItemsPosition($app.grid, $breakpoint) as gridItem, index}
<ComponentOutput {gridItem} first={index === 0} {expanded} />
{/each}
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { getContext } from 'svelte'
import { slide } from 'svelte/transition'
import type { Output } from '../../rx'
import type { AppViewerContext } from '../../types'
import { connectInput, sortGridItemsPosition } from '../appUtils'
import ComponentOutput from './ComponentOutput.svelte'
export let name: string | undefined = undefined
export let parentId: string
export let expanded: boolean = false
export let subGrids: string[]
const { app, connectingInput, breakpoint, worldStore } =
getContext<AppViewerContext>('AppViewerContext')
let selected = 0
$: outputs = $worldStore?.outputsById[parentId] as {
selectedTabIndex: Output<number>
}
$: if (outputs?.selectedTabIndex) {
outputs.selectedTabIndex.subscribe({
next: (value) => {
selected = value
}
})
}
</script>
{#each subGrids as subGridId, index}
<div class="ml-2 my-2">
{#if subGrids.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'px-1 py-0.5 flex justify-between items-center font-semibold text-xs border-l border-y w-full cursor-pointer',
selected === index ? 'bg-gray-200' : 'bg-gray-50'
)}
on:click={() => {
selected = index
}}
>
<div class="text-xs">
{name ? name : 'Should implement'}
{index + 1}
</div>
</div>
{/if}
{#if selected === index || name !== 'Tabs'}
<div transition:slide|local>
{#if $app.subgrids && $app.subgrids[subGridId].length > 0}
{#each sortGridItemsPosition($app.subgrids[subGridId], $breakpoint) as subGridItem, index}
<ComponentOutput
gridItem={subGridItem}
first={index === 0}
nested
{expanded}
on:select={({ detail }) => {
$connectingInput = connectInput($connectingInput, subGridItem.id, detail)
}}
/>
{/each}
{:else}
<div class="text-xs text-gray-500 border-y border-l p-1">No components</div>
{/if}
</div>
{/if}
</div>
{/each}

View File

@@ -8,7 +8,6 @@
import { inferArgs } from '$lib/infer'
import type { Schema } from '$lib/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { fly } from 'svelte/transition'
import Editor from '$lib/components/Editor.svelte'
import { emptySchema, scriptLangToEditorLang } from '$lib/utils'
import Popover from '../../../Popover.svelte'
@@ -82,7 +81,12 @@
<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" />
<input
on:keydown|stopPropagation
bind:value={name}
placeholder="Inline script name"
class="!text-xs !rounded-xs"
/>
{/if}
<div class="flex w-full flex-row gap-2 items-center justify-end">
{#if validCode}

View File

@@ -22,6 +22,8 @@
componentInput.value.push(0)
} else if (componentInput.subFieldType === 'object') {
componentInput.value.push({})
} else if (componentInput.subFieldType === 'labeledresource') {
componentInput.value.push({ value: '', label: '' })
} else if (
componentInput.subFieldType === 'text' ||
componentInput.subFieldType === 'textarea' ||

View File

@@ -19,14 +19,12 @@
import TemplateEditor from '$lib/components/TemplateEditor.svelte'
import type { AppComponent } from '../component'
import CssProperty from '../componentsPanel/CssProperty.svelte'
import { dirtyStore } from '$lib/components/common/confirmationModal/dirtyStore'
import GridTab from './GridTab.svelte'
import { duplicateGridItem } from '../appUtils'
import { deleteGridItem } from '../appUtils'
import MoveToOtherGrid from './MoveToOtherGrid.svelte'
import GridPane from './GridPane.svelte'
import { slide } from 'svelte/transition'
import { push } from '$lib/history'
import Kbd from '$lib/components/common/kbd/Kbd.svelte'
export let component: AppComponent
export let rowColumns = false
@@ -47,13 +45,6 @@
const { history } = getContext<AppEditorContext>('AppEditorContext')
function duplicateElement(id: string) {
push(history, $app)
$dirtyStore = true
const newId = duplicateGridItem($app, parent, id)
$selectedComponent = newId
}
function removeGridElement() {
push(history, $app)
$selectedComponent = undefined
@@ -96,7 +87,7 @@
<svelte:window on:keydown={keydown} />
{#if component}
<div class="flex flex-col min-w-[150px] w-full divide-y">
<div class="flex min-h-full flex-col min-w-[150px] w-full divide-y">
{#if component.componentInput}
<PanelSection
smallPadding
@@ -195,6 +186,8 @@
<Recompute bind:recomputeIds={component.recomputeIds} ownId={component.id} />
{/if}
<div class="grow shrink" />
{#if Object.keys(component.customCss ?? {}).length > 0}
<PanelSection title="Custom CSS">
<div slot="action">
@@ -222,8 +215,20 @@
{/if}
{#if duplicateMoveAllowed}
<PanelSection title="Duplicate">
<Button
<PanelSection title="Copy/Move">
<div slot="action">
<Button size="xs" color="red" variant="border" on:click={removeGridElement}>
Delete&nbsp;&nbsp;
<Kbd>Del</Kbd>
</Button>
</div>
<div class="flex flex-col">
<div
><span class="text-gray-600 text-xs mr-2">Copy:</span><Kbd>CTRL+C</Kbd> + <Kbd
>CTRL+V</Kbd
></div
>
<!-- <Button
size="xs"
color="dark"
startIcon={{ icon: faCopy }}
@@ -234,24 +239,19 @@
}}
>
Duplicate component: {component.id}
</Button>
</PanelSection>
<PanelSection title="Move to other grid">
<MoveToOtherGrid bind:component {parent} />
</Button> -->
<div
><span class="text-gray-600 text-xs mr-2">Move:</span><Kbd>CTRL+X</Kbd> + <Kbd
>CTRL+V</Kbd
></div
>
<div
><span class="text-gray-600 text-xs mr-2">Navigate:</span><Kbd>&leftarrow;</Kbd><Kbd
>&uparrow;</Kbd
><Kbd>&rightarrow;</Kbd><Kbd>ESC</Kbd></div
>
</div>
</PanelSection>
{/if}
<PanelSection title="Danger zone">
<Button
size="xs"
color="red"
variant="border"
startIcon={{ icon: faTrashAlt }}
on:click={removeGridElement}
>
Delete component: {component.id}
</Button>
</PanelSection>
</div>
{/if}

View File

@@ -56,7 +56,7 @@
<div class="w-full flex gap-2 flex-col mt-2">
{#each panes as value, index (index)}
<div class="w-full flex flex-row gap-2 items-center relative">
<input type="number" bind:value />
<input on:keydown|stopPropagation type="number" bind:value />
<div class="absolute top-1 right-1">
<Button

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import CloseButton from '$lib/components/common/CloseButton.svelte'
import { faPlus, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { CrossIcon } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { AppViewerContext } from '../../types'
import { deleteGridItem } from '../appUtils'
@@ -54,20 +56,10 @@
<div class="w-full flex gap-2 flex-col mt-2">
{#each tabs as value, index (index)}
<div class="w-full flex flex-row gap-2 items-center relative">
<input type="text" bind:value />
<input on:keydown|stopPropagation type="text" bind:value />
<div class="absolute top-1 right-1">
<Button
size="xs"
color="light"
variant="border"
on:click={() => {
deleteSubgrid(index)
}}
iconOnly
btnClasses="!text-red-500"
startIcon={{ icon: faTrashAlt }}
/>
<div class="absolute right-1">
<CloseButton noBg on:close={() => deleteSubgrid(index)} />
</div>
</div>
{/each}

View File

@@ -1,123 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { dirtyStore } from '$lib/components/common/confirmationModal/dirtyStore'
import { push } from '$lib/history'
import { faCopy } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import type { App, AppEditorContext, AppViewerContext } from '../../types'
import {
createNewGridItem,
deleteGridItem,
findGridItem,
getAllSubgridsAndComponentIds,
insertNewGridItem
} from '../appUtils'
import type { AppComponent } from '../component'
export let component: AppComponent | undefined
export let parent: string | undefined
let selectedOption: string
const { app } = getContext<AppViewerContext>('AppViewerContext')
const { history } = getContext<AppEditorContext>('AppEditorContext')
function listAllSubGrids(app: App) {
return app.subgrids ? Object.keys(app.subgrids) : []
}
function findAndDelete(id: string) {
const node = findGridItem($app, id)
if (!node) {
return
}
const data: AppComponent = JSON.parse(JSON.stringify(node.data))
const shouldKeepSubGrid = data.numberOfSubgrids ? data.numberOfSubgrids >= 1 : false
$dirtyStore = true
deleteGridItem($app, data, parent, shouldKeepSubGrid)
return data
}
function insertComponentInSubGrid(
data: AppComponent,
targetId: string,
targetSubGridIndex: number
) {
push(history, $app)
const focusedGrid = {
parentComponentId: targetId,
subGridIndex: targetSubGridIndex
}
insertNewGridItem($app, data, focusedGrid, true)
$app.grid = [...$app.grid]
}
function insertComponentInMainGrid(data: AppComponent) {
const newComponent = createNewGridItem($app.grid, data.id, data)
$app.grid = [...$app.grid, newComponent]
}
function onMove(component: AppComponent) {
push(history, $app)
const data = findAndDelete(component.id)
if (!data) {
return
}
if (selectedOption !== 'main-grid') {
const [targetId, targetSubGridIndex] = selectedOption.split('-')
insertComponentInSubGrid(data, targetId, parseInt(targetSubGridIndex))
} else {
insertComponentInMainGrid(data)
}
}
const defaultOption = {
label: 'Main grid',
value: 'main-grid',
disabled: parent === undefined
}
$: [subgrids] = component ? getAllSubgridsAndComponentIds($app, component) : [[], []]
$: availableGrids = listAllSubGrids($app)
$: options = availableGrids
? [
defaultOption,
...availableGrids?.map((grid) => ({
label: grid,
value: grid,
disabled: grid === parent || subgrids.includes(grid)
}))
]
: [defaultOption]
</script>
{#if component && !options.every((option) => option.disabled)}
<select bind:value={selectedOption} class="w-full">
{#each options as option}
<option value={option.value} disabled={option.disabled}>{option.label}</option>
{/each}
</select>
<Button
size="xs"
color="dark"
startIcon={{ icon: faCopy }}
on:click={() => {
if (component) {
onMove(component)
}
}}
>
Move component: {component.id}
</Button>
{:else}
<p class="text-gray-500 text-sm">No grids available</p>
{/if}

View File

@@ -43,7 +43,8 @@
disabled: {
fieldType: 'boolean',
type: 'eval',
expr: 'false'
expr: 'false',
tooltip: "Disabled based on a predicate. Use `row` to access the current row's data"
},
goto: {
tooltip: 'Go to an url on success if not empty',

View File

@@ -2,6 +2,7 @@
import type { AppInput, StaticInput } from '../../../inputType'
import { Loader2, X } from 'lucide-svelte'
import { Popup } from '../../../../common'
import { fade } from 'svelte/transition'
export let componentInput: StaticInput<string> & Extract<AppInput, { fieldType: 'icon-select' }>
let anchor: HTMLElement
@@ -9,6 +10,7 @@
let items: { label: string; icon: any }[]
let filteredItems: { label: string; icon: any }[]
let search = ''
let openPopup: () => void
$: if (search) {
filteredItems = items.filter((item) => {
@@ -19,13 +21,16 @@
}
async function getData() {
if (items) return
if (items) {
return openPopup()
}
loading = true
const data = await import('lucide-svelte/dist/svelte/icons')
filteredItems = items = Object.entries(data)
.filter(([key]) => !(key.endsWith('Icon') || key.startsWith('Lucide')))
.map(([key, icon]) => ({ label: key, icon }))
loading = false
openPopup()
}
function formatName(name?: string) {
@@ -54,7 +59,11 @@
on:focus={getData}
class="pr-8"
/>
{#if componentInput.value}
{#if loading}
<div class="center-center absolute right-2 top-1/2 transform -translate-y-1/2 p-0.5">
<Loader2 class="animate-spin" size={16} />
</div>
{:else if componentInput.value}
<button
class="absolute right-2 top-1/2 transform -translate-y-1/2 hover:bg-gray-200 rounded-full p-0.5"
on:click|stopPropagation|preventDefault={() => (componentInput.value = undefined)}
@@ -66,42 +75,52 @@
{/if}
</div>
{#if anchor}
<Popup ref={anchor} options={{ placement: 'bottom' }} let:close>
<div class="max-w-xs shadow-[0_10px_40px_-5px_rgba(0,0,0,0.25)] bg-white rounded-md p-2">
{#if loading}
<div class="center-center p-2">
<Loader2 class="animate-spin" size={18} />
</div>
{:else if filteredItems}
<input bind:value={search} type="text" placeholder="Search" class="col-span-4 mb-2" />
<div class="grid gap-1 grid-cols-4 max-h-[300px] overflow-auto">
{#each filteredItems as { label, icon }}
{@const formatedLabel = formatName(label)}
<button
type="button"
title={formatedLabel}
on:click={() => {
select(label)
close()
}}
class="w-full center-center flex-col font-normal p-1
hover:bg-gray-100 focus:bg-gray-100 rounded duration-200
{label === componentInput.value ? 'text-blue-600 bg-blue-50 pointer-events-none' : ''}"
>
<svelte:component this={icon} size={22} />
<span class="inline-block w-full text-[10px] ellipsize pt-0.5">
{formatedLabel}
</span>
</button>
{:else}
<div class="col-span-4 text-center text-gray-700 text-sm p-2">
No icons match your search
</div>
{/each}
</div>
{:else}
<div class="text-center text-sm text-gray-600 p-2"> Couldn't load options </div>
{/if}
</div>
<Popup
ref={anchor}
options={{ placement: 'bottom' }}
bind:open={openPopup}
let:close
transition={fade}
>
{#if !loading}
<div class="max-w-xs shadow-[0_10px_40px_-5px_rgba(0,0,0,0.25)] bg-white rounded-md p-2">
{#if filteredItems}
<input
on:keydown|stopPropagation
bind:value={search}
type="text"
placeholder="Search"
class="col-span-4 mb-2"
/>
<div class="grid gap-1 grid-cols-4 max-h-[300px] overflow-auto">
{#each filteredItems as { label, icon }}
{@const formatedLabel = formatName(label)}
<button
type="button"
title={formatedLabel}
on:click={() => {
select(label)
close()
}}
class="w-full center-center flex-col font-normal p-1
hover:bg-gray-100 focus:bg-gray-100 rounded duration-200
{label === componentInput.value ? 'text-blue-600 bg-blue-50 pointer-events-none' : ''}"
>
<svelte:component this={icon} size={22} />
<span class="inline-block w-full text-[10px] ellipsize pt-0.5">
{formatedLabel}
</span>
</button>
{:else}
<div class="col-span-4 text-center text-gray-700 text-sm p-2">
No icons match your search
</div>
{/each}
</div>
{:else}
<div class="text-center text-sm text-gray-600 p-2"> Couldn't load options </div>
{/if}
</div>
{/if}
</Popup>
{/if}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
export let code: string
export let value: any
export let value: any = undefined
export let error = ''
function parseJson() {
try {
@@ -11,13 +12,12 @@
error = e.message
}
}
let error = ''
$: code && parseJson()
</script>
<div class="flex flex-col w-full">
<div class="border border-gray-300 w-full">
<SimpleEditor autoHeight lang="json" bind:code />
<SimpleEditor on:change autoHeight lang="json" bind:code />
</div>
{#if error != ''}
<span class="text-red-600 text-xs">{error}</span>

View File

@@ -39,6 +39,29 @@
</select>
{:else if componentInput.fieldType === 'icon-select'}
<IconSelectInput bind:componentInput />
{:else if componentInput.fieldType === 'labeledresource'}
{#if componentInput?.value && typeof componentInput?.value == 'object' && 'label' in componentInput?.value}
<div class="flex flex-col gap-1 w-full">
<input
on:keydown|stopPropagation
placeholder="Label"
type="text"
bind:value={componentInput.value['label']}
/>
<ResourcePicker
initialValue={componentInput.value?.['value']?.split('$res:')[1] || ''}
on:change={(e) => {
let path = e.detail
if (componentInput && path) {
componentInput.value['value'] = `$res:${path}`
}
}}
/>
</div>
{:else}
Inconsistent labeled resource object
{/if}
{:else if componentInput.fieldType === 'color'}
<ColorInput bind:componentInput />
{:else if componentInput.fieldType === 'object'}

View File

@@ -19,6 +19,7 @@
<div class="w-full flex mt-1 items-center gap-2">
<slot />
<input
on:keydown|stopPropagation
type="text"
placeholder="Search inline scripts"
bind:value={filter}

View File

@@ -17,6 +17,7 @@ export type InputType =
| 'object'
| 'array'
| 'any'
| 'labeledresource'
// Connection to an output of another component
// defined by the id of the component and the path of the output
@@ -139,11 +140,11 @@ export type AppInput =
| AppInputSpec<'object', Record<string | number, any>>
| AppInputSpec<'object', string>
| (AppInputSpec<'select', string> & {
/**
* One of the keys of `staticValues` from `lib/components/apps/editor/componentsPanel/componentStaticValues`
*/
optionValuesKey: keyof typeof staticValues
})
/**
* One of the keys of `staticValues` from `lib/components/apps/editor/componentsPanel/componentStaticValues`
*/
optionValuesKey: keyof typeof staticValues
})
| (AppInputSpec<'select', string> & StaticOptions)
| AppInputSpec<'icon-select', string>
| AppInputSpec<'color', string>
@@ -156,9 +157,12 @@ export type AppInput =
| AppInputSpec<'array', string[], 'datetime'>
| AppInputSpec<'array', object[], 'object'>
| (AppInputSpec<'array', string[], 'select'> & {
optionValuesKey: keyof typeof staticValues
})
optionValuesKey: keyof typeof staticValues
})
| (AppInputSpec<'array', string[], 'select'> & StaticOptions)
| AppInputSpec<'array', object[], 'labeledresource'>
| AppInputSpec<'labeledresource', object>
export type RowAppInput = Extract<AppInput, { type: 'row' }>
export type StaticAppInput = Extract<AppInput, { type: 'static' }>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { getContainerHeight } from './utils/container'
import { moveItemsAroundItem, moveItem, getItemById, specifyUndefinedColumns } from './utils/item'
import { onMount, createEventDispatcher } from 'svelte'
import { getColumn, throttle } from './utils/other'
import MoveResize from './MoveResize.svelte'
import type { FilledItem } from './types'
const dispatch = createEventDispatcher()
type T = $$Generic
export let fillSpace = false
export let items: FilledItem<T>[]
export let rowHeight: number
export let cols: [number, number][]
export let gap = [10, 10]
export let fastStart = false
export let throttleUpdate = 100
export let throttleResize = 100
export let onTopId: string | undefined = undefined
export let scroller = undefined
export let sensor = 20
export let parentWidth: number | undefined = undefined
let getComputedCols
let container
$: [gapX, gapY] = gap
let xPerPx = 0
let yPerPx = rowHeight
let containerWidth
$: containerHeight = getContainerHeight(items, yPerPx, getComputedCols)
const pointerup = (ev) => {
dispatch('pointerup', {
id: ev.detail.id,
cols: getComputedCols
})
}
const onResize = throttle(() => {
items = specifyUndefinedColumns(items, getComputedCols, cols)
dispatch('resize', {
cols: getComputedCols,
xPerPx,
yPerPx,
width: containerWidth
})
}, throttleUpdate)
onMount(() => {
const sizeObserver = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
let width = entries[0].contentRect.width
if (width === containerWidth) return
getComputedCols = getColumn(parentWidth ?? width, cols)
xPerPx = width / getComputedCols
if (!containerWidth) {
items = specifyUndefinedColumns(items, getComputedCols, cols)
dispatch('mount', {
cols: getComputedCols,
xPerPx,
yPerPx // same as rowHeight
})
} else {
onResize()
}
containerWidth = width
})
})
sizeObserver.observe(container)
return () => sizeObserver.disconnect()
})
let initItems: FilledItem<T>[] | undefined = undefined
const updateMatrix = ({ detail }) => {
let isPointerUp = detail.isPointerUp
let citems: FilledItem<T>[]
if (isPointerUp) {
citems = JSON.parse(JSON.stringify(initItems))
initItems = undefined
} else {
if (initItems == undefined) {
initItems = JSON.parse(JSON.stringify(items))
}
citems = JSON.parse(JSON.stringify(initItems))
}
let activeItem = getItemById(detail.id, citems)
if (activeItem) {
activeItem = {
...activeItem,
[getComputedCols]: {
...activeItem[getComputedCols],
...detail.shadow
}
}
if (fillSpace) {
items = moveItemsAroundItem(
activeItem,
citems,
getComputedCols,
getItemById(detail.id, citems)
)
} else {
items = moveItem(activeItem, citems, getComputedCols, getItemById(detail.id, citems))
}
if (detail.onUpdate) detail.onUpdate()
dispatch('change', {
unsafeItem: activeItem,
id: activeItem.id,
cols: getComputedCols
})
}
}
const throttleMatrix = throttle(updateMatrix, throttleResize)
const handleRepaint = ({ detail }) => {
if (!detail.isPointerUp) {
throttleMatrix({ detail })
} else {
updateMatrix({ detail })
}
}
</script>
<div class="svlt-grid-container" style="height: {containerHeight}px" bind:this={container}>
{#if xPerPx || !fastStart}
{#each items as item, i (item.id)}
<MoveResize
on:repaint={handleRepaint}
on:pointerup={pointerup}
onTop={item.id == onTopId}
id={item.id}
resizable={item[getComputedCols] && item[getComputedCols].resizable}
draggable={item[getComputedCols] && item[getComputedCols].draggable}
{xPerPx}
{yPerPx}
width={Math.min(getComputedCols, item[getComputedCols] && item[getComputedCols].w) *
xPerPx -
gapX * 2}
height={(item[getComputedCols] && item[getComputedCols].h) * yPerPx - gapY * 2}
top={(item[getComputedCols] && item[getComputedCols].y) * yPerPx + gapY}
left={(item[getComputedCols] && item[getComputedCols].x) * xPerPx + gapX}
item={item[getComputedCols]}
min={item[getComputedCols] && item[getComputedCols].min}
max={item[getComputedCols] && item[getComputedCols].max}
cols={getComputedCols}
{gapX}
{gapY}
{sensor}
container={scroller}
nativeContainer={container}
let:resizePointerDown
let:movePointerDown
>
{#if item[getComputedCols]}
<slot
{movePointerDown}
{resizePointerDown}
dataItem={item}
item={item[getComputedCols]}
index={i}
/>
{/if}
</MoveResize>
{/each}
{/if}
</div>
<style>
.svlt-grid-container {
position: relative;
width: 100%;
}
</style>

View File

@@ -0,0 +1,23 @@
Anything under this repo was initially forked from svelte-grid whose LICENSE is below
MIT License
Copyright (c) 2019 Vahe
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.

View File

@@ -0,0 +1,404 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
export let sensor
export let width
export let height
export let left
export let top
export let resizable
export let draggable
export let id
export let container
export let xPerPx
export let yPerPx
export let gapX
export let gapY
export let item
export let max
export let min
export let cols
export let nativeContainer
export let onTop
let shadowElement
let shadow: { x: number; y: number; w: number; h: number } | undefined = undefined
let active = false
let initX, initY
let capturePos = {
x: 0,
y: 0
}
let cordDiff = { x: 0, y: 0 }
let newSize = { width, height }
let trans = false
let anima
const inActivate = () => {
if (shadowElement && shadow) {
let subgrid = shadowElement.closest('.subgrid')
let irect = shadowElement.getBoundingClientRect()
let shadowBound
if (subgrid) {
let subGridParent = subgrid.parentElement
let subGridParentRect = subGridParent.getBoundingClientRect()
let prect = subgrid.getBoundingClientRect()
const subGridOffset = {
x: subGridParentRect.x - prect.x,
y: subGridParentRect.y - prect.y
}
shadowBound = {
x: irect.x - prect.left - subGridOffset.x,
y: irect.y - prect.top - subGridOffset.y
}
} else {
shadowBound = irect
}
const xdragBound = rect.left + cordDiff.x
const ydragBound = rect.top + cordDiff.y
cordDiff.x = shadow.x * xPerPx + gapX - (shadowBound.x - xdragBound)
cordDiff.y = shadow.y * yPerPx + gapY - (shadowBound.y - ydragBound)
active = false
trans = true
clearTimeout(anima)
anima = setTimeout(() => {
trans = false
}, 100)
dispatch('pointerup', {
id
})
}
}
let repaint = (cb: (() => void) | undefined, isPointerUp: boolean) => {
dispatch('repaint', {
id,
shadow,
isPointerUp,
onUpdate: cb
})
}
// Autoscroll
let _scrollTop = 0
let containerFrame
let rect
let scrollElement
const getContainerFrame = (element) => {
if (element === document.documentElement || !element) {
const { height, top, right, bottom, left } = nativeContainer.getBoundingClientRect()
return {
top: Math.max(0, top),
bottom: Math.min(window.innerHeight, bottom)
}
}
return element.getBoundingClientRect()
}
const getScroller = (element) => (!element ? document.documentElement : element)
function computeRect(target) {
let gridItem = target.closest('.svlt-grid-item')
let subgrid = gridItem.closest('.subgrid')
let irect = gridItem.getBoundingClientRect()
if (subgrid) {
let subGridParent = subgrid.parentElement
let subGridParentRect = subGridParent.getBoundingClientRect()
let prect = subgrid.getBoundingClientRect()
const subGridOffset = {
x: subGridParentRect.x - prect.x,
y: subGridParentRect.y - prect.y
}
rect = {
top: irect.top - prect.top - subGridOffset.y,
left: irect.left - prect.left - subGridOffset.x
}
} else {
rect = irect
}
}
const pointerdown = ({ clientX, clientY, target }) => {
initX = clientX
initY = clientY
capturePos = { x: left, y: top }
shadow = { x: item.x, y: item.y, w: item.w, h: item.h }
newSize = { width, height }
containerFrame = getContainerFrame(container)
scrollElement = getScroller(container)
cordDiff = { x: 0, y: 0 }
computeRect(target)
active = true
trans = false
_scrollTop = scrollElement.scrollTop
window.addEventListener('pointermove', pointermove)
window.addEventListener('pointerup', pointerup)
}
let sign = { x: 0, y: 0 }
let vel = { x: 0, y: 0 }
let intervalId: NodeJS.Timer | undefined = undefined
const stopAutoscroll = () => {
intervalId && clearInterval(intervalId)
intervalId = undefined
sign = { x: 0, y: 0 }
vel = { x: 0, y: 0 }
}
const update = () => {
const _newScrollTop = scrollElement.scrollTop - _scrollTop
const boundX = capturePos.x + cordDiff.x
const boundY = capturePos.y + (cordDiff.y + _newScrollTop)
let gridX = Math.round(boundX / xPerPx)
let gridY = Math.round(boundY / yPerPx)
if (shadow) {
shadow.x = Math.max(Math.min(gridX, cols - shadow.w), 0)
shadow.y = Math.max(gridY, 0)
if (max.y) {
shadow.y = Math.min(shadow.y, max.y)
}
}
repaint(undefined, false)
}
const pointermove = (event) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
const { clientX, clientY } = event
cordDiff = { x: clientX - initX, y: clientY - initY }
const Y_SENSOR = sensor
let velocityTop = Math.max(0, (containerFrame.top + Y_SENSOR - clientY) / Y_SENSOR)
let velocityBottom = Math.max(0, (clientY - (containerFrame.bottom - Y_SENSOR)) / Y_SENSOR)
const topSensor = velocityTop > 0 && velocityBottom === 0
const bottomSensor = velocityBottom > 0 && velocityTop === 0
sign.y = topSensor ? -1 : bottomSensor ? 1 : 0
vel.y = sign.y === -1 ? velocityTop : velocityBottom
if (vel.y > 0) {
if (!intervalId) {
// Start scrolling
// TODO Use requestAnimationFrame
intervalId = setInterval(() => {
scrollElement.scrollTop += 2 * (vel.y + Math.sign(vel.y)) * sign.y
update()
}, 10)
}
} else if (intervalId) {
stopAutoscroll()
} else {
update()
}
}
const pointerup = (e) => {
stopAutoscroll()
window.removeEventListener('pointerdown', pointerdown)
window.removeEventListener('pointermove', pointermove)
window.removeEventListener('pointerup', pointerup)
repaint(inActivate, true)
}
// Resize
let resizeInitPos = { x: 0, y: 0 }
let initSize = { width: 0, height: 0 }
const resizePointerDown = (e) => {
e.stopPropagation()
const { pageX, pageY, target } = e
resizeInitPos = { x: pageX, y: pageY }
initSize = { width, height }
cordDiff = { x: 0, y: 0 }
computeRect(target)
newSize = { width, height }
active = true
trans = false
shadow = { x: item.x, y: item.y, w: item.w, h: item.h }
containerFrame = getContainerFrame(container)
scrollElement = getScroller(container)
window.addEventListener('pointermove', resizePointerMove)
window.addEventListener('pointerup', resizePointerUp)
}
const resizePointerMove = ({ pageX, pageY }) => {
if (shadow) {
newSize.width = initSize.width + pageX - resizeInitPos.x
newSize.height = initSize.height + pageY - resizeInitPos.y
// Get max col number
let maxWidth = cols - shadow.x
maxWidth = Math.min(max.w, maxWidth) || maxWidth
// Limit bound
newSize.width = Math.max(
Math.min(newSize.width, maxWidth * xPerPx - gapX * 2),
min.w * xPerPx - gapX * 2
)
newSize.height = Math.max(newSize.height, min.h * yPerPx - gapY * 2)
if (max.h) {
newSize.height = Math.min(newSize.height, max.h * yPerPx - gapY * 2)
}
// Limit col & row
shadow.w = Math.round((newSize.width + gapX * 2) / xPerPx)
shadow.h = Math.round((newSize.height + gapY * 2) / yPerPx)
repaint(undefined, false)
}
}
const resizePointerUp = (e) => {
e.stopPropagation()
repaint(inActivate, true)
window.removeEventListener('pointermove', resizePointerMove)
window.removeEventListener('pointerup', resizePointerUp)
}
</script>
<div
draggable={false}
on:pointerdown={item && item.customDragger ? null : draggable && pointerdown}
class="svlt-grid-item"
class:svlt-grid-active={active || (trans && rect)}
style="width: {active ? newSize.width : width}px; height:{active
? newSize.height
: height}px; {onTop ? 'z-index: 100;' : ''}
{active
? `transform: translate(${cordDiff.x}px, ${cordDiff.y}px);top:${rect.top}px;left:${rect.left}px;`
: trans
? `transform: translate(${cordDiff.x}px, ${cordDiff.y}px); position:absolute; transition: width 0.2s, height 0.2s;`
: `transition: transform 0.2s, opacity 0.2s; transform: translate(${left}px, ${top}px); `} "
>
<slot movePointerDown={pointerdown} {resizePointerDown} />
{#if resizable && !item.customResizer}
<div class="svlt-grid-resizer" on:pointerdown={resizePointerDown} />
{/if}
</div>
{#if (active || trans) && shadow}
<div
class="svlt-grid-shadow shadow-active"
style="width: {shadow.w * xPerPx - gapX * 2}px; height: {shadow.h * yPerPx -
gapY * 2}px; transform: translate({shadow.x * xPerPx + gapX}px, {shadow.y * yPerPx +
gapY}px); "
bind:this={shadowElement}
/>
{/if}
<style>
.svlt-grid-item {
touch-action: none;
position: absolute;
will-change: auto;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.svlt-grid-resizer {
user-select: none;
width: 20px;
height: 20px;
position: absolute;
right: 0;
bottom: 0;
cursor: se-resize;
}
.svlt-grid-resizer::after {
content: '';
position: absolute;
right: 3px;
bottom: 3px;
width: 5px;
height: 5px;
border-right: 2px solid rgba(0, 0, 0, 0.4);
border-bottom: 2px solid rgba(0, 0, 0, 0.4);
}
.svlt-grid-active {
z-index: 3;
cursor: grabbing;
position: fixed;
opacity: 0.5;
/*No user*/
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-o-backface-visibility: hidden;
-ms-backface-visibility: hidden;
user-select: none;
}
.shadow-active {
z-index: 2;
transition: all 0.2s;
}
.svlt-grid-shadow {
position: absolute;
background: red;
will-change: transform;
background: pink;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
</style>

View File

@@ -0,0 +1,25 @@
export interface Size {
w: number
h: number
}
export interface Positon {
x: number
y: number
}
interface ItemLayout extends Size, Positon {
fixed?: boolean
resizable?: boolean
draggable?: boolean
customDragger?: boolean
customResizer?: boolean
min?: Size
max?: Size
}
export type FilledItem<T> = T & { [width: number]: Required<ItemLayout>; data: any, id: string }

View File

@@ -0,0 +1,5 @@
import { getRowsCount } from "./other";
export function getContainerHeight(items, yPerPx, cols) {
return getRowsCount(items, cols) * yPerPx;
}

View File

@@ -0,0 +1,43 @@
import { makeMatrixFromItems } from "./matrix";
import { findFreeSpaceForItem, normalize, adjust } from "./item";
import { getRowsCount } from "./other";
function makeItem(item) {
const { min = { w: 1, h: 1 }, max } = item;
return {
fixed: false,
resizable: !item.fixed,
draggable: !item.fixed,
customDragger: false,
customResizer: false,
min: {
w: Math.max(1, min.w),
h: Math.max(1, min.h),
},
max: { ...max },
...item,
};
}
const gridHelp = {
normalize(items, col) {
return normalize(items, col);
},
adjust(items, col) {
return adjust(items, col);
},
item(obj) {
return makeItem(obj);
},
findSpace(item, items, cols) {
let matrix = makeMatrixFromItems(items, getRowsCount(items, cols), cols);
let position = findFreeSpaceForItem(matrix, item[cols]);
return position;
},
};
export default gridHelp;

View File

@@ -0,0 +1,260 @@
import type { FilledItem, ItemLayout } from '../types'
import {
makeMatrix,
makeMatrixFromItemsIgnore,
findCloseBlocks,
findItemsById,
makeMatrixFromItems
} from './matrix'
import { getRowsCount } from './other'
export function getItemById(id, items) {
return items.find((value) => value.id === id)
}
export function isEmpty(matrix: any[][], x: number, y: number, w: number, h: number) {
for (var i = 0; i < h; i++) {
if (matrix[y + i]) {
for (var j = 0; j < w; j++) {
if (matrix[y + i][x + j] != undefined) {
return false
}
}
}
}
return true
}
function distance(a, b): number {
return Math.abs(a.x - b.x + 0.25) + Math.abs(a.y - b.y + 0.25)
}
export function findFreeSpaceForItem<T>(matrix: FilledItem<T>[][], item: ItemLayout) {
const cols = matrix[0].length
const w = Math.min(cols, item.w)
const h = item.h
let xNtime = cols - w
let getMatrixRows = matrix.length
const range = Array.from({ length: getMatrixRows }, (_, y) =>
Array.from({ length: xNtime }, (_, x) => ({ x, y }))
)
.flat(1)
.sort((a, b) => {
let dst1 = distance(a, item)
let dst2 = distance(b, item)
if (dst1 > dst2) {
return 1
} else if (dst1 < dst2) {
return -1
} else {
return 0
}
})
for (const { x, y } of range.values()) {
if (isEmpty(matrix, x, y, w, h)) {
return { x, y }
}
}
return {
y: getMatrixRows,
x: 0
}
}
const getItem = (item, col) => {
return { ...item[col], id: item.id }
}
const updateItem = (elements, active, position, col) => {
return elements.map((value) => {
if (value.id === active.id) {
return { ...value, [col]: { ...value[col], ...position } }
}
return value
})
}
export function moveItemsAroundItem(active, items, cols, original) {
// Get current item from the breakpoint
const activeItem = getItem(active, cols)
const ids = items.map((value) => value.id).filter((value) => value !== activeItem.id)
const els = items.filter((value) => value.id !== activeItem.id)
// Update items
let newItems = updateItem(items, active, activeItem, cols)
let matrix = makeMatrixFromItemsIgnore(newItems, ids, getRowsCount(newItems, cols), cols)
let tempItems = newItems
// Exclude resolved elements ids in array
let exclude: string[] = []
els.forEach((item) => {
// Find position for element
let position = findFreeSpaceForItem(matrix, item[cols])
// Exclude item
exclude.push(item.id)
tempItems = updateItem(tempItems, item, position, cols)
// Recreate ids of elements
let getIgnoreItems = ids.filter((value) => exclude.indexOf(value) === -1)
// Update matrix for next iteration
matrix = makeMatrixFromItemsIgnore(
tempItems,
getIgnoreItems,
getRowsCount(tempItems, cols),
cols
)
})
// Return result
return tempItems
}
export function moveItem(active, items, cols, original) {
// Get current item from the breakpoint
const item = getItem(active, cols)
// Create matrix from the items expect the active
let matrix = makeMatrixFromItemsIgnore(items, [item.id], getRowsCount(items, cols), cols)
// Getting the ids of items under active Array<String>
const closeBlocks = findCloseBlocks(matrix, item)
// Getting the objects of items under active Array<Object>
let closeObj = findItemsById(closeBlocks, items)
// Getting whenever of these items is fixed
const fixed = closeObj.find((value) => value[cols].fixed)
// If found fixed, reset the active to its original position
if (fixed) return items
// Update items
items = updateItem(items, active, item, cols)
// Create matrix of items expect close elements
matrix = makeMatrixFromItemsIgnore(items, closeBlocks, getRowsCount(items, cols), cols)
// Create temp vars
let tempItems = items
let tempCloseBlocks = closeBlocks
// Exclude resolved elements ids in array
let exclude: string[] = []
// Iterate over close elements under active item
closeObj.forEach((item) => {
// Find position for element
let position = findFreeSpaceForItem(matrix, item[cols])
// Exclude item
exclude.push(item.id)
// Assign the position to the element in the column
tempItems = updateItem(tempItems, item, position, cols)
// Recreate ids of elements
let getIgnoreItems = tempCloseBlocks.filter((value) => exclude.indexOf(value) === -1)
// Update matrix for next iteration
matrix = makeMatrixFromItemsIgnore(
tempItems,
getIgnoreItems,
getRowsCount(tempItems, cols),
cols
)
})
// Return result
return tempItems
}
// Helper function
export function normalize(items, col) {
let result = items.slice()
result.forEach((value) => {
const getItem = value[col]
if (!getItem.static) {
result = moveItem(getItem, result, col, { ...getItem })
}
})
return result
}
// Helper function
export function adjust<T>(items: FilledItem<T>[], col) {
let matrix = makeMatrix(getRowsCount(items, col), col)
let res: FilledItem<T>[] = []
items.forEach((item) => {
let position = findFreeSpaceForItem(matrix, item[col])
res.push({
...item,
[col]: {
...item[col],
...position
}
})
matrix = makeMatrixFromItems(res, getRowsCount(res, col), col)
})
return res
}
export function getUndefinedItems(items, col, breakpoints) {
return items
.map((value) => {
if (!value[col]) {
return value.id
}
})
.filter(Boolean)
}
export function getClosestColumn(items, item, col, breakpoints) {
return breakpoints
.map(([_, column]) => item[column] && column)
.filter(Boolean)
.reduce(function (acc, value) {
const isLower = Math.abs(value - col) < Math.abs(acc - col)
return isLower ? value : acc
})
}
export function specifyUndefinedColumns(items, col, breakpoints) {
let matrix = makeMatrixFromItems(items, getRowsCount(items, col), col)
const getUndefinedElements = getUndefinedItems(items, col, breakpoints)
let newItems = [...items]
getUndefinedElements.forEach((elementId) => {
const getElement = items.find((item) => item.id === elementId)
const closestColumn = getClosestColumn(items, getElement, col, breakpoints)
const position = findFreeSpaceForItem(matrix, getElement[closestColumn])
const newItem = {
...getElement,
[col]: {
...getElement[closestColumn],
...position
}
}
newItems = newItems.map((value) => (value.id === elementId ? newItem : value))
matrix = makeMatrixFromItems(newItems, getRowsCount(newItems, col), col)
})
return newItems
}

View File

@@ -0,0 +1,65 @@
import type { FilledItem } from "../types";
export const makeMatrix: (w: number, h: number) => any[][] = (rows, cols) => Array.from(Array(rows), () => new Array(cols)); // make 2d array
export function makeMatrixFromItems<T>(items: FilledItem<T>[], row: number, col: number): FilledItem<T>[][] {
let matrix = makeMatrix(row, col);
for (var i = 0; i < items.length; i++) {
const value = items[i][col];
if (value) {
const { x, y, h } = value;
const id = items[i].id;
const w = Math.min(col, value.w);
for (var j = y; j < y + h; j++) {
const row = matrix[j];
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id };
}
}
}
}
return matrix;
}
export function findCloseBlocks<T>(matrix: FilledItem<T>[][], curObject) {
const { h, x, y } = curObject;
const w = Math.min(matrix[0].length, curObject.w);
const tempR = matrix.slice(y, y + h);
let result: string[] = [];
for (var i = 0; i < tempR.length; i++) {
let tempA = tempR[i].slice(x, x + w);
result = [...result, ...tempA.map((val) => val?.id).filter((id) => id !== undefined && id !== curObject.id)];
}
return [...new Set(result)];
}
export function makeMatrixFromItemsIgnore(items, ignoreList, _row, _col) {
let matrix = makeMatrix(_row, _col);
for (var i = 0; i < items.length; i++) {
const value = items[i][_col];
const id = items[i].id;
const { x, y, h } = value;
const w = Math.min(_col, value.w);
if (ignoreList.indexOf(id) === -1) {
for (var j = y; j < y + h; j++) {
const row = matrix[j];
if (row) {
for (var k = x; k < x + w; k++) {
row[k] = { ...value, id };
}
}
}
}
}
return matrix;
}
export function findItemsById<T>(closeBlocks: string[], items: FilledItem<T>[]) {
return items.filter((value) => closeBlocks.indexOf(value.id) !== -1);
}

View File

@@ -0,0 +1,35 @@
export function throttle(func, timeFrame) {
let lastTime = new Date().getTime();
return function (...args) {
let now = new Date().getTime();
if (now - lastTime >= timeFrame) {
func(...args);
lastTime = now;
}
};
}
export function getRowsCount(items, cols) {
const getItemsMaxHeight = items.map((val) => {
const item = val[cols];
return (item && item.y) + (item && item.h) || 0;
});
return Math.max(...getItemsMaxHeight, 1);
}
export const getColumn = (containerWidth, columns) => {
const sortColumns = columns.slice().sort((a, b) => a[0] - b[0]);
const breakpoint = sortColumns.find((value) => {
const [width] = value;
return containerWidth <= width;
});
if (breakpoint) {
return breakpoint[1];
} else {
return sortColumns[sortColumns.length - 1][1];
}
};

View File

@@ -1,9 +1,8 @@
import type { Schema } from '$lib/common'
import type { Preview } from '$lib/gen'
import type { History } from '$lib/history'
import type { FilledItem } from '@windmill-labs/svelte-grid'
import type { Writable } from 'svelte/store'
import type VariableEditor from '../VariableEditor.svelte'
import type { AppComponent } from './editor/component/components'
import type {
AppInput,
@@ -16,6 +15,7 @@ import type {
UserAppInput
} from './inputType'
import type { World } from './rx'
import type { FilledItem } from './svelte-grid/types'
export type HorizontalAlignment = 'left' | 'center' | 'right'
export type VerticalAlignment = 'top' | 'center' | 'bottom'
@@ -137,7 +137,7 @@ export type AppViewerContext = {
}
export type AppEditorContext = {
history: History<App>
history: History<App> | undefined
componentControl: Writable<Record<string, { left?: () => boolean; right?: () => boolean }>>
pickVariableCallback: Writable<((path: string) => void) | undefined>
}

View File

@@ -0,0 +1,18 @@
<script>
import { faClose } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import { Icon } from 'svelte-awesome'
export let noBg = false
const dispatch = createEventDispatcher()
</script>
<button
on:click={() => dispatch('close')}
class="hover:bg-gray-200 {noBg
? ''
: 'bg-gray-100'} rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<Icon data={faClose} class="text-gray-500" />
</button>

View File

@@ -9,6 +9,7 @@
export let href = ''
export let rounded = false
export let dismissable = false
export let wrapperClass = ''
export let baseClass = 'text-center'
export let capitalize = false
export let icon: BadgeIconProps | undefined = undefined
@@ -56,7 +57,11 @@
const handleHide = () => (hidden = !hidden)
</script>
<span on:click on:keydown class="inline-flex justify-center items-center whitespace-nowrap">
<span
on:click
on:keydown
class="inline-flex justify-center items-center whitespace-nowrap {wrapperClass}"
>
<svelte:element
this={href ? 'a' : 'span'}
{href}

View File

@@ -3,6 +3,7 @@
import { faClose } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import { Icon } from 'svelte-awesome'
import CloseButton from '../CloseButton.svelte'
export let title: string | undefined = undefined
export let overflow_y = true
@@ -15,12 +16,7 @@
<div class="flex flex-col divide-y h-screen max-h-screen">
<div class="flex justify-between w-wull items-center px-2 py-2 ">
<div class="flex items-center gap-2">
<button
on:click={() => dispatch('close')}
class="hover:bg-gray-200 bg-gray-100 rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<Icon data={faClose} class="text-gray-500" />
</button>
<CloseButton on:close />
<span class="font-semibold truncate text-gray-800">{title}</span>
</div>

View File

@@ -63,7 +63,7 @@
}
show = !show
}}
class="relative cursor-pointer hover:bg-gray-100/30"
class="relative"
>
<slot class="triggerable" name="trigger" />
</div>

View File

@@ -16,6 +16,7 @@
export let innerClasses = ''
export let outerClasses = ''
export let transition: (node: Element, params?: Record<string, any>) => TransitionConfig = slide
export { openFocusIn as open, closed as close }
const states = ['closed', 'open-focus-in', 'open-focus-out'] as const
const stateMachine = createStateMachine(states, {
@@ -158,7 +159,7 @@
{#if $stateMachine.currentState !== 'closed'}
<div transition:transition|local={{ duration: 200 }} class={outerClasses}>
<div class={innerClasses}>
<slot close={closed} />
<slot open={openFocusIn} close={closed} />
</div>
</div>
{/if}

View File

@@ -86,7 +86,6 @@
</span>
<Dropdown
placement="bottom-end"
btnClasses="!text-gray-700 !bg-transparent hover:!bg-gray-400/20 !p-[6px]"
dropdownItems={[
{
displayName: 'View',
@@ -96,7 +95,8 @@
{
displayName: 'Edit',
icon: faPen,
href: `/apps/edit/${path}?nodraft=true`
href: `/apps/edit/${path}?nodraft=true`,
disabled: !canWrite
},
{
displayName: 'Use as template',
@@ -136,8 +136,6 @@
disabled: !canWrite
}
]}
>
<MoreVertical size={20} />
</Dropdown>
/>
</svelte:fragment>
</Row>

View File

@@ -130,7 +130,6 @@
<Dropdown
placement="bottom-end"
btnClasses="!text-gray-700 !bg-transparent hover:!bg-gray-400/20 !p-[6px]"
dropdownItems={[
{
displayName: 'View flow',
@@ -201,8 +200,6 @@
disabled: !canWrite
}
]}
>
<MoreVertical size={20} />
</Dropdown>
/>
</svelte:fragment>
</Row>

View File

@@ -146,7 +146,6 @@
</span>
<Dropdown
placement="bottom-end"
btnClasses="!text-gray-700 !bg-transparent hover:!bg-gray-400/20 !p-[6px]"
dropdownItems={[
{
displayName: 'View script',
@@ -229,8 +228,6 @@
}
])
]}
>
<MoreVertical size={20} />
</Dropdown>
/>
</svelte:fragment>
</Row>

View File

@@ -35,6 +35,7 @@
)}
style={`${style} ${$selected?.startsWith(value) ? selectedStyle : ''}`}
on:click={() => update(value)}
on:pointerdown|stopPropagation
>
<slot />
</div>

View File

@@ -341,7 +341,7 @@
undefined,
1000,
loopDepth,
0,
modules.findIndex((m) => m.id == module.id) + 1,
true,
undefined,
module.id

View File

@@ -1,3 +0,0 @@
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.

View File

@@ -1,58 +0,0 @@
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');
}
}

View File

@@ -1,145 +0,0 @@
# 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.

View File

@@ -1,44 +0,0 @@
# 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.

View File

@@ -1,5 +0,0 @@
# 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.

View File

@@ -1,34 +0,0 @@
# 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.

View File

@@ -1,14 +0,0 @@
## 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

View File

@@ -1,25 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

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