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:
Ruben Fiszel
2022-11-30 00:08:51 +01:00
parent 2b4a72ade6
commit 751dca0d28
29 changed files with 996 additions and 939 deletions

View File

@@ -0,0 +1 @@
-- Add down migration script here

View 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)
);

View File

@@ -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": [],

View File

@@ -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

View 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))
}

View File

@@ -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))
}

View File

@@ -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(

View File

@@ -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))
}

View File

@@ -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>,
}

View File

@@ -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> {

View File

@@ -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",

View File

@@ -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",

View 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
>

View 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
>

View File

@@ -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({

View 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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()}>

View File

@@ -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}

View File

@@ -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">

View File

@@ -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}&cross;{/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()
}}
/>

View File

@@ -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}&cross;{/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}

View File

@@ -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}&cross;{/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()
}}
/>