feat: add favorite/star + remove flows/scripts page in favor of unified home page (#968)
* favorites * favorites * favorites * feat(frontend): add favorite + refactor homepage
This commit is contained in:
1
backend/migrations/20221128210151_favorites.down.sql
Normal file
1
backend/migrations/20221128210151_favorites.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
11
backend/migrations/20221128210151_favorites.up.sql
Normal file
11
backend/migrations/20221128210151_favorites.up.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Add up migration script here
|
||||
|
||||
CREATE TYPE FAVORITE_KIND AS ENUM ('app', 'script', 'flow');
|
||||
|
||||
CREATE TABLE favorite (
|
||||
usr VARCHAR(50) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
favorite_kind FAVORITE_KIND NOT NULL,
|
||||
PRIMARY KEY (usr, workspace_id, favorite_kind, path)
|
||||
);
|
||||
@@ -580,6 +580,32 @@
|
||||
},
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username\n FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = false"
|
||||
},
|
||||
"22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"app",
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
},
|
||||
"name": "favorite_kind"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "DELETE FROM favorite WHERE workspace_id = $1 AND usr = $2 AND path = $3 AND favorite_kind = $4"
|
||||
},
|
||||
"23086afd75927486884944e48b768e956d1fd77ce08c6f345fcde083b1e9bbf1": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
@@ -1719,6 +1745,32 @@
|
||||
},
|
||||
"query": "UPDATE queue SET canceled = true, canceled_by = $1, canceled_reason = $2 WHERE parent_job = $3 AND workspace_id = $4 RETURNING id"
|
||||
},
|
||||
"653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"app",
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
},
|
||||
"name": "favorite_kind"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": "INSERT INTO favorite (workspace_id, usr, path, favorite_kind) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING"
|
||||
},
|
||||
"6c63bbcb45d3f51eccaea52ec862700e1f1c2426d823abd951e1eea4fd9b85aa": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
|
||||
@@ -1748,6 +1748,13 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: starred_only
|
||||
description: |
|
||||
(default false)
|
||||
show only the starred items
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: All available scripts
|
||||
@@ -2191,6 +2198,13 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: starred_only
|
||||
description: |
|
||||
(default false)
|
||||
show only the starred items
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: All available flow
|
||||
@@ -3572,6 +3586,52 @@ paths:
|
||||
"404":
|
||||
description: capture does not exist for this flow
|
||||
|
||||
/w/{workspace}/favorites/star:
|
||||
post:
|
||||
summary: star item
|
||||
operationId: star
|
||||
tags:
|
||||
- favorite
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script]
|
||||
responses:
|
||||
"200":
|
||||
description: star item
|
||||
|
||||
/w/{workspace}/favorites/unstar:
|
||||
post:
|
||||
summary: unstar item
|
||||
operationId: unstar
|
||||
tags:
|
||||
- favorite
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script]
|
||||
responses:
|
||||
"200":
|
||||
description: unstar item
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -3808,6 +3868,8 @@ components:
|
||||
kind:
|
||||
type: string
|
||||
enum: [script, failure, trigger, command, approval]
|
||||
starred:
|
||||
type: boolean
|
||||
required:
|
||||
- hash
|
||||
- path
|
||||
@@ -3821,6 +3883,7 @@ components:
|
||||
- extra_perms
|
||||
- language
|
||||
- kind
|
||||
- starred
|
||||
|
||||
ScriptArgs:
|
||||
type: object
|
||||
@@ -4696,6 +4759,8 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
starred:
|
||||
type: boolean
|
||||
required:
|
||||
- path
|
||||
- edited_by
|
||||
|
||||
75
backend/windmill-api/src/favorite.rs
Normal file
75
backend/windmill-api/src/favorite.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::{db::DB, users::Authed};
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use windmill_common::error::Result;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/star", post(star))
|
||||
.route("/unstar", post(unstar))
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||
#[sqlx(type_name = "FAVORITE_KIND", rename_all = "lowercase")]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
pub enum FavoriteKind {
|
||||
Script,
|
||||
Flow,
|
||||
App,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Favorite {
|
||||
pub favorite_kind: FavoriteKind,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
async fn star(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(Favorite { favorite_kind, path }): Json<Favorite>,
|
||||
) -> Result<String> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO favorite (workspace_id, usr, path, favorite_kind) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||||
&w_id,
|
||||
authed.username,
|
||||
path,
|
||||
favorite_kind: FavoriteKind,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Starred {}", path))
|
||||
}
|
||||
|
||||
async fn unstar(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(Favorite { favorite_kind, path }): Json<Favorite>,
|
||||
) -> Result<String> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM favorite WHERE workspace_id = $1 AND usr = $2 AND path = $3 AND favorite_kind = $4",
|
||||
&w_id,
|
||||
authed.username,
|
||||
path,
|
||||
favorite_kind: FavoriteKind,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Starred {}", path))
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use sqlx::{Postgres, Transaction};
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{self, to_anyhow, Error, JsonResult, Result},
|
||||
flows::{Flow, ListFlowQuery, NewFlow},
|
||||
flows::{Flow, ListFlowQuery, ListableFlow, NewFlow},
|
||||
utils::{
|
||||
http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath,
|
||||
},
|
||||
@@ -54,24 +54,30 @@ async fn list_flows(
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListFlowQuery>,
|
||||
) -> JsonResult<Vec<Flow>> {
|
||||
) -> JsonResult<Vec<ListableFlow>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let mut sqlb = SqlBuilder::select_from("flow as o")
|
||||
.fields(&[
|
||||
"workspace_id",
|
||||
"path",
|
||||
"o.workspace_id",
|
||||
"o.path",
|
||||
"summary",
|
||||
"description",
|
||||
"'{}'::jsonb as value",
|
||||
"edited_by",
|
||||
"edited_at",
|
||||
"archived",
|
||||
"null schema",
|
||||
"extra_perms",
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
.on(
|
||||
"favorite.favorite_kind = 'flow' AND favorite.path = o.path AND favorite.usr = ?"
|
||||
.bind(&authed.username),
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("edited_at", lq.order_desc.unwrap_or(true))
|
||||
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -88,10 +94,15 @@ async fn list_flows(
|
||||
if let Some(cb) = &lq.edited_by {
|
||||
sqlb.and_where_eq("edited_by", "?".bind(cb));
|
||||
}
|
||||
if let Some(so) = &lq.starred_only {
|
||||
sqlb.and_where_eq("starred", "?".bind(so));
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, Flow>(&sql).fetch_all(&mut tx).await?;
|
||||
let rows = sqlx::query_as::<_, ListableFlow>(&sql)
|
||||
.fetch_all(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ mod apps;
|
||||
mod audit;
|
||||
mod capture;
|
||||
mod db;
|
||||
mod favorite;
|
||||
mod flows;
|
||||
mod granular_acls;
|
||||
mod groups;
|
||||
@@ -123,7 +124,8 @@ pub async fn run_server(
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest("/capture", capture::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service()),
|
||||
.nest("/apps", apps::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service()),
|
||||
)
|
||||
.nest("/workspaces", workspaces::global_service())
|
||||
.nest(
|
||||
|
||||
@@ -31,7 +31,8 @@ use std::{
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
scripts::{
|
||||
to_i64, HubScript, ListScriptQuery, NewScript, Script, ScriptHash, ScriptKind, ScriptLang,
|
||||
to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Script, ScriptHash,
|
||||
ScriptKind, ScriptLang,
|
||||
},
|
||||
users::owner_to_token_owner,
|
||||
utils::{
|
||||
@@ -76,32 +77,37 @@ async fn list_scripts(
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListScriptQuery>,
|
||||
) -> JsonResult<Vec<Script>> {
|
||||
) -> JsonResult<Vec<ListableScript>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let mut sqlb = SqlBuilder::select_from("script as o")
|
||||
.fields(&[
|
||||
"workspace_id",
|
||||
"o.workspace_id",
|
||||
"hash",
|
||||
"path",
|
||||
"o.path",
|
||||
"array_remove(array[parent_hashes[1]], NULL) as parent_hashes",
|
||||
"summary",
|
||||
"description",
|
||||
"'' as content",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"archived",
|
||||
"null as schema",
|
||||
"deleted",
|
||||
"is_template",
|
||||
"extra_perms",
|
||||
"null as lock",
|
||||
"CASE WHEN lock_error_logs IS NOT NULL THEN 'error' ELSE null END as lock_error_logs",
|
||||
"language",
|
||||
"kind",
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
.on(
|
||||
"favorite.favorite_kind = 'script' AND favorite.path = o.path AND favorite.usr = ?"
|
||||
.bind(&authed.username),
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||
.and_where("o.workspace_id = ? OR o.workspace_id = 'starter'".bind(&w_id))
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
@@ -110,7 +116,8 @@ async fn list_scripts(
|
||||
sqlb.and_where_eq(
|
||||
"created_at",
|
||||
"(select max(created_at) from script where o.path = path
|
||||
AND (workspace_id = $1 OR workspace_id = 'starter'))",
|
||||
AND (workspace_id = ? OR workspace_id = 'starter'))"
|
||||
.bind(&w_id),
|
||||
);
|
||||
} else {
|
||||
sqlb.and_where_eq("archived", false);
|
||||
@@ -139,10 +146,15 @@ async fn list_scripts(
|
||||
if let Some(k) = &lq.kind {
|
||||
sqlb.and_where_eq("kind", "?".bind(&k.to_lowercase()));
|
||||
}
|
||||
if let Some(so) = &lq.starred_only {
|
||||
sqlb.and_where_eq("starred", "?".bind(so));
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, Script>(&sql).fetch_all(&mut tx).await?;
|
||||
let rows = sqlx::query_as::<_, ListableScript>(&sql)
|
||||
.fetch_all(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,20 @@ pub struct Flow {
|
||||
pub extra_perms: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
|
||||
pub struct ListableFlow {
|
||||
pub workspace_id: String,
|
||||
pub path: String,
|
||||
pub summary: String,
|
||||
pub description: String,
|
||||
pub edited_by: String,
|
||||
pub edited_at: chrono::DateTime<chrono::Utc>,
|
||||
pub archived: bool,
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub starred: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
|
||||
pub struct NewFlow {
|
||||
@@ -224,4 +238,5 @@ pub struct ListFlowQuery {
|
||||
pub show_archived: Option<bool>,
|
||||
pub order_by: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub starred_only: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -126,6 +126,27 @@ pub struct Script {
|
||||
pub kind: ScriptKind,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))]
|
||||
pub struct ListableScript {
|
||||
pub workspace_id: String,
|
||||
pub hash: ScriptHash,
|
||||
pub path: String,
|
||||
pub parent_hashes: Option<ScriptHashes>,
|
||||
pub summary: String,
|
||||
pub description: String,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub archived: bool,
|
||||
pub deleted: bool,
|
||||
pub is_template: bool,
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub lock_error_logs: Option<String>,
|
||||
pub language: ScriptLang,
|
||||
pub kind: ScriptKind,
|
||||
pub starred: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "sqlx", sqlx)]
|
||||
@@ -168,6 +189,7 @@ pub struct ListScriptQuery {
|
||||
pub order_desc: Option<bool>,
|
||||
pub is_template: Option<bool>,
|
||||
pub kind: Option<String>,
|
||||
pub starred_only: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn to_i64(s: &str) -> crate::error::Result<i64> {
|
||||
|
||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -60,6 +60,7 @@
|
||||
"svelte-grid": "^5.1.1",
|
||||
"svelte-heros": "^2.3.5",
|
||||
"svelte-highlight": "^6.2.1",
|
||||
"svelte-lucide": "^0.2.0",
|
||||
"svelte-markdown": "^0.2.3",
|
||||
"svelte-overlay": "^1.4.1",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
@@ -6104,6 +6105,12 @@
|
||||
"svelte": ">=3.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-lucide": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-lucide/-/svelte-lucide-0.2.0.tgz",
|
||||
"integrity": "sha512-Ki+M3rHNEcopLLjWzSfWiE4YumevBqqVfSQeWjsq1ZsZJrXyiEhh0qJB0Gb7Oj3OZVFo3uSOOMMQOxYHAVxLvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/svelte-markdown": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.2.3.tgz",
|
||||
@@ -11316,6 +11323,12 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"svelte-lucide": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-lucide/-/svelte-lucide-0.2.0.tgz",
|
||||
"integrity": "sha512-Ki+M3rHNEcopLLjWzSfWiE4YumevBqqVfSQeWjsq1ZsZJrXyiEhh0qJB0Gb7Oj3OZVFo3uSOOMMQOxYHAVxLvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte-markdown": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.2.3.tgz",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"svelte-grid": "^5.1.1",
|
||||
"svelte-heros": "^2.3.5",
|
||||
"svelte-highlight": "^6.2.1",
|
||||
"svelte-lucide": "^0.2.0",
|
||||
"svelte-markdown": "^0.2.3",
|
||||
"svelte-overlay": "^1.4.1",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
|
||||
147
frontend/src/lib/components/FlowBox.svelte
Normal file
147
frontend/src/lib/components/FlowBox.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { FlowService, ScriptService, type Flow, type Script } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
faCalendarAlt,
|
||||
faCodeFork,
|
||||
faEdit,
|
||||
faEye,
|
||||
faList,
|
||||
faPlay,
|
||||
faShare,
|
||||
faWind
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { Button } from './common'
|
||||
import Dropdown from './Dropdown.svelte'
|
||||
import SharedBadge from './SharedBadge.svelte'
|
||||
import type ShareModal from './ShareModal.svelte'
|
||||
import Star from './Star.svelte'
|
||||
|
||||
export let flow: Flow & { canWrite: boolean }
|
||||
export let marked: string | undefined
|
||||
export let starred: boolean
|
||||
|
||||
export let shareModal: ShareModal
|
||||
const { summary, path, extra_perms, canWrite } = flow
|
||||
|
||||
async function archiveFlow(path: string): Promise<void> {
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
sendUserToast(`Successfully archived flow ${path}`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not archive this flow ${err.body}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="border border-gray-400 py-2 px-4 rounded-sm shadow-sm hover:border-blue-600 text-gray-800 flex flex-row items-center justify-between"
|
||||
href="/flows/get/{path}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 w-full h-full">
|
||||
<div class="font-semibold text-gray-700 truncate">
|
||||
<Icon data={faWind} class="mr-2" scale={1} />
|
||||
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? path : summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
|
||||
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center"
|
||||
>{path}
|
||||
<Star kind="flow" {path} {starred} on:starred={() => dispatch('change')} />
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse place gap-x-2 pt-4">
|
||||
<div>
|
||||
<Dropdown
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View flow',
|
||||
icon: faEye,
|
||||
href: `/flows/get/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/flows/edit/${path}`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template/Fork',
|
||||
icon: faCodeFork,
|
||||
href: `/flows/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
href: `/schedule/add?path=${path}&isFlow=true`
|
||||
},
|
||||
{
|
||||
displayName: 'Share',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer(path)
|
||||
},
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveFlow(path) : null
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button color="dark" size="xs" href="/flows/run/{path}" startIcon={{ icon: faPlay }}
|
||||
>Run</Button
|
||||
>
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
href="/flows/edit/{path}"
|
||||
startIcon={{ icon: faEdit }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
href="/flows/add?template={path}"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div></div
|
||||
></a
|
||||
>
|
||||
157
frontend/src/lib/components/ScriptBox.svelte
Normal file
157
frontend/src/lib/components/ScriptBox.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
faCalendarAlt,
|
||||
faCodeFork,
|
||||
faEdit,
|
||||
faEye,
|
||||
faList,
|
||||
faPlay,
|
||||
faScroll,
|
||||
faShare
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { Badge, Button } from './common'
|
||||
import { LanguageIcon } from './common/languageIcons'
|
||||
import Dropdown from './Dropdown.svelte'
|
||||
import SharedBadge from './SharedBadge.svelte'
|
||||
import type ShareModal from './ShareModal.svelte'
|
||||
import Star from './Star.svelte'
|
||||
|
||||
export let script: Script & { canWrite: boolean }
|
||||
export let marked: string | undefined
|
||||
export let starred: boolean
|
||||
export let shareModal: ShareModal
|
||||
let { summary, path, hash, language, extra_perms, canWrite, lock_error_logs, kind } = script
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
async function archiveScript(path: string): Promise<void> {
|
||||
await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
sendUserToast(`Successfully archived script ${path}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="border border-gray-400 py-2 px-4 rounded-sm shadow-sm hover:border-blue-600 text-gray-800"
|
||||
href="/scripts/get/{hash}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 w-full h-full">
|
||||
<div class="font-semibold text-gray-700 truncate">
|
||||
<Icon data={faScroll} class="mr-2" scale={1} />
|
||||
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? path : summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
|
||||
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center">
|
||||
{path}
|
||||
<Star kind="script" {path} {starred} on:starred={() => dispatch('change')} />
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
<div><LanguageIcon height={16} lang={language} /></div>
|
||||
{#if kind != 'script'}
|
||||
<Badge color="blue" capitalize>{kind}</Badge>
|
||||
{/if}
|
||||
{#if lock_error_logs}
|
||||
<Badge color="red">Deployment error</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-end grow pt-4">
|
||||
<div class="flex flex-row-reverse place gap-x-2">
|
||||
<div>
|
||||
<Dropdown
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View script',
|
||||
icon: faEye,
|
||||
href: `/scripts/get/${hash}`
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Edit code',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}?step=2`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template',
|
||||
icon: faCodeFork,
|
||||
href: `/scripts/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
href: `/schedule/add?path=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Share',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer(path)
|
||||
},
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveScript(path) : null
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button color="dark" size="xs" startIcon={{ icon: faPlay }} href="/scripts/run/{hash}">
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="dark"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/scripts/edit/{hash}?step=2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/scripts/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div></div
|
||||
>
|
||||
</div></a
|
||||
>
|
||||
@@ -5,12 +5,12 @@
|
||||
export let items: any[]
|
||||
export let f: (item: any) => string
|
||||
export let filteredItems: (any & { marked: string })[]
|
||||
export let opts: uFuzzy.Options = {}
|
||||
|
||||
let opts = {}
|
||||
let uf = new uFuzzy(opts)
|
||||
$: plaintextItems = items.map((item) => f(item))
|
||||
|
||||
$: plaintextItems && filter != undefined && setTimeout(() => filterItems(), 100)
|
||||
$: plaintextItems && filter != undefined && setTimeout(() => filterItems(), 0)
|
||||
|
||||
function filterItems() {
|
||||
if (filter.length == 0) {
|
||||
@@ -24,6 +24,7 @@
|
||||
let order = uf.sort(info, plaintextItems, filter)
|
||||
|
||||
let result: any[] = []
|
||||
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
let infoIdx = order[i]
|
||||
result.push({
|
||||
|
||||
41
frontend/src/lib/components/Star.svelte
Normal file
41
frontend/src/lib/components/Star.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { FavoriteService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { faStar } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { Star } from 'svelte-lucide'
|
||||
|
||||
export let path: string
|
||||
export let kind: 'flow' | 'app' | 'script'
|
||||
export let starred = false
|
||||
|
||||
async function onClick() {
|
||||
if (starred) {
|
||||
await FavoriteService.unstar({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { path, favorite_kind: kind }
|
||||
})
|
||||
sendUserToast('Marked as favorite, it will appear first in the list')
|
||||
} else {
|
||||
await FavoriteService.star({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { path, favorite_kind: kind }
|
||||
})
|
||||
sendUserToast('Marked as favorite, it will appear first in the list')
|
||||
}
|
||||
dispatch('starred', !starred)
|
||||
}
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<button on:click|preventDefault={onClick} class="mx-1">
|
||||
{#if starred}
|
||||
<div>
|
||||
<Icon data={faStar} class="hover:text-gray-300" scale={1.1} />
|
||||
</div>
|
||||
{:else}
|
||||
<Star size="18px" class="hover:bg-gray-200" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPlus, faWind } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import { loadHubFlows, sendUserToast } from '$lib/utils'
|
||||
@@ -10,6 +10,7 @@
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { flowStore, initFlow } from '$lib/components/flows/flowStore'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
let hubFlows: any[] | undefined = undefined
|
||||
|
||||
@@ -49,12 +50,9 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<ButtonPopup size="md" startIcon={{ icon: faPlus }} href="/flows/add">
|
||||
<svelte:fragment slot="main">New Flow</svelte:fragment>
|
||||
<ButtonPopupItem on:click={() => drawers.hub?.openDrawer()}>
|
||||
Import flow from WindmillHub
|
||||
</ButtonPopupItem>
|
||||
<svelte:fragment slot="main">New Flow<Icon data={faWind} class="ml-1" /></svelte:fragment>
|
||||
<ButtonPopupItem on:click={() => drawers.json?.toggleDrawer()}>
|
||||
Import flow from raw JSON
|
||||
Import from raw JSON
|
||||
</ButtonPopupItem>
|
||||
</ButtonPopup>
|
||||
</div>
|
||||
@@ -29,8 +29,8 @@
|
||||
<SearchItems {filter} {items} bind:filteredItems f={(x) => x.summary} />
|
||||
|
||||
<div class="flex flex-col min-h-0">
|
||||
<div class="w-12/12 pb-2 flex flex-row mt-1 gap-1">
|
||||
<input type="text" placeholder="Search Scripts" bind:value={filter} class="text-2xl grow" />
|
||||
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
|
||||
<input type="text" placeholder="Search Hub Scripts" bind:value={filter} class="text-2xl grow" />
|
||||
</div>
|
||||
|
||||
<div class="gap-2 w-full flex flex-wrap pb-2">
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation'
|
||||
import { faExternalLink, faFile, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
</script>
|
||||
|
||||
<div class=" overflow-auto">
|
||||
<div class="mt-2 mb-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
Flows allow you to streamline complex processes and operations by chaining simple steps
|
||||
together. Each Flow is composed of one or more steps.
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap gap-y-2">
|
||||
<Button href="/flows/add" color="dark" size="xs" btnClasses="mr-2" startIcon={{ icon: faPlus }}>
|
||||
Create flow
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => goto('https://docs.windmill.dev/docs/getting_started/flows')}
|
||||
color="blue"
|
||||
size="xs"
|
||||
btnClasses="mr-2"
|
||||
startIcon={{ icon: faFile }}
|
||||
>
|
||||
Flow documentation
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => goto('https://hub.windmill.dev/')}
|
||||
color="light"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: faExternalLink }}
|
||||
target="_blank"
|
||||
>
|
||||
Explore community flows on WindmillHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import type { Flow } from '$lib/gen'
|
||||
import { faPencil, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Button } from '../common'
|
||||
|
||||
export let flow: Flow
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="#{flow.path}"
|
||||
class="border border-gray-400 p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between cursor-pointer"
|
||||
on:click={() => goto(`/flows/get/${flow.path}`)}
|
||||
>
|
||||
<div class="font-bold">{flow.summary || flow.path}</div>
|
||||
|
||||
<div class="inline-flex justify-between w-full break-words">
|
||||
<div class="text-xs">{flow.path}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
href="/flows/edit/{flow.path}"
|
||||
color="dark"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faPencil }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href="/flows/run/{flow.path}"
|
||||
color="dark"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faPlay }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation'
|
||||
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import HatIcon from '../icons/HatIcon.svelte'
|
||||
</script>
|
||||
|
||||
<div class="fle flex-row gap-2">
|
||||
<div class="mt-2 mb-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
Connect to apps like Slack, Google Drive or Airtable using OAuth.
|
||||
</div>
|
||||
<Button href="/resources" color="dark" startIcon={{ icon: faLink }} size="xs">
|
||||
Connect an API
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import type { Script } from '$lib/gen'
|
||||
|
||||
import { truncateHash } from '$lib/utils'
|
||||
import { faPencil, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Button, Badge } from '$lib/components/common'
|
||||
import { LanguageIcon } from '../common/languageIcons'
|
||||
|
||||
export let script: Script
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="border border-gray-400 p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between"
|
||||
href="/scripts/get/{script.hash}"
|
||||
>
|
||||
<div class="font-bold">{script.summary || script.path}</div>
|
||||
|
||||
<div class="inline-flex justify-between w-full">
|
||||
<div class="text-xs">{script.path}</div>
|
||||
<div><LanguageIcon height={16} lang={script.language} /></div>
|
||||
</div>
|
||||
<div class="inline-flex space-x-1 w-full">
|
||||
{#if script.kind !== 'script'}
|
||||
<Badge color="green" capitalize>
|
||||
{script.kind}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
href="/scripts/edit/{script.hash}?step=2"
|
||||
color="dark"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faPencil }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href="/scripts/run/{script.hash}"
|
||||
color="dark"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faPlay }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation'
|
||||
import { faExternalLink, faFile, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import HatIcon from '../icons/HatIcon.svelte'
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
<div class="inline-flex flex-wrap gap-y-2">
|
||||
<Button
|
||||
href="/scripts/add"
|
||||
color="dark"
|
||||
size="xs"
|
||||
btnClasses="mr-2"
|
||||
startIcon={{ icon: faPlus }}
|
||||
>
|
||||
Create script
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => goto('https://docs.windmill.dev/docs/getting_started/scripts')}
|
||||
color="blue"
|
||||
size="xs"
|
||||
btnClasses="mr-2"
|
||||
startIcon={{ icon: faFile }}
|
||||
>
|
||||
Script documentation
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => goto('https://hub.windmill.dev/')}
|
||||
color="light"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: faExternalLink }}
|
||||
target="_blank"
|
||||
>
|
||||
Explore community scripts on WindmillHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPlus, faScroll } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fuse from 'fuse.js'
|
||||
import { Script } from '$lib/gen'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
@@ -10,12 +10,11 @@
|
||||
import type { HubItem } from '../flows/pickers/model'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
const drawers: {
|
||||
hub: ItemPicker | undefined
|
||||
template: Drawer | undefined
|
||||
} = {
|
||||
hub: undefined,
|
||||
template: undefined
|
||||
}
|
||||
let hubItems: HubItem[]
|
||||
@@ -47,29 +46,13 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<ButtonPopup size="md" startIcon={{ icon: faPlus }} href="/scripts/add">
|
||||
<svelte:fragment slot="main">New Script</svelte:fragment>
|
||||
<ButtonPopupItem on:click={() => drawers.hub?.openDrawer()}>
|
||||
Import script from WindmillHub
|
||||
</ButtonPopupItem>
|
||||
<svelte:fragment slot="main">New Script<Icon data={faScroll} class="ml-1" /></svelte:fragment>
|
||||
<ButtonPopupItem on:click={() => drawers.template?.toggleDrawer()}>
|
||||
Import script from template
|
||||
Import from template
|
||||
</ButtonPopupItem>
|
||||
</ButtonPopup>
|
||||
</div>
|
||||
|
||||
<!-- Initially hidden elements in a drawer -->
|
||||
<!-- WindmillHub script list -->
|
||||
<ItemPicker
|
||||
bind:this={drawers.hub}
|
||||
pickCallback={(path) => {
|
||||
goto('/scripts/add?hub=' + path)
|
||||
}}
|
||||
itemName={'Script'}
|
||||
extraField="summary"
|
||||
loadItems={async () => {
|
||||
return hubItems
|
||||
}}
|
||||
/>
|
||||
<!-- Template script list -->
|
||||
<Drawer bind:this={drawers.template} size="800px" on:open={loadTemplateScripts}>
|
||||
<DrawerContent title="Pick a template" on:close={() => drawers.template?.toggleDrawer()}>
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
const mainMenuLinks = [
|
||||
{ label: 'Home', href: '/', icon: faHomeAlt },
|
||||
{ label: 'Scripts', href: '/scripts', icon: faCode },
|
||||
{ label: 'Flows', href: '/flows', icon: faWind },
|
||||
{ label: 'Runs', href: '/runs', icon: faPlay },
|
||||
{ label: 'Schedules', href: '/schedules', icon: faCalendar },
|
||||
{ label: 'Variables', href: '/variables', icon: faWallet },
|
||||
@@ -54,18 +52,17 @@
|
||||
|
||||
<div class="flex-1 flex flex-col py-4 overflow-x-hidden scrollbar-hidden">
|
||||
<nav class="h-full flex justify-between flex-col px-2">
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-1 pt-4">
|
||||
{#each mainMenuLinks as menuLink}
|
||||
<MenuLink class="text-lg" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
<div class="h-8" />
|
||||
{#each secondaryMenuLinks as menuLink}
|
||||
<MenuLink class="text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-1-2">
|
||||
<div class="h-4" />
|
||||
{#each secondaryMenuLinks as menuLink}
|
||||
<MenuLink class="text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
<div class="h-8" />
|
||||
{#each thirdMenuLinks as menuLink}
|
||||
<MenuLink class="text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
/>
|
||||
|
||||
<div class="flex flex-col min-h-0">
|
||||
<div class="w-12/12 pb-2 flex flex-row mt-1 gap-1">
|
||||
<input type="text" placeholder="Search Flows" bind:value={filter} class="text-2xl grow" />
|
||||
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
|
||||
<input type="text" placeholder="Search Hub Flows" bind:value={filter} class="text-2xl grow" />
|
||||
</div>
|
||||
|
||||
<div class="gap-2 w-full flex flex-wrap pb-2">
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
<script context="module">
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Flows' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { FlowService, type OpenFlow } from '$lib/gen'
|
||||
import type { Flow } from '$lib/gen'
|
||||
|
||||
import { sendUserToast, canWrite } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
faBuilding,
|
||||
faCalendarAlt,
|
||||
faCodeFork,
|
||||
faEdit,
|
||||
faEye,
|
||||
faGlobe,
|
||||
faList,
|
||||
faPlay,
|
||||
faShare
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import FlowViewer from '$lib/components/FlowViewer.svelte'
|
||||
import { Button, Tabs, Tab, Skeleton, Badge } from '../lib/components/common'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import CreateActions from '$lib/components/flows/CreateActions.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import PickHubFlow from './PickHubFlow.svelte'
|
||||
|
||||
type Tab = 'hub' | 'workspace'
|
||||
type FlowW = Flow & { canWrite: boolean; marked?: string }
|
||||
let flows: FlowW[] = []
|
||||
let filteredFlows: FlowW[] = []
|
||||
let flowFilter = ''
|
||||
let tab: Tab = 'workspace'
|
||||
let shareModal: ShareModal
|
||||
|
||||
let flowViewer: Drawer
|
||||
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined
|
||||
|
||||
let loading = true
|
||||
|
||||
async function loadFlows(): Promise<void> {
|
||||
flows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map((x: Flow) => {
|
||||
return {
|
||||
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
})
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function archiveFlow(path: string): Promise<void> {
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
loadFlows()
|
||||
sendUserToast(`Successfully archived flow ${path}`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not archive this flow ${err.body}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function viewFlow(obj: { flow_id: number }): Promise<void> {
|
||||
// console.log(obj)
|
||||
|
||||
const hub = await FlowService.getHubFlowById({ id: obj.flow_id })
|
||||
flowViewerFlow = hub
|
||||
flowViewer.openDrawer()
|
||||
}
|
||||
|
||||
$: owners = Array.from(
|
||||
new Set(filteredFlows?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
|
||||
$: preFilteredFlows =
|
||||
ownerFilter != undefined ? flows.filter((x) => x.path.startsWith(ownerFilter ?? '')) : flows
|
||||
|
||||
let ownerFilter: string | undefined = undefined
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && ($userStore || $superadmin)) {
|
||||
loadFlows()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
filter={flowFilter}
|
||||
items={preFilteredFlows}
|
||||
bind:filteredItems={filteredFlows}
|
||||
f={(x) => x.summary + ' (' + x.path + ')'}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={flowViewer} size="900px">
|
||||
<DrawerContent title="Hub flow" on:close={flowViewer.closeDrawer}>
|
||||
<div slot="submission" class="flex flex-row gap-2 pr-2"
|
||||
><Button
|
||||
href="https://hub.windmill.dev/flows/{flowViewerFlow?.flow?.id}"
|
||||
startIcon={{ icon: faGlobe }}
|
||||
variant="border">View on the Hub</Button
|
||||
><Button href="/flows/add?hub={flowViewerFlow?.flow?.id}" startIcon={{ icon: faCodeFork }}
|
||||
>Fork</Button
|
||||
></div
|
||||
>
|
||||
|
||||
{#if flowViewerFlow?.flow}
|
||||
<FlowViewer flow={flowViewerFlow.flow} />
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader title="Flows" tooltip="Flows can compose and chain scripts together">
|
||||
<div class="flex flex-row">
|
||||
<CreateActions />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="xl" value="workspace"><Icon data={faBuilding} class="mr-1" /> Workspace</Tab>
|
||||
<Tab size="xl" value="hub"><Icon data={faGlobe} class="mr-1" /> Hub</Tab>
|
||||
</Tabs>
|
||||
|
||||
<div class="mb-1" />
|
||||
{#if tab != 'hub'}
|
||||
<input type="text" placeholder="Search Flows" bind:value={flowFilter} class="text-2xl mt-2" />
|
||||
|
||||
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
|
||||
{#each owners as owner}
|
||||
<Badge
|
||||
class="cursor-pointer hover:bg-gray-200"
|
||||
on:click={() => {
|
||||
ownerFilter = ownerFilter == owner ? undefined : owner
|
||||
}}
|
||||
color={owner === ownerFilter ? 'blue' : 'gray'}
|
||||
>
|
||||
{owner}
|
||||
{#if owner === ownerFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
|
||||
{#if !loading}
|
||||
{#each filteredFlows as { summary, path, extra_perms, canWrite, marked }}
|
||||
<a
|
||||
class="border border-gray-400 p-2 rounded-sm shadow-sm hover:border-blue-600 text-gray-800 flex flex-row items-center justify-between"
|
||||
href="/flows/get/{path}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 w-full h-full">
|
||||
<div class="font-semibold text-gray-700 truncate">
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? path : summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
|
||||
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center"
|
||||
>{path}
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse place gap-x-2 pt-4">
|
||||
<div>
|
||||
<Dropdown
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View flow',
|
||||
icon: faEye,
|
||||
href: `/flows/get/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/flows/edit/${path}`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template/Fork',
|
||||
icon: faCodeFork,
|
||||
href: `/flows/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
href: `/schedule/add?path=${path}&isFlow=true`
|
||||
},
|
||||
{
|
||||
displayName: 'Share',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer(path)
|
||||
},
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveFlow(path) : null
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
size="xs"
|
||||
href="/flows/run/{path}"
|
||||
startIcon={{ icon: faPlay }}>Run</Button
|
||||
>
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
href="/flows/edit/{path}"
|
||||
startIcon={{ icon: faEdit }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
href="/flows/add?template={path}"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div></div
|
||||
></a
|
||||
>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each Array(10).fill(0) as sk}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<PickHubFlow on:pick={(e) => viewFlow(e.detail)} />
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
kind="flow"
|
||||
on:change={() => {
|
||||
loadFlows()
|
||||
}}
|
||||
/>
|
||||
@@ -1,39 +1,150 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import JobDetail from '$lib/components/jobs/JobDetail.svelte'
|
||||
import FlowGettingStarted from '$lib/components/landing/FlowGettingStarted.svelte'
|
||||
import FlowLandingBox from '$lib/components/landing/FlowLandingBox.svelte'
|
||||
import RessourceGettingStarted from '$lib/components/landing/RessourceGettingStarted.svelte'
|
||||
import ScriptBox from '$lib/components/landing/ScriptBox.svelte'
|
||||
import ScriptGettingStarted from '$lib/components/landing/ScriptGettingStarted.svelte'
|
||||
import { FlowService, Job, JobService, Script, ScriptService, type Flow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { Alert, Skeleton } from '$lib/components/common'
|
||||
import {
|
||||
FlowService,
|
||||
Job,
|
||||
JobService,
|
||||
Script,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
type OpenFlow
|
||||
} from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
Skeleton,
|
||||
Tab,
|
||||
Tabs,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup
|
||||
} from '$lib/components/common'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import CreateActionsFlow from '$lib/components/flows/CreateActionsFlow.svelte'
|
||||
import CreateActionsScript from '$lib/components/scripts/CreateActionsScript.svelte'
|
||||
import { canWrite, getScriptByPath, sendUserToast } from '$lib/utils'
|
||||
import type { HubItem } from '$lib/components/flows/pickers/model'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import {
|
||||
faBuilding,
|
||||
faCodeFork,
|
||||
faGlobe,
|
||||
faScroll,
|
||||
faWind
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
import PickHubFlow from './PickHubFlow.svelte'
|
||||
import FlowViewer from '$lib/components/FlowViewer.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import ScriptBox from '$lib/components/ScriptBox.svelte'
|
||||
import FlowBox from '$lib/components/FlowBox.svelte'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
|
||||
let scripts: Script[] = []
|
||||
let flows: Flow[] = []
|
||||
let jobs: Job[] = []
|
||||
let loading = {
|
||||
scripts: true,
|
||||
flows: true,
|
||||
jobs: true
|
||||
|
||||
type Tab = 'hubscripts' | 'hubflows' | 'workspace'
|
||||
type ScriptW = Script & { canWrite: boolean; marked?: string }
|
||||
type FlowW = Flow & { canWrite: boolean; marked?: string }
|
||||
let scripts: ScriptW[] = []
|
||||
let flows: FlowW[] = []
|
||||
let filteredItems: ((ScriptW & { type: 'script' }) | (FlowW & { type: 'flow' }))[] = []
|
||||
|
||||
let itemKind: 'script' | 'flow' | 'all' = 'all'
|
||||
|
||||
let tab: Tab = 'workspace'
|
||||
let filter: string = ''
|
||||
let shareModalScripts: ShareModal
|
||||
let shareModalFlows: ShareModal
|
||||
|
||||
let loading = true
|
||||
let flowViewer: Drawer
|
||||
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined
|
||||
|
||||
let codeViewer: Drawer
|
||||
let codeViewerContent: string = ''
|
||||
let codeViewerLanguage: 'deno' | 'python3' | 'go' | 'bash' = 'deno'
|
||||
let codeViewerObj: HubItem | undefined = undefined
|
||||
|
||||
async function loadScripts(): Promise<void> {
|
||||
scripts = (await ScriptService.listScripts({ workspace: $workspaceStore!, perPage: 300 })).map(
|
||||
(x: Script) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
}
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
scripts = await ScriptService.listScripts({
|
||||
workspace: $workspaceStore!,
|
||||
perPage: 3
|
||||
})
|
||||
loading.scripts = false
|
||||
async function viewCode(obj: HubItem) {
|
||||
const { content, language } = await getScriptByPath(obj.path)
|
||||
codeViewerContent = content
|
||||
codeViewerLanguage = language
|
||||
codeViewerObj = obj
|
||||
codeViewer.openDrawer()
|
||||
}
|
||||
async function loadFlows() {
|
||||
flows = await FlowService.listFlows({
|
||||
workspace: $workspaceStore!,
|
||||
perPage: 3
|
||||
|
||||
async function loadFlows(): Promise<void> {
|
||||
flows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map((x: Flow) => {
|
||||
return {
|
||||
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
})
|
||||
loading.flows = false
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function archiveFlow(path: string): Promise<void> {
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
loadFlows()
|
||||
sendUserToast(`Successfully archived flow ${path}`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not archive this flow ${err.body}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function viewFlow(obj: { flow_id: number }): Promise<void> {
|
||||
const hub = await FlowService.getHubFlowById({ id: obj.flow_id })
|
||||
flowViewerFlow = hub
|
||||
flowViewer.openDrawer()
|
||||
}
|
||||
|
||||
$: owners = Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
|
||||
let combinedItems: (
|
||||
| (ScriptW & { type: 'script'; time: number })
|
||||
| (FlowW & { type: 'flow'; time: number })
|
||||
)[] = []
|
||||
|
||||
$: combinedItems = [
|
||||
...flows.map((x) => ({ ...x, type: 'flow' as 'flow', time: new Date(x.edited_at).getTime() })),
|
||||
...scripts.map((x) => ({
|
||||
...x,
|
||||
type: 'script' as 'script',
|
||||
time: new Date(x.created_at).getTime()
|
||||
}))
|
||||
].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1))
|
||||
|
||||
$: preFilteredItems =
|
||||
ownerFilter != undefined
|
||||
? combinedItems.filter(
|
||||
(x) => x.path.startsWith(ownerFilter ?? '') && (x.type == itemKind || itemKind == 'all')
|
||||
)
|
||||
: combinedItems.filter((x) => x.type == itemKind || itemKind == 'all')
|
||||
|
||||
let ownerFilter: string | undefined = undefined
|
||||
|
||||
async function loadJobs() {
|
||||
jobs = await JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -41,106 +152,217 @@
|
||||
createdBy: $userStore?.username,
|
||||
jobKinds: 'flow,script'
|
||||
})
|
||||
loading.jobs = false
|
||||
loading = false
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($userStore && $workspaceStore) {
|
||||
if (($userStore || $superadmin) && $workspaceStore) {
|
||||
loadScripts()
|
||||
loadFlows()
|
||||
loadJobs()
|
||||
}
|
||||
}
|
||||
const cmp = new Intl.Collator('en').compare
|
||||
const opts: uFuzzy.Options = {
|
||||
sort: (info, haystack, needle) => {
|
||||
let {
|
||||
idx,
|
||||
chars,
|
||||
terms,
|
||||
interLft2,
|
||||
interLft1,
|
||||
// interRgt2,
|
||||
// interRgt1,
|
||||
start,
|
||||
intraIns,
|
||||
interIns
|
||||
} = info
|
||||
|
||||
const resources = []
|
||||
return idx
|
||||
.map((v, i) => i)
|
||||
.sort(
|
||||
(ia, ib) =>
|
||||
// most contig chars matched
|
||||
chars[ib] - chars[ia] ||
|
||||
// least char intra-fuzz (most contiguous)
|
||||
intraIns[ia] - intraIns[ib] ||
|
||||
// most prefix bounds, boosted by full term matches
|
||||
terms[ib] +
|
||||
interLft2[ib] +
|
||||
0.5 * interLft1[ib] -
|
||||
(terms[ia] + interLft2[ia] + 0.5 * interLft1[ia]) ||
|
||||
// highest density of match (least span)
|
||||
// span[ia] - span[ib] ||
|
||||
// highest density of match (least term inter-fuzz)
|
||||
interIns[ia] - interIns[ib] ||
|
||||
// earliest start of match
|
||||
start[ia] - start[ib] ||
|
||||
// alphabetic
|
||||
cmp(haystack[idx[ia]], haystack[idx[ib]]) +
|
||||
(preFilteredItems[idx[ib]].starred ? 100 : 0) -
|
||||
(preFilteredItems[idx[ia]].starred ? 100 : 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<CenteredPage>
|
||||
<h1 class="flex items-center min-h-[48px] font-black my-4">Home</h1>
|
||||
<div class="space-y-8">
|
||||
{#if $workspaceStore == 'demo'}
|
||||
<Alert title="Demo workspace">The demo workspace shared in which all users get invited.</Alert
|
||||
>
|
||||
{:else if $workspaceStore == 'starter'}
|
||||
<Alert title="Stater workspace"
|
||||
>The starter workspace has all its elements (variables, resources, scripts, flows) shared
|
||||
across all other workspaces. Useful to seed workspace with common elements within your
|
||||
organization.</Alert
|
||||
>
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ? x.summary + ' (' + x.path + ')' : x.path)}
|
||||
{opts}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModalScripts}
|
||||
kind="script"
|
||||
on:change={() => {
|
||||
loadScripts()
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModalFlows}
|
||||
kind="flow"
|
||||
on:change={() => {
|
||||
loadFlows()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={codeViewer} size="900px">
|
||||
<DrawerContent title={codeViewerObj?.summary ?? ''} on:close={codeViewer.closeDrawer}>
|
||||
<div slot="submission" class="flex flex-row gap-2 pr-2"
|
||||
><Button
|
||||
href="https://hub.windmill.dev/scripts/{codeViewerObj?.app ?? ''}/{codeViewerObj?.ask_id ??
|
||||
0}"
|
||||
startIcon={{ icon: faGlobe }}
|
||||
variant="border">View on the Hub</Button
|
||||
><Button
|
||||
href="/scripts/add?hub={encodeURIComponent(codeViewerObj?.path ?? '')}"
|
||||
startIcon={{ icon: faCodeFork }}>Fork</Button
|
||||
></div
|
||||
>
|
||||
|
||||
<HighlightCode language={codeViewerLanguage} code={codeViewerContent} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={flowViewer} size="900px">
|
||||
<DrawerContent title="Hub flow" on:close={flowViewer.closeDrawer}>
|
||||
<div slot="submission" class="flex flex-row gap-2 pr-2"
|
||||
><Button
|
||||
href="https://hub.windmill.dev/flows/{flowViewerFlow?.flow?.id}"
|
||||
startIcon={{ icon: faGlobe }}
|
||||
variant="border">View on the Hub</Button
|
||||
><Button href="/flows/add?hub={flowViewerFlow?.flow?.id}" startIcon={{ icon: faCodeFork }}
|
||||
>Fork</Button
|
||||
></div
|
||||
>
|
||||
|
||||
{#if flowViewerFlow?.flow}
|
||||
<FlowViewer flow={flowViewerFlow.flow} />
|
||||
{/if}
|
||||
<div>
|
||||
<h2 class="border-b mb-4 py-2">
|
||||
<span class="text-black-gradient">Scripts</span>
|
||||
</h2>
|
||||
<ScriptGettingStarted />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class="mt-6 mb-2 text-md font-bold text-gray-900 ">Latest scripts:</div>
|
||||
<Skeleton loading={loading.scripts} layout={[0.5, [12.25, 12.25, 12.25]]} />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 my-4">
|
||||
{#each scripts as script}
|
||||
<ScriptBox {script} />
|
||||
{/each}
|
||||
<a
|
||||
href="/scripts"
|
||||
class="text-sm font-extrabold text-gray-700 hover:underline inline-flex items-center"
|
||||
>
|
||||
All scripts
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<CenteredPage>
|
||||
{#if $workspaceStore == 'demo'}
|
||||
<div class="my-4" />
|
||||
<Alert title="Demo workspace">The demo workspace shared in which all users get invited.</Alert>
|
||||
{:else if $workspaceStore == 'starter'}
|
||||
<div class="my-4" />
|
||||
|
||||
<Alert title="Stater workspace"
|
||||
>The starter workspace has all its elements (variables, resources, scripts, flows) shared
|
||||
across all other workspaces. Useful to seed workspace with common elements within your
|
||||
organization.</Alert
|
||||
>
|
||||
{/if}
|
||||
<PageHeader title="Home">
|
||||
<div class="flex flex-row gap-8">
|
||||
<CreateActionsScript />
|
||||
<CreateActionsFlow />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="border-b mb-4 py-2">
|
||||
<span class="text-black-gradient">Flows</span>
|
||||
</h2>
|
||||
<FlowGettingStarted />
|
||||
<div class="mt-6 mb-2 text-md font-bold text-gray-900 ">Latest flows:</div>
|
||||
</PageHeader>
|
||||
|
||||
<Skeleton loading={loading.flows} layout={[1, [13.5, 13.5, 13.5]]} />
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 my-4">
|
||||
{#each flows as flow}
|
||||
<FlowLandingBox {flow} />
|
||||
{/each}
|
||||
<a
|
||||
href="/flows"
|
||||
class="text-sm font-extrabold text-gray-700 hover:underline inline-flex items-center"
|
||||
>
|
||||
All flows
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="border-b mb-4 py-2">
|
||||
<span class="text-black-gradient">Resources</span>
|
||||
</h2>
|
||||
<div class="my-6" />
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="xl" value="workspace"><Icon data={faBuilding} class="mr-2" />Workspace</Tab>
|
||||
<Tab value="hubscripts"><Icon data={faGlobe} class="mr-2" />Hub Scripts</Tab>
|
||||
<Tab value="hubflows"><Icon data={faGlobe} class="mr-2" />Hub Flows</Tab>
|
||||
</Tabs>
|
||||
<div class="my-2" />
|
||||
<div class="flex flex-col gap-y-16">
|
||||
<div class="max-h-screen h-full flex flex-col">
|
||||
{#if tab == 'workspace'}
|
||||
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
|
||||
<input
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="Search Scripts, Flows & Apps"
|
||||
bind:value={filter}
|
||||
class="text-2xl grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if resources.length === 0}
|
||||
<RessourceGettingStarted />
|
||||
<div class="max-w-min">
|
||||
<ToggleButtonGroup bind:selected={itemKind}>
|
||||
<ToggleButton light position="left" value="all" size="xs">All</ToggleButton>
|
||||
<ToggleButton light position="center" value="script" size="xs"
|
||||
><Icon data={faScroll} class="mr-1" />Scripts</ToggleButton
|
||||
>
|
||||
<ToggleButton light position="right" value="flow" size="xs"
|
||||
><Icon data={faWind} class="mr-1" />Flows</ToggleButton
|
||||
>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
|
||||
{#each owners as owner}
|
||||
<Badge
|
||||
class="cursor-pointer hover:bg-gray-200"
|
||||
on:click={() => {
|
||||
ownerFilter = ownerFilter == owner ? undefined : owner
|
||||
}}
|
||||
color={owner === ownerFilter ? 'blue' : 'gray'}
|
||||
>
|
||||
{owner}
|
||||
{#if owner === ownerFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
|
||||
{#if !loading}
|
||||
{#each filter != '' ? filteredItems : preFilteredItems as item (item.path)}
|
||||
{#if item.type == 'script'}
|
||||
<ScriptBox
|
||||
starred={item.starred}
|
||||
marked={item.marked}
|
||||
on:change={loadScripts}
|
||||
script={item}
|
||||
shareModal={shareModalScripts}
|
||||
/>
|
||||
{:else if item.type == 'flow'}
|
||||
<FlowBox
|
||||
starred={item.starred ?? false}
|
||||
marked={item.marked}
|
||||
on:change={loadFlows}
|
||||
flow={item}
|
||||
shareModal={shareModalFlows}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each Array(10).fill(0) as sk}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'hubscripts'}
|
||||
<PickHubScript on:pick={(e) => viewCode(e.detail)} />
|
||||
{:else if tab == 'hubflows'}
|
||||
<PickHubFlow on:pick={(e) => viewFlow(e.detail)} />
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
@@ -149,7 +371,7 @@
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 my-4">
|
||||
<Skeleton loading={loading.jobs} layout={[[6], 1, [6], 1, [6]]} />
|
||||
<Skeleton {loading} layout={[[6], 1, [6], 1, [6]]} />
|
||||
{#each jobs.splice(0, 3) as job}
|
||||
<JobDetail {job} />
|
||||
{/each}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
<script context="module">
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Scripts' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
faArchive,
|
||||
faBuilding,
|
||||
faCalendarAlt,
|
||||
faCodeFork,
|
||||
faEdit,
|
||||
faEye,
|
||||
faGlobe,
|
||||
faList,
|
||||
faPlay,
|
||||
faShare
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import type { Script } from '$lib/gen'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, getScriptByPath, sendUserToast } from '$lib/utils'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { Button, Tabs, Tab, Badge, Skeleton, DrawerContent } from '$lib/components/common'
|
||||
import CreateActions from '$lib/components/scripts/CreateActions.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
import type { HubItem } from '$lib/components/flows/pickers/model'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
type Tab = 'workspace' | 'hub'
|
||||
type ScriptW = Script & { canWrite: boolean; marked?: string }
|
||||
let scripts: ScriptW[] = []
|
||||
let preFilteredScripts: ScriptW[] = []
|
||||
let filteredScripts: ScriptW[] = []
|
||||
let filter = ''
|
||||
|
||||
let loading = true
|
||||
|
||||
let tab: Tab = 'workspace'
|
||||
|
||||
let shareModal: ShareModal
|
||||
|
||||
let codeViewer: Drawer
|
||||
let codeViewerContent: string = ''
|
||||
let codeViewerLanguage: 'deno' | 'python3' | 'go' | 'bash' = 'deno'
|
||||
let codeViewerObj: HubItem | undefined = undefined
|
||||
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
|
||||
async function loadScripts(): Promise<void> {
|
||||
scripts = (await ScriptService.listScripts({ workspace: $workspaceStore!, perPage: 300 })).map(
|
||||
(x: Script) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
}
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function archiveScript(path: string): Promise<void> {
|
||||
await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path })
|
||||
loadScripts()
|
||||
sendUserToast(`Successfully archived script ${path}`)
|
||||
}
|
||||
|
||||
async function viewCode(obj: HubItem) {
|
||||
const { content, language } = await getScriptByPath(obj.path)
|
||||
codeViewerContent = content
|
||||
codeViewerLanguage = language
|
||||
codeViewerObj = obj
|
||||
codeViewer.openDrawer()
|
||||
}
|
||||
|
||||
$: owners = Array.from(
|
||||
new Set(filteredScripts?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
|
||||
$: preFilteredScripts =
|
||||
ownerFilter != undefined ? scripts.filter((x) => x.path.startsWith(ownerFilter ?? '')) : scripts
|
||||
|
||||
let ownerFilter: string | undefined = undefined
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && ($userStore || $superadmin)) {
|
||||
loadScripts()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredScripts}
|
||||
bind:filteredItems={filteredScripts}
|
||||
f={(x) => x.summary + ' (' + x.path + ')'}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={codeViewer} size="900px">
|
||||
<DrawerContent title={codeViewerObj?.summary ?? ''} on:close={codeViewer.closeDrawer}>
|
||||
<div slot="submission" class="flex flex-row gap-2 pr-2"
|
||||
><Button
|
||||
href="https://hub.windmill.dev/scripts/{codeViewerObj?.app ?? ''}/{codeViewerObj?.ask_id ??
|
||||
0}"
|
||||
startIcon={{ icon: faGlobe }}
|
||||
variant="border">View on the Hub</Button
|
||||
><Button
|
||||
href="/scripts/add?hub={encodeURIComponent(codeViewerObj?.path ?? '')}"
|
||||
startIcon={{ icon: faCodeFork }}>Fork</Button
|
||||
></div
|
||||
>
|
||||
|
||||
<HighlightCode language={codeViewerLanguage} code={codeViewerContent} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Scripts"
|
||||
tooltip="A Script can be used standalone or as part of a Flow.
|
||||
When standalone, it has webhooks and an auto-generated UI from its parameters whom you can access clicking on 'Run'.
|
||||
Scripts have owners (users or groups) and can be shared to users and groups."
|
||||
>
|
||||
<CreateActions />
|
||||
</PageHeader>
|
||||
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="xl" value="workspace"><Icon data={faBuilding} class="mr-1" /> Workspace</Tab>
|
||||
<Tab size="xl" value="hub"><Icon data={faGlobe} class="mr-1" /> Hub</Tab>
|
||||
</Tabs>
|
||||
|
||||
<div class="mb-1" />
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<input type="text" placeholder="Search Scripts" bind:value={filter} class="text-2xl mt-2" />
|
||||
|
||||
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
|
||||
{#each owners as owner}
|
||||
<Badge
|
||||
class="cursor-pointer hover:bg-gray-200"
|
||||
on:click={() => {
|
||||
ownerFilter = ownerFilter == owner ? undefined : owner
|
||||
}}
|
||||
color={owner === ownerFilter ? 'blue' : 'gray'}
|
||||
>
|
||||
{owner}
|
||||
{#if owner === ownerFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
|
||||
{#if !loading}
|
||||
{#each filteredScripts as { summary, path, hash, language, extra_perms, canWrite, lock_error_logs, kind, marked } (path)}
|
||||
<a
|
||||
class="border border-gray-400 p-2 rounded-sm shadow-sm hover:border-blue-600 text-gray-800"
|
||||
href="/scripts/get/{hash}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 w-full h-full">
|
||||
<div class="font-semibold text-gray-700 truncate">
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? path : summary}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
|
||||
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center">
|
||||
{path}
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
<div><LanguageIcon height={16} lang={language} /></div>
|
||||
{#if kind != 'script'}
|
||||
<Badge color="blue" capitalize>{kind}</Badge>
|
||||
{/if}
|
||||
{#if lock_error_logs}
|
||||
<Badge color="red">Deployment error</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-end grow pt-4">
|
||||
<div class="flex flex-row-reverse place gap-x-2 items-end">
|
||||
<div>
|
||||
<Dropdown
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View script',
|
||||
icon: faEye,
|
||||
href: `/scripts/get/${hash}`
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Edit code',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}?step=2`,
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template',
|
||||
icon: faCodeFork,
|
||||
href: `/scripts/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
href: `/schedule/add?path=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Share',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer(path)
|
||||
},
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveScript(path) : null
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
size="xs"
|
||||
startIcon={{ icon: faPlay }}
|
||||
href="/scripts/run/{hash}"
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="dark"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/scripts/edit/{hash}?step=2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="dark"
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/scripts/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div></div
|
||||
>
|
||||
</div></a
|
||||
>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each Array(10).fill(0) as sk}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<PickHubScript on:pick={(e) => viewCode(e.detail)} />
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
|
||||
<ShareModal
|
||||
bind:this={shareModal}
|
||||
kind="script"
|
||||
on:change={() => {
|
||||
loadScripts()
|
||||
}}
|
||||
/>
|
||||
Reference in New Issue
Block a user