Merge remote-tracking branch 'origin/main' into feat/frontend/save-flow-builder-layout

This commit is contained in:
Guilhem
2025-06-19 16:35:40 +01:00
130 changed files with 1904 additions and 2830 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.43
- uses: astral-sh/setup-uv@v6
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.6.2"
- uses: actions-rust-lang/setup-rust-toolchain@v1
+67 -13
View File
@@ -64,22 +64,76 @@ jobs:
with:
fetch-depth: 1
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./backend -> target"
# Cache node dependencies
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Run npm install and generate-backend-client
working-directory: ./frontend
run: |
# add a build directory for cargo check
mkdir -p build
npm install
npm run generate-backend-client
- name: install xmlsec1
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxmlsec1-dev
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.85.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: backend
- name: cargo check
working-directory: ./backend
timeout-minutes: 16
run: |
SQLX_OFFLINE=true cargo check --features $(./all_features_oss.sh)
- name: Run Claude PR Action
uses: anthropics/claude-code-action@beta
env:
SQLX_OFFLINE: true
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
allowed_tools: "mcp__github__create_pull_request,Bash(npm run check),Bash(npm install),Bash(cargo check),Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y)"
custom_instructions: "IMPORTANT INSTRUCTIONS:
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main, with the title starting with [Claude PR].
- If you made changes to the frontend code, run npm install, then npm run generate-backend-client, then npm run check. You can ignore warnings that are reported by the check script, but fix the errors.
- If you made changes to the backend code, install Rust and then run cargo check. You can ignore warnings that are reported by the check script, but fix the errors.
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
AVAILABLE TOOLS:
- mcp__github__create_pull_request: Create a PR from a branch to main
- Bash(npm run check): Run the check script. You should run this tool after making changes to the frontend code.
- Bash(npm install): Install dependencies. You need this to run npm run check.
- Bash(npm run generate-backend-client): Generate the backend client. You need this to run npm run check.
- Bash(cargo check): Run the cargo check script. You should run this tool after making changes to the backend code.
- Bash(curl https://sh.rustup.rs -sSf | sh -s -- -y): Install Rust. You need this to run cargo check."
allowed_tools: "mcp__github__create_pull_request,Bash"
custom_instructions: |
## IMPORTANT INSTRUCTIONS
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
## Code Quality Requirements
After making any code changes, you MUST run the appropriate validation commands:
**Frontend Changes:**
- Run: `npm run check` in the frontend directory
- Fix all warnings and errors before proceeding
**Backend Changes:**
- Run: `cargo check --features $(./all_features_oss.sh)` in the backend directory
- Fix all warnings and errors before proceeding
**Pull Request Creation:**
- DO NOT FORGET TO OPEN A DRAFT PR AFTER YOU ARE DONE if you made changes after a request from a git issue.
## Available Tools
- mcp__github__create_pull_request: Create PRs from branches
- Bash: Full access to run validation commands and git operations
trigger_phrase: "/ai"
+105
View File
@@ -0,0 +1,105 @@
name: Update SQLx
on:
issue_comment:
types: [created]
jobs:
update-sqlx:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/updatesqlx')
runs-on: ubicloud-standard-8
permissions:
contents: write
pull-requests: write
issues: write
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: windmill
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Comment on PR - Starting
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Starting sqlx update...'
})
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.issue.pull_request.head.ref }}
fetch-depth: 0
- name: Checkout windmill-ee-private
uses: actions/checkout@v3
with:
repository: windmill-labs/windmill-ee-private
path: windmill-ee-private
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./backend -> target"
- name: Install xmlsec build-time deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxml2-dev libssl-dev \
xmlsec1 libxmlsec1-dev libxmlsec1-openssl
- name: Run update-sqlx script
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/windmill
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
mkdir frontend/build
cd backend
cargo install sqlx-cli --version 0.8.5
sqlx migrate run
./update_sqlx.sh --dir ./windmill-ee-private
# Pass the branch name to the next step
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
- name: Commit changes if any
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/.sqlx
git commit -m "Update SQLx metadata"
git push origin ${{ env.BRANCH_NAME }}
- name: Comment on PR - Completed
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Successfully ran sqlx update'
})
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.499.0](https://github.com/windmill-labs/windmill/compare/v1.498.0...v1.499.0) (2025-06-18)
### Features
* devOps role can edit worker groups ([#5984](https://github.com/windmill-labs/windmill/issues/5984)) ([b1c4f8b](https://github.com/windmill-labs/windmill/commit/b1c4f8b29d0fb4cad76853110b84a87892b54661))
### Bug Fixes
* prevent keypress events from bubbling in decision tree drawer ([#5993](https://github.com/windmill-labs/windmill/issues/5993)) ([2a33442](https://github.com/windmill-labs/windmill/commit/2a334421e85abf046784aab57522582439ef2901))
## [1.498.0](https://github.com/windmill-labs/windmill/compare/v1.497.2...v1.498.0) (2025-06-17)
+10 -3
View File
@@ -1,3 +1,10 @@
To have an overview of what this app does, see @.cursor/rules/windmill-overview.mdc
For backend modifications, follow the rules mentioned here @.cursor/rules/rust-best-practices.mdc. You also have access to a summarized version of the database schema here @backend/summarized_schema.txt
For frontend modifications, follow the rules mentioned here @.cursor/rules/svelte5-best-practices.mdc
# Windmill Development Guide
## Overview
Windmill is an open-source developer platform for building internal tools, workflows, API integrations, background jobs, workflows, and user interfaces. See @windmill-overview.mdc for full platform details.
## Language-Specific Guides
- Backend (Rust): @backend/rust-best-practices.mdc + @backend/summarized_schema.txt
- Frontend (Svelte 5): @frontend/svelte5-best-practices.mdc
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path\n FROM\n flow\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0de53d764bbcf44d76cd8e47f7b2cd49e4632f03f16ee6f34d9a2c0842890f05"
}
@@ -1,158 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "route_path_key",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "workspaced_route",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 8,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "authentication_method: _",
"type_info": {
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
}
},
{
"ordinal": 14,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 16,
"name": "authentication_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"name": "raw_string",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false
]
},
"hash": "144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db"
}
@@ -1,58 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n edited_at = now(), \n is_static_website = $16\n WHERE \n workspace_id = $17 AND \n path = $18\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Bool",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
},
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n flow\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2\n ",
"query": "SELECT \n path,\n summary,\n description\n FROM\n flow\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
@@ -31,5 +31,5 @@
false
]
},
"hash": "4dc21dda58758a84bbc1b7f9328efb9806223d1684c53907ff825bc0228ec18d"
"hash": "33367c42e87e78ae987c0966dc4d445c5eff75b2e2843ffd7a46b03cbaea9ae8"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path\n FROM\n script\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "3aaa6b6e362b10f2f3546b8595c60ab725295e0f20cb5f85d5fddc14d503a119"
}
@@ -1,157 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "workspaced_route",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "route_path_key",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "authentication_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "edited_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 12,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "authentication_method: _",
"type_info": {
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
}
},
{
"ordinal": 14,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 15,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 17,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 18,
"name": "raw_string",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
false,
false
]
},
"hash": "56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364"
}
@@ -1,55 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n route_path,\n http_method AS \"http_method: _\",\n is_async,\n workspaced_route\n FROM\n http_trigger\n WHERE\n path ~ ANY($1) AND\n route_path ~ ANY($2) AND\n workspace_id = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "http_method: _",
"type_info": {
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
},
{
"ordinal": 2,
"name": "is_async",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspaced_route",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "6b6aca712df657f81e74a438f6753bc72e787a3f768040c8c73ea86313badb71"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
null
]
},
"hash": "7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf"
}
@@ -1,57 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Bool",
"Bool",
"Varchar",
"Bool",
"Bool",
{
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
},
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n path,\n summary,\n description\n FROM\n script\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2\n ",
"query": "SELECT \n path,\n summary,\n description\n FROM\n script\n WHERE\n path ~ ANY($1) AND\n workspace_id = $2 AND\n archived is FALSE\n ",
"describe": {
"columns": [
{
@@ -31,5 +31,5 @@
false
]
},
"hash": "d41df114e78873111e409e1182bccc0024acf17ce740f70d2243e5e2a2b25163"
"hash": "dc36b46b9eb80cb7c92fa72519d117eda99a6f482a073ccd36a6431ef689a3fd"
}
+12
View File
@@ -0,0 +1,12 @@
# Backend Development (Rust)
## Core Principles
- Follow @rust-best-practices.mdc for detailed guidelines
- Database schema reference: @summarized_schema.txt
- The API routes prefixes are all listed in windmill-api/src/lib.rs
## Adding New Features
1. Update database schema with migration if necessary
2. Update backend/windmill-api/openapi.yaml after modifying API endpoints
+43 -43
View File
@@ -741,9 +741,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-config"
@@ -902,9 +902,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.74.0"
version = "1.75.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d440e1d368759bd10df0dbdddbfff6473d7cd73e9d9ef2363dc9995ac2d711"
checksum = "e3258fa707f2f585ee3049d9550954b959002abd59176975150a01d5cf38ae3f"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -2303,9 +2303,9 @@ dependencies = [
[[package]]
name = "croner"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee"
checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843"
dependencies = [
"chrono",
]
@@ -7188,9 +7188,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.173"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libduckdb-sys"
@@ -10410,7 +10410,7 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.1.5"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#d1884fe598cd4c524ff6e0101859a60d4b4c8b8f"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#8a577659f01917289064c0a3c753392b44305ef4"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -10439,7 +10439,7 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "0.1.5"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#d1884fe598cd4c524ff6e0101859a60d4b4c8b8f"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#8a577659f01917289064c0a3c753392b44305ef4"
dependencies = [
"proc-macro2",
"quote",
@@ -11922,9 +11922,9 @@ dependencies = [
[[package]]
name = "sse-stream"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f649a9f9e91db2ed32f3724516eac2bc09fab77fc33be8f670f5619b9dc6c3f"
checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a"
dependencies = [
"bytes",
"futures-util",
@@ -14564,7 +14564,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"axum",
@@ -14616,7 +14616,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"argon2",
@@ -14727,7 +14727,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -14742,7 +14742,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"chrono",
"serde",
@@ -14755,7 +14755,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"serde",
@@ -14769,7 +14769,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14846,7 +14846,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"regex",
"serde",
@@ -14860,7 +14860,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"bytes",
@@ -14883,7 +14883,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14895,7 +14895,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14904,7 +14904,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14916,7 +14916,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14940,7 +14940,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14952,7 +14952,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14964,7 +14964,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14975,7 +14975,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14986,7 +14986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14997,7 +14997,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15020,7 +15020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15037,7 +15037,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15049,7 +15049,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15067,7 +15067,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"getrandom 0.2.16",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15101,7 +15101,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.498.0"
version = "1.499.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15865,18 +15865,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.25"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.25"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.498.0"
version = "1.499.0"
authors.workspace = true
edition.workspace = true
@@ -32,7 +32,7 @@ members = [
]
[workspace.package]
version = "1.498.0"
version = "1.499.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+16 -2
View File
@@ -1,4 +1,18 @@
./substitute_ee_code.sh --dir ../windmill-ee-private
#!/bin/bash
# Default directory
EE_DIR="../windmill-ee-private"
# Parse arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--dir) EE_DIR="$2"; shift ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
shift
done
./substitute_ee_code.sh --dir "$EE_DIR"
# Check if running on macOS
if [[ "$(uname)" == "Darwin" ]]; then
@@ -18,4 +32,4 @@ if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' 's/^#samael = { version="0.0.14", features = \["xmlsec"\] }/samael = { version="0.0.14", features = ["xmlsec"] }/' Cargo.toml
# Comment out the git-based samael dependency
sed -i '' 's/^\(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/# \1/' Cargo.toml
fi
fi
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.498.0
version: 1.499.0
title: Windmill API
contact:
+5 -5
View File
@@ -21,7 +21,7 @@ use windmill_common::{
DB,
};
use crate::{db::ApiAuthed, utils::require_super_admin};
use crate::{db::ApiAuthed, utils::{require_devops_role}};
pub fn global_service() -> Router {
Router::new()
@@ -103,7 +103,7 @@ async fn get_config(
Path(name): Path<String>,
Extension(db): Extension<DB>,
) -> error::JsonResult<Option<serde_json::Value>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let config = sqlx::query_as!(Config, "SELECT * FROM config WHERE name = $1", name)
.fetch_optional(&db)
@@ -119,7 +119,7 @@ async fn update_config(
authed: ApiAuthed,
Json(config): Json<serde_json::Value>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
#[cfg(not(feature = "enterprise"))]
if name.starts_with("worker__") {
@@ -157,7 +157,7 @@ async fn delete_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let mut tx = db.begin().await?;
@@ -232,7 +232,7 @@ async fn list_configs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<Config>> {
require_super_admin(&db, &authed.email).await?;
require_devops_role(&db, &authed.email).await?;
let configs = sqlx::query_as!(Config, "SELECT name, config FROM config")
.fetch_all(&db)
.await?;
+25 -27
View File
@@ -9,7 +9,6 @@ use axum::{
};
use http::{header, HeaderValue, Method, StatusCode};
use indexmap::IndexMap;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::{to_value, Map, Value};
use sqlx::PgConnection;
@@ -24,9 +23,12 @@ use windmill_common::{
use crate::db::ApiAuthed;
#[cfg(feature = "http_trigger")]
use crate::{
http_trigger_args::HttpMethod, http_trigger_auth::ApiKeyAuthentication,
http_triggers::AuthenticationMethod, resources::try_get_resource_from_db_as,
use {
crate::{
http_trigger_args::HttpMethod, http_trigger_auth::ApiKeyAuthentication,
http_triggers::AuthenticationMethod, resources::try_get_resource_from_db_as,
},
itertools::Itertools,
};
lazy_static::lazy_static! {
@@ -311,9 +313,15 @@ fn generate_paths(
}
};
let mut duplicate_webhooks = HashSet::new();
let mut webhooks = HashSet::new();
for path in paths {
if let Kind::Webhook(WebhookConfig { runnable_kind }) = &path.kind {
if !webhooks.insert((path.route_path.clone(), runnable_kind.to_owned())) {
continue;
}
}
let (route_paths, parameters) =
from_route_path_to_openapi_path(&path.route_path, &path.kind)?;
@@ -340,9 +348,6 @@ fn generate_paths(
let (methods, is_webhook) = match &path.kind {
Kind::Webhook(_) => {
if !duplicate_webhooks.insert(route_path.clone()) {
return Err(anyhow!("Found duplicate webhook: {}", path.route_path).into());
}
is_async = route_path.starts_with("/run/");
let methods = if is_async {
vec![Method::POST]
@@ -354,7 +359,12 @@ fn generate_paths(
}
Kind::HttpRoute(HttpRouteConfig { method }) => {
if path_object.get(&method.to_string()).is_some() {
return Err(anyhow!("Found duplicate route: {}", path.route_path).into());
return Err(anyhow!(
"Found duplicate {} method, for route at path: {}",
method,
path.route_path
)
.into());
}
is_async = path.is_async.unwrap_or(true);
(vec![method.to_owned()], false)
@@ -809,14 +819,6 @@ async fn webhook_to_future_paths(
summary: Option<String>,
}
impl PartialEq for MinifiedWebhook {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
impl Eq for MinifiedWebhook {}
let webhook_scripts = sqlx::query_as!(
MinifiedWebhook,
r#"SELECT
@@ -827,16 +829,14 @@ async fn webhook_to_future_paths(
script
WHERE
path ~ ANY($1) AND
workspace_id = $2
workspace_id = $2 AND
archived is FALSE
"#,
&script_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?
.into_iter()
.unique()
.collect_vec();
.await?;
let webhook_flows = sqlx::query_as!(
MinifiedWebhook,
@@ -848,16 +848,14 @@ async fn webhook_to_future_paths(
flow
WHERE
path ~ ANY($1) AND
workspace_id = $2
workspace_id = $2 AND
archived is FALSE
"#,
&flow_webhook_filter,
&w_id
)
.fetch_all(&mut *pg_pool)
.await?
.into_iter()
.unique()
.collect_vec();
.await?;
openapi_future_paths.reserve_exact(webhook_scripts.len() + webhook_flows.len());
+47
View File
@@ -450,6 +450,7 @@ struct EditVariable {
value: Option<String>,
is_secret: Option<bool>,
description: Option<String>,
account: Option<i32>,
}
#[derive(Deserialize)]
@@ -506,6 +507,10 @@ async fn update_variable(
sqlb.set_str("description", &desc);
}
if let Some(account_id) = ns.account {
sqlb.set_str("account", account_id);
}
if let Some(nbool) = ns.is_secret {
let old_secret = sqlx::query_scalar!(
"SELECT is_secret from variable WHERE path = $1 AND workspace_id = $2",
@@ -523,6 +528,21 @@ async fn update_variable(
sqlb.set_str("is_secret", nbool);
}
sqlb.returning("path");
// Get old account_id if we're updating the account field
let old_account_id = if ns.account.is_some() {
sqlx::query_scalar!(
"SELECT account FROM variable WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
} else {
None
};
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
if let Some(npath) = ns.path {
@@ -575,6 +595,33 @@ async fn update_variable(
None,
)
.await?;
// Clean up old account if it's no longer referenced and different from new account
if let Some(old_acc_id) = old_account_id {
if ns.account.is_some() && ns.account != Some(old_acc_id) {
// Check if old account is still referenced by other variables or resources
let account_still_used = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)",
old_acc_id,
&w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(true);
if !account_still_used {
// Delete the orphaned account
sqlx::query!(
"DELETE FROM account WHERE id = $1 AND workspace_id = $2",
old_acc_id,
&w_id
)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
handle_deployment_metadata(
+1
View File
@@ -4028,6 +4028,7 @@ pub async fn push<'c, 'd>(
),
};
let final_priority: Option<i16>;
#[cfg(not(feature = "enterprise"))]
{
+60 -4
View File
@@ -62,12 +62,52 @@ lazy_static::lazy_static! {
static ref EPHEMERAL_TOKEN_CMD: Option<String> = var("EPHEMERAL_TOKEN_CMD").ok();
}
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
lazy_static::lazy_static! {
static ref PIPTAR_UPLOAD_CHANNEL: tokio::sync::mpsc::UnboundedSender<PiptarUploadTask> = {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
// Spawn background task to handle uploads sequentially
tokio::spawn(handle_piptar_uploads(rx));
tx
};
}
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
#[derive(Debug)]
struct PiptarUploadTask {
venv_path: String,
cache_dir: String,
}
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
async fn handle_piptar_uploads(mut rx: tokio::sync::mpsc::UnboundedReceiver<PiptarUploadTask>) {
use crate::global_cache::build_tar_and_push;
use windmill_common::s3_helpers::get_object_store;
while let Some(task) = rx.recv().await {
if let Some(os) = get_object_store().await {
match build_tar_and_push(os, task.venv_path.clone(), task.cache_dir, None, false).await {
Ok(()) => {
tracing::info!("Successfully uploaded piptar for {}", task.venv_path);
}
Err(e) => {
tracing::error!("Failed to upload piptar for {}: {}", task.venv_path, e);
}
}
} else {
tracing::warn!("S3 object store not available for piptar upload: {}", task.venv_path);
}
}
}
const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download.py.config.proto");
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
use crate::global_cache::{build_tar_and_push, pull_from_tar};
use crate::global_cache::pull_from_tar;
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
use windmill_common::s3_helpers::OBJECT_STORE_SETTINGS;
@@ -674,7 +714,14 @@ except BaseException as e:
// ^^^^^^ ^
// We also want this be priorotized, that's why we insert it to the beginning
}
paths.iter().join(":")
#[cfg(windows)]
{
paths.iter().join(";")
}
#[cfg(not(windows))]
{
paths.iter().join(":")
}
};
#[cfg(windows)]
@@ -1890,8 +1937,16 @@ pub async fn handle_python_reqs(
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
if s3_push {
if let Some(os) = windmill_common::s3_helpers::get_object_store().await {
tokio::spawn(build_tar_and_push(os, venv_p.clone(), py_version.to_cache_dir_top_level(false), None, false));
// Send to upload channel for sequential processing
let upload_task = PiptarUploadTask {
venv_path: venv_p.clone(),
cache_dir: py_version.to_cache_dir_top_level(false),
};
if let Err(e) = PIPTAR_UPLOAD_CHANNEL.send(upload_task) {
tracing::warn!("Failed to queue piptar upload for {venv_p}: {e}");
} else {
tracing::info!("Queued piptar upload for {venv_p}");
}
}
@@ -2193,3 +2248,4 @@ for line in sys.stdin:
)
.await
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.498.0";
export const VERSION = "v1.499.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -63,7 +63,7 @@ export {
// }
// });
export const VERSION = "1.498.0";
export const VERSION = "1.499.0";
const command = new Command()
.name("wmill")
+77
View File
@@ -0,0 +1,77 @@
# Frontend Development (Svelte 5)
## Core Principles
- Follow @svelte5-best-practices.mdc for detailed guidelines
- Use Runes ($state, $derived, $effect) for reactivity
- Keep components small and focused
- Always use keys in {#each} blocks
## UI Guidelines
- Follow existing design system
- Use consistent spacing and colors
## Backend API
- If you need to call the backend API, you can find the available routes in ../backend/windmill-api/openapi.yaml
- You can also use the associated types and services that are auto generated from the openapi file. They are in src/lib/gen/\*gen.ts files
### OpenAPI Autogeneration
Windmill automatically generates TypeScript types and services from the OpenAPI specification.
#### Service Generation Pattern
The autogeneration follows this pattern:
- **Tag** → **Service Name**: The OpenAPI tag becomes the service name with "Service" suffix
- **operationId** → **Method Name**: The operationId becomes the method name in the service
#### Example
Given this OpenAPI specification:
```yaml
/w/{workspace}/audit/list:
get:
summary: list audit logs (requires admin privilege)
operationId: listAuditLogs
tags:
- audit
parameters:
- $ref: '#/components/parameters/WorkspaceId'
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PerPage'
- $ref: '#/components/parameters/Before'
- $ref: '#/components/parameters/After'
- $ref: '#/components/parameters/Username'
- $ref: '#/components/parameters/Operation'
- name: operations
in: query
description: comma separated list of exact operations to include
schema:
type: string
```
This generates:
- **Service**: `AuditService` (from tag "audit")
- **Method**: `listAuditLogs` (from operationId)
#### Method Arguments
The generated method arguments correspond to the OpenAPI parameters:
```typescript
AuditService.listAuditLogs({
workspace: string, // from WorkspaceId parameter
page?: number, // from Page parameter
perPage?: number, // from PerPage parameter
before?: string, // from Before parameter
after?: string, // from After parameter
username?: string, // from Username parameter
operation?: string, // from Operation parameter
operations?: string // from operations parameter
})
```
+2 -12
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.498.0",
"version": "1.499.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.498.0",
"version": "1.499.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -132,7 +132,6 @@
"svelte-check": "^4.0.0",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
"svelte-multiselect": "^11.0.0-rc.1",
"svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^6.0.0",
"svelte-range-slider-pips": "^2.3.1",
@@ -11862,15 +11861,6 @@
"resolved": "https://registry.npmjs.org/svelte-infinite-loading/-/svelte-infinite-loading-1.4.0.tgz",
"integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg=="
},
"node_modules/svelte-multiselect": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/svelte-multiselect/-/svelte-multiselect-11.1.0.tgz",
"integrity": "sha512-D93t6GlOV//gU7upR59uCd755Hq6OWhqv9ddhOXBDN/mEZj5XOAYHwDjUjePUKlE+bI+jry1BlZef8Mof2Y+YA==",
"dev": true,
"peerDependencies": {
"svelte": "^5.8.0"
}
},
"node_modules/svelte-popperjs": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/svelte-popperjs/-/svelte-popperjs-1.3.2.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.498.0",
"version": "1.499.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -57,7 +57,6 @@
"svelte-check": "^4.0.0",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
"svelte-multiselect": "^11.0.0-rc.1",
"svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^6.0.0",
"svelte-range-slider-pips": "^2.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { EnumType } from '$lib/common'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
interface Props {
disabled: boolean
+14 -34
View File
@@ -11,7 +11,6 @@
} from '$lib/utils'
import { DollarSign, Pipette, Plus, X, Check, Loader2 } from 'lucide-svelte'
import { createEventDispatcher, onMount, tick, untrack } from 'svelte'
import Multiselect from 'svelte-multiselect'
import { fade } from 'svelte/transition'
import { Button, SecondsInput } from './common'
import FieldHeader from './FieldHeader.svelte'
@@ -41,6 +40,8 @@
import type { Script } from '$lib/gen'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { ComponentCustomCSS } from './apps/types'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
interface Props {
label?: string
@@ -631,43 +632,22 @@
<div class="w-full">
{#if Array.isArray(itemsType?.multiselect) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
<MultiSelect
{disabled}
bind:selected={value}
onremove={(e) => {
if (Array.isArray(value)) value = value.filter((v) => v !== e.option)
}}
options={itemsType?.multiselect ?? []}
selectedOptionsDraggable={true}
onopen={() => {
dispatch('focus')
}}
bind:value
items={safeSelectItems(itemsType?.multiselect)}
onOpen={() => dispatch('focus')}
reorderable
/>
</div>
{:else if itemsType?.enum != undefined && Array.isArray(itemsType?.enum) && (Array.isArray(value) || value == undefined)}
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
<MultiSelect
{disabled}
onremove={(e) => {
if (Array.isArray(value)) value = value.filter((v) => v !== e.option)
}}
bind:selected={
() => [...(value ?? [])],
(v) => {
if (!deepEqual(v, value)) {
value = v
}
}
}
options={itemsType?.enum ?? []}
selectedOptionsDraggable={true}
onopen={() => {
dispatch('focus')
}}
bind:value
items={safeSelectItems(itemsType?.enum)}
onOpen={() => dispatch('focus')}
reorderable
/>
</div>
{:else if itemsType?.type == 'object' && itemsType?.resourceType == 's3object'}
@@ -738,8 +718,7 @@
/>
{:else if itemsType?.type == 'resource' && itemsType?.resourceType && resourceTypes?.includes(itemsType.resourceType)}
<ObjectResourceInput
value={v ? `$res:${v}` : undefined}
bind:path={value[i]}
bind:value={value[i]}
format={'resource-' + itemsType?.resourceType}
defaultValue={undefined}
/>
@@ -855,6 +834,7 @@
{:else if inputCat == 'resource-object' && resourceTypes == undefined}
<span class="text-2xs text-tertiary">Loading resource types...</span>
{:else if inputCat == 'resource-object' && (resourceTypes == undefined || (format && format?.split('-').length > 1 && resourceTypes.includes(format?.substring('resource-'.length))))}
<!-- {JSON.stringify(value)} -->
<ObjectResourceInput
{defaultValue}
selectFirst={!noDefaultOnSelectFirst}
@@ -5,7 +5,7 @@
import { ExternalLink, Loader2, X } from 'lucide-svelte'
import { SettingService, WorkerService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { superadmin } from '$lib/stores'
import { superadmin, devopsRole } from '$lib/stores'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { CUSTOM_TAGS_SETTING } from '$lib/consts'
import { base } from '$lib/base'
@@ -80,10 +80,10 @@
loadCustomTags()
sendUserToast('Tag added')
}}
disabled={newTag.trim() == '' || !$superadmin}
disabled={newTag.trim() == '' || !($superadmin || $devopsRole)}
>
Add {#if !$superadmin}
<span class="text-2xs text-tertiary">superadmin only</span>
Add {#if !($superadmin || $devopsRole)}
<span class="text-2xs text-tertiary">superadmin or devops only</span>
{/if}
</Button>
<span class="text-sm text-primary"
@@ -9,7 +9,8 @@
import { ExternalLink } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Label from './Label.svelte'
import MultiSelect from 'svelte-multiselect'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
interface Props {
config: AutoscalingConfig | undefined
@@ -209,32 +210,14 @@
{#if config}
{#if config.custom_tags}
<MultiSelect
outerDivClass="text-secondary !bg-surface-disabled !border-0"
selected={config.custom_tags}
onchange={(e) => {
console.log(e.type, config?.custom_tags)
if (e && config?.custom_tags) {
if (e.type === 'add') {
config.custom_tags = [
...config.custom_tags,
...(e.option ? [e.option.toString()] : [])
]
} else if (e.type === 'remove') {
config.custom_tags = config.custom_tags.filter((t) => t !== e.option)
if (config?.custom_tags && config.custom_tags.length == 0) {
config.custom_tags = undefined
}
} else if (e.type === 'removeAll') {
config.custom_tags = undefined
} else {
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
}
bind:value={
() => config?.custom_tags ?? [],
(v) => {
config && (config.custom_tags = v.length ? v : undefined)
dispatch('dirty')
}
}}
options={worker_tags ?? []}
selectedOptionsDraggable={false}
ulOptionsClass={'!bg-surface-secondary'}
}
items={safeSelectItems(worker_tags)}
placeholder="Tags"
/>
{:else}
@@ -1,5 +1,5 @@
<script lang="ts">
import Select from './Select.svelte'
import Select from './select/Select.svelte'
interface ChannelItem {
channel_id?: string
+16 -19
View File
@@ -2,14 +2,14 @@
import { ScheduleService } from '$lib/gen'
import { emptyString, formatCron, sendUserToast } from '$lib/utils'
import Badge from './Badge.svelte'
// @ts-ignore
import Multiselect from 'svelte-multiselect'
import { Button } from './common'
import timezones from './timezones'
import CronBuilder from './CronBuilder.svelte'
import Label from './Label.svelte'
import CronGen from './copilot/CronGen.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let schedule: string
// export let offset: number = -60 * Math.floor(new Date().getTimezoneOffset() / 60)
@@ -237,7 +237,7 @@
<div class="text-secondary text-sm leading-none">Execute schedule every</div>
<div class="w-full flex gap-4">
<div class="w-full flex flex-col gap-1">
<div class="w-full flex flex-col gap-1 mb-2">
<select
{disabled}
name="execute_every"
@@ -280,26 +280,24 @@
<div class="w-full flex flex-col gap-4">
{#if executeEvery == 'month'}
<div class="w-full flex flex-col">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={monthsOfYear}
options={monthsOfYearOptions}
selectedOptionsDraggable={false}
bind:value={monthsOfYear}
items={safeSelectItems(monthsOfYearOptions)}
placeholder="Every month"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
{/if}
{#if executeEvery == 'day-week'}
<div class="w-full flex flex-col">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={daysOfWeek}
options={daysOfWeekOptions}
selectedOptionsDraggable={false}
bind:value={daysOfWeek}
items={safeSelectItems(daysOfWeekOptions)}
placeholder="Every day"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
{/if}
@@ -311,13 +309,12 @@
{/if}
<div class="w-full flex gap-4">
<div class="w-full flex">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={daysOfMonth}
options={daysOfMonthOptions}
selectedOptionsDraggable={false}
bind:value={daysOfMonth}
items={safeSelectItems(daysOfMonthOptions)}
placeholder="Every day"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
@@ -71,7 +71,8 @@
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import DarkModeObserver from './DarkModeObserver.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
const { onConfirm, resourceType, previewSql, dbSchema, currentSchema }: DBTableEditorProps =
$props()
@@ -165,7 +166,7 @@
}
}
}
items={columnTypes.map((type) => ({ value: type, label: type }))}
items={safeSelectItems(columnTypes)}
class="w-48"
/>
</Cell>
@@ -7,7 +7,7 @@
import DefaultTagsInner from './DefaultTagsInner.svelte'
export let defaultTagPerWorkspace: boolean | undefined = undefined
export let defaultTagWorkspaces: string[] | undefined = undefined
export let defaultTagWorkspaces: string[] = []
let placement: 'bottom-end' | 'top-end' = 'bottom-end'
</script>
@@ -7,11 +7,12 @@
import { enterpriseLicense, superadmin } from '$lib/stores'
import { DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING } from '$lib/consts'
import Toggle from './Toggle.svelte'
import MultiSelectWrapper from './multiselect/MultiSelectWrapper.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
let defaultTags: string[] | undefined = undefined
export let defaultTagPerWorkspace: boolean | undefined = undefined
export let defaultTagWorkspaces: string[] | undefined = undefined
export let defaultTagWorkspaces: string[] = []
let limitToWorkspaces = false
let workspaces: string[] = []
@@ -62,7 +63,7 @@
</div>
{/each}
</div>
<div id="default-tags-settings" class="py-4 flex flex-col gap-2">
<div class="py-4 flex flex-col gap-2">
<Toggle
bind:checked={defaultTagPerWorkspace}
options={{ right: 'workspace specific default tags' }}
@@ -70,9 +71,9 @@
{#if defaultTagPerWorkspace}
<Toggle bind:checked={limitToWorkspaces} options={{ right: 'only for some workspaces' }} />
{#if limitToWorkspaces}
<MultiSelectWrapper
target="#default-tags-settings"
items={workspaces}
<MultiSelect
disablePortal
items={safeSelectItems(workspaces)}
bind:value={defaultTagWorkspaces}
/>
{/if}
@@ -8,7 +8,7 @@
loadingSave = false,
newFlow = false,
dropdownItems = []
} = $props<{
}: {
loading?: boolean
loadingSave?: boolean
newFlow?: boolean
@@ -16,7 +16,7 @@
label: string
onClick: () => void
}>
}>()
} = $props()
const dispatch = createEventDispatcher()
+4 -2
View File
@@ -76,6 +76,7 @@
diffMode?: boolean
showHistoryDrawer?: boolean
right?: import('svelte').Snippet
openAiChat?: boolean
}
let {
@@ -98,7 +99,8 @@
lastDeployedCode = undefined,
diffMode = false,
showHistoryDrawer = $bindable(false),
right
right,
openAiChat = false
}: Props = $props()
let contextualVariablePicker: ItemPicker | undefined = $state()
@@ -810,7 +812,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
{/if}
{#if customUi?.aiGen != false}
<ScriptGen {editor} {diffEditor} {lang} {iconOnly} {args} />
<ScriptGen {editor} {diffEditor} {lang} {iconOnly} {args} {openAiChat} />
{/if}
<EditorSettings {customUi} />
@@ -18,7 +18,8 @@
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let name: string
let can_write = false
@@ -197,7 +198,7 @@
ownerKind === 'user'
? usernames.filter((x) => !perms?.map((y) => y.owner_name).includes('u/' + x))
: groups.filter((x) => !perms?.map((y) => y.owner_name).includes('g/' + x))}
<Select items={items.map((x) => ({ label: x, value: x }))} bind:value={ownerItem} />
<Select items={safeSelectItems(items)} bind:value={ownerItem} />
{#if ownerKind == 'group'}
<Button
title="View Group"
@@ -17,7 +17,8 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let name: string
let can_write = false
@@ -129,7 +130,7 @@
<Section label={`Members (${members?.length ?? 0})`}>
{#if can_write}
<div class="flex items-start">
<Select items={usernames?.map((u) => ({ value: u, label: u }))} bind:value={username} />
<Select items={safeSelectItems(usernames)} bind:value={username} />
<Button variant="contained" color="blue" size="sm" btnClasses="!ml-4" on:click={addToGroup}>
Add member
</Button>
@@ -177,6 +177,9 @@
if (arg.type) {
propertyType = arg.type
}
if (arg.expr != undefined) {
arg.expr = undefined
}
}
}
@@ -332,6 +335,8 @@
untrack(() => updatePropertyType())
})
$effect(() => {
arg.value
arg.expr
inputCat && propertyType && arg && untrack(() => onArgChange())
})
$effect(() => {
@@ -8,7 +8,7 @@
import type { AppViewerContext } from './apps/types'
import { sendUserToast } from '$lib/toast'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
@@ -4,40 +4,43 @@
import S3ObjectPicker from './S3ObjectPicker.svelte'
import type SimpleEditor from './SimpleEditor.svelte'
export let format: string
export let value: any
export let disablePortal = false
export let showSchemaExplorer = false
export let selectFirst = false
export let defaultValue: any
export let editor: SimpleEditor | undefined = undefined
function isString(value: any) {
return typeof value === 'string' || value instanceof String
}
export let path: string = ''
function resourceToValue() {
if (path && path != '') {
value = `$res:${path}`
} else {
value = undefined
}
interface Props {
format: string
value: any
disablePortal?: boolean
showSchemaExplorer?: boolean
selectFirst?: boolean
defaultValue: any
editor?: SimpleEditor | undefined
path?: string
}
let {
format,
value = $bindable(),
disablePortal = false,
showSchemaExplorer = false,
selectFirst = false,
defaultValue,
editor = $bindable(undefined)
}: Props = $props()
function isResource() {
return isString(value) && value.length >= '$res:'.length
}
function valueToPath() {
if (isResource()) {
path = value.substr('$res:'.length)
return value.substr('$res:'.length)
}
}
$: value && valueToPath()
</script>
<!-- {JSON.stringify({ value })} -->
<div class="flex flex-row w-full flex-wrap gap-x-2 gap-y-0.5">
{#if format === 'resource-s3_object'}
<S3ObjectPicker bind:value />
@@ -45,12 +48,17 @@
<ResourcePicker
{selectFirst}
{disablePortal}
on:change={(e) => {
path = e.detail
resourceToValue()
}}
on:clear
bind:value={path}
bind:value={
() => valueToPath(),
(v) => {
if (v == undefined) {
value = undefined
} else {
value = `$res:${v}`
}
}
}
initialValue={typeof defaultValue == 'string' && defaultValue.startsWith('$res:')
? defaultValue.substr('$res:'.length)
: defaultValue}
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
@@ -34,7 +32,7 @@
}
}
let enabled = $derived(value != undefined)
run(() => {
$effect(() => {
changeDomain(value?.['domain'], value?.['custom'])
})
</script>
@@ -10,23 +10,44 @@
import { Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { isDbType } from './apps/components/display/dbtable/utils'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
export let initialValue: string | undefined = undefined
export let value: string | undefined = initialValue
export let valueType: string | undefined = undefined
export let resourceType: string | undefined = undefined
export let disabled = false
export let disablePortal = false
export let showSchemaExplorer = false
export let selectFirst = false
export let expressOAuthSetup = false
export let defaultValues: Record<string, any> | undefined = undefined
export let placeholder: string | undefined = undefined
interface Props {
initialValue?: string | undefined
value?: string | undefined
valueType?: string | undefined
resourceType?: string | undefined
disabled?: boolean
disablePortal?: boolean
showSchemaExplorer?: boolean
selectFirst?: boolean
expressOAuthSetup?: boolean
defaultValues?: Record<string, any> | undefined
placeholder?: string | undefined
}
let {
initialValue = $bindable(undefined),
value = $bindable(undefined),
valueType = $bindable(undefined),
resourceType = undefined,
disabled = false,
disablePortal = false,
showSchemaExplorer = false,
selectFirst = false,
expressOAuthSetup = false,
defaultValues = undefined,
placeholder = undefined
}: Props = $props()
if (initialValue && value == undefined) {
console.log('initialValue', initialValue)
value = initialValue
}
onMount(() => {
setTimeout(() => {
@@ -36,26 +57,36 @@
}, 500)
})
let valueSelect =
initialValue || value
? {
value: value ?? initialValue,
label: value ?? initialValue,
type: valueType
}
: undefined
// let valueSelect = $state(
// initialValue || value
// ? {
// value: value ?? initialValue,
// label: value ?? initialValue,
// type: valueType
// }
// : undefined
// )
$: if (value === undefined && initialValue) {
value = initialValue
}
$effect(() => {
if (value === undefined) {
if (initialValue) {
console.log('initialValue', initialValue)
value = initialValue
} else {
console.log('no value')
}
} else {
console.log('value', value)
}
})
let collection = valueSelect ? [valueSelect] : []
let collection = $state(value ? [{ value, label: value, type: valueType }] : [])
export async function askNewResource() {
appConnect?.open?.(resourceType)
}
let loading = true
let loading = $state(true)
async function loadResources(resourceType: string | undefined) {
loading = true
try {
@@ -84,10 +115,10 @@
nc.push({ value: value ?? initialValue!, label: value ?? initialValue!, type: '' })
}
collection = nc
if (collection.length == 1 && selectFirst && valueSelect == undefined) {
if (collection.length == 1 && selectFirst && value == undefined) {
console.log('selectFirst', collection[0].value)
value = collection[0].value
valueType = collection[0].type
valueSelect = collection[0]
}
} catch (e) {
sendUserToast('Failed to load resource types', true)
@@ -96,12 +127,16 @@
loading = false
}
$: $workspaceStore && loadResources(resourceType)
$effect(() => {
$workspaceStore && loadResources(resourceType)
})
$: dispatchIfMounted('change', value)
$effect(() => {
dispatchIfMounted('change', value)
})
let appConnect: AppConnect
let resourceEditor: ResourceEditorDrawer
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
</script>
<AppConnect
@@ -109,11 +144,6 @@
await loadResources(resourceType)
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
valueSelect = {
value: e.detail,
label: e.detail,
type: valueType ?? ''
}
}}
bind:this={appConnect}
{expressOAuthSetup}
@@ -125,11 +155,11 @@
if (e.detail) {
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
// valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
}
}}
/>
<!-- {JSON.stringify({ value, collection })} -->
<div class="flex flex-col w-full items-start min-h-9">
<div class="flex flex-row w-full items-center">
{#if collection?.length > 0}
@@ -137,18 +167,16 @@
{disabled}
{disablePortal}
bind:value={
() => valueSelect?.value,
() => value,
(v) => {
valueSelect = collection.find((x) => x.value === v)
value = v
valueType = valueSelect?.type
valueType = collection.find((x) => x?.value == v)?.type
}
}
onClear={() => {
initialValue = undefined
value = undefined
valueType = undefined
valueSelect = undefined
dispatch('clear')
}}
items={collection}
@@ -10,7 +10,8 @@
import { ExternalLink, Search } from 'lucide-svelte'
import { Popover } from './meltComponents'
import SchemaForm from './SchemaForm.svelte'
import MultiSelect from './multiselect/MultiSelectWrapper.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
const dispatch = createEventDispatcher()
interface Props {
@@ -142,7 +143,6 @@
{/snippet}
{#snippet content()}
<div id="multi-select-search"></div>
<div class="p-2 overflow-auto max-h-[400px] min-h-[300px] w-[400px]">
<div class="flex items-center flex-wrap gap-x-2 justify-between">
<div class="text-sm text-secondary">Search by args</div>
@@ -175,11 +175,10 @@
</div>
<div class="my-2">
<MultiSelect
topPlacement
target="#multi-select-search"
placeholder="arg fields to filter on"
items={Object.keys(schema?.properties ?? {})}
items={safeSelectItems(Object.keys(schema?.properties ?? {}))}
bind:value={searchArgsFields}
disablePortal
/>
</div>
{#key filteredSchema}
@@ -5,7 +5,7 @@
import { base } from '$lib/base'
import { createEventDispatcher, untrack } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { getScriptByPath } from '$lib/scripts'
import { Button, Drawer, DrawerContent } from './common'
-271
View File
@@ -1,271 +0,0 @@
<script lang="ts" generics="Item extends { label?: string; value: any; }">
import { clickOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import CloseButton from './common/CloseButton.svelte'
import ConditionalPortal from './common/drawer/ConditionalPortal.svelte'
import { deepEqual } from 'fast-equals'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
type Value = Item['value']
let {
items,
placeholder = 'Please select',
value = $bindable(),
filterText: _filterTextBind = $bindable(undefined),
class: className = '',
clearable = false,
listAutoWidth = true,
disabled: _disabled = false,
containerStyle = '',
inputClass = '',
disablePortal = false,
loading = false,
autofocus,
RightIcon,
createText,
groupBy,
sortBy,
onFocus,
onBlur,
onClear,
onCreateItem
}: {
items?: Item[]
value: Value | undefined
placeholder?: string
class?: string
clearable?: boolean
filterText?: string
disabled?: boolean
listAutoWidth?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
loading?: boolean
autofocus?: boolean
RightIcon?: any
createText?: string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
onBlur?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
} = $props()
let disabled = $derived(_disabled || loading)
let filterText = $state<string>('')
let open = $state<boolean>(false)
let keyArrowPos = $state<number | undefined>()
let inputEl: HTMLInputElement | undefined = $state()
let listEl: HTMLDivElement | undefined = $state()
$effect(() => {
if (_filterTextBind !== undefined) filterText = _filterTextBind
})
$effect(() => {
if (_filterTextBind !== undefined) _filterTextBind = filterText
})
$effect(() => {
;[filterText, open, processedItems]
keyArrowPos = open && filterText ? 0 : undefined
})
$effect(() => {
if (filterText) open = true
})
$effect(() => {
if (!open) filterText = ''
})
type ProcessedItem = Item & { __select_group?: string; __is_create?: true; label: string }
let processedItems: ProcessedItem[] = $derived.by(() => {
let items2 =
items?.map((item) => ({
...item,
label: getLabel(item)
})) ?? []
if (filterText) {
items2 = items2.filter((item) =>
item?.label?.toLowerCase().includes(filterText?.toLowerCase())
)
}
if (groupBy) {
items2 =
items2?.map((item) => ({
...item,
__select_group: groupBy(item)
})) ?? []
}
if (sortBy) {
items2 = items2?.sort(sortBy)
}
if (onCreateItem && filterText && !items2.some((item) => item.label === filterText)) {
items2.push({
label: createText ?? `Add new: "${filterText}"`,
value: filterText,
__is_create: true
} as any)
}
return items2
})
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
function setValue(item: ProcessedItem) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
} else {
value = item.value
}
filterText = ''
open = false
}
function clearValue() {
filterText = ''
if (onClear) onClear()
else value = undefined
}
function getLabel(item: Item | undefined): string {
if (!item) return ''
if (item.label) return item.label
if (typeof item.value === 'string') return item.value
if (typeof item.value == 'number' || typeof item.value == 'boolean')
return item.value.toString()
return JSON.stringify(item.value)
}
function computeDropdownPos(): { width: number; x: number; y: number } {
if (!inputEl || !listEl) return { width: 0, x: 0, y: 0 }
const r = inputEl.getBoundingClientRect()
const listR = listEl.getBoundingClientRect()
const openBelow = r.y + r.height + listR.height <= window.innerHeight
let [x, y] = disablePortal ? [0, 0] : [r.x, r.y]
if (openBelow) return { width: r.width, x: x, y: y + r.height }
else {
return { width: r.width, x: x, y: y - listR.height }
}
}
let dropdownPos = $state(computeDropdownPos())
$effect(() => {
function updateDropdownPos() {
let nPos = computeDropdownPos()
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
if (open) requestAnimationFrame(updateDropdownPos)
}
if (open) untrack(() => updateDropdownPos())
})
</script>
<svelte:window
on:keydown={(e) => {
if (!open || !processedItems?.length) return
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
} else if (e.key === 'ArrowDown') {
if (keyArrowPos === undefined) {
keyArrowPos = 0
} else {
keyArrowPos = Math.min(processedItems.length - 1, keyArrowPos + 1)
}
} else if (e.key === 'Enter' && keyArrowPos !== undefined && processedItems?.[keyArrowPos]) {
setValue(processedItems[keyArrowPos])
} else {
keyArrowPos = undefined
return
}
e.preventDefault()
}}
/>
<div
class={`relative ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
onblur={() => onBlur?.()}
>
{#if loading}
<div class="absolute z-10 right-2 h-full flex items-center">
<Loader2 size={18} class="animate-spin" />
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton noBg small on:close={clearValue} />
</div>
{:else if RightIcon}
<div class="absolute z-10 right-2 h-full flex items-center">
<RightIcon size={18} class="text-tertiary/35" />
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
bind:value={() => filterText, (v) => (filterText = v)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? placeholder)}
style={containerStyle}
class={twMerge(
'!bg-surface text-ellipsis',
open ? '' : 'cursor-pointer',
valueEntry && !loading ? '!placeholder-primary' : '',
(clearable || RightIcon) && !disabled && value ? '!pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
onpointerdown={() => (open = true)}
bind:this={inputEl}
/>
<ConditionalPortal condition={!disablePortal}>
{#if open && !disabled}
<div
class={twMerge(
disablePortal ? 'absolute' : 'fixed',
'flex flex-col z-[5001] max-h-64 overflow-y-auto bg-surface-secondary text-tertiary text-sm select-none border rounded-lg'
)}
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
? `min-width: ${dropdownPos.width}px;`
: ''}"
bind:this={listEl}
>
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary">No items</div>
{/if}
{#each processedItems ?? [] as item, itemIndex}
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
<div
class={twMerge(
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b',
itemIndex === 0 ? 'mt-3' : 'mt-6'
)}
>
{item.__select_group}
</div>
{/if}
<button
class={twMerge(
'py-2 px-4 w-full font-normal text-left text-primary',
itemIndex === keyArrowPos ? 'bg-surface-hover' : '',
item.value === value ? 'bg-surface-selected' : 'hover:bg-surface-hover'
)}
onclick={(e) => {
e.stopImmediatePropagation()
setValue(item)
}}
>
{item.label}
</button>
{/each}
</div>
{/if}
</ConditionalPortal>
</div>
@@ -11,14 +11,13 @@
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, truncateRev } from '$lib/utils'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
import { Button, Drawer, DrawerContent } from './common'
import ClipboardCopy from 'lucide-svelte/icons/clipboard-copy'
import AnsiUp from 'ansi_up'
import { scroll_into_view_if_needed_polyfill } from './multiselect/utils'
import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
@@ -13,7 +13,8 @@
import { isOwner } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
const dispatch = createEventDispatcher()
@@ -138,9 +139,9 @@
</div>
{#key ownerKind}
<Select
items={(ownerKind === 'user' ? usernames : groups)
.map((x) => x.toString())
.map((x) => ({ value: x, label: x }))}
items={safeSelectItems(
(ownerKind === 'user' ? usernames : groups).map((x) => x.toString())
)}
bind:value={owner}
/>
{/key}
@@ -115,7 +115,7 @@
tailwindClasses = [],
class: className = '',
loadAsync = false
} = $props<{
}: {
lang: string
code?: string
hash?: string
@@ -137,7 +137,7 @@
tailwindClasses?: string[]
class?: string
loadAsync?: boolean
}>()
} = $props()
const dispatch = createEventDispatcher()
@@ -343,7 +343,7 @@
// })
// }
try {
model = meditor.createModel(code, lang, mUri.parse(uri))
model = meditor.createModel(code ?? '', lang, mUri.parse(uri))
} catch (err) {
console.log('model already existed', err)
const nmodel = meditor.getModel(mUri.parse(uri))
@@ -361,7 +361,7 @@
}
try {
editor = meditor.create(divEl as HTMLDivElement, {
...editorConfig(code, lang, automaticLayout, fixedOverflowWidgets),
...editorConfig(code ?? '', lang, automaticLayout, fixedOverflowWidgets),
model,
lineDecorationsWidth: 6,
lineNumbersMinChars: 2,
@@ -546,7 +546,7 @@
})
}
let previousExtraLib = undefined
let previousExtraLib: string | undefined = undefined
function loadExtraLib() {
if (lang == 'javascript') {
const stdLib = { content: libStdContent, filePath: 'es6.d.ts' }
@@ -609,11 +609,11 @@
}
}
updatePlaceholderVisibility(code)
updatePlaceholderVisibility(code ?? '')
</script>
<EditorTheme />
{#if editor && suggestion && code.length === 0}
{#if editor && suggestion && code?.length === 0}
<div
class="absolute top-[0.05rem] left-[2.05rem] z-10 text-sm text-[#0007] italic font-mono dark:text-[#ffffff56] text-ellipsis overflow-hidden whitespace-nowrap"
style={`max-width: calc(${width}px - 2.05rem)`}
@@ -1,10 +1,9 @@
<script lang="ts">
import { X, Plus, Trash } from 'lucide-svelte'
import { Button } from './common'
import { superadmin } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { defaultTags, nativeTags } from './worker_group'
import Select from './Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { superadmin } from '$lib/stores'
const dispatch = createEventDispatcher()
type Props = {
@@ -15,83 +14,21 @@
let {
worker_tags = $bindable([]),
customTags = $bindable([]),
disabled = $bindable(false)
disabled: _disabled = $bindable(false)
}: Props = $props()
let newTag = $state('')
let createdTags: string[] = $state([])
let disabled = $derived(_disabled || !$superadmin)
</script>
<div class="flex gap-2 gap-y-2 flex-wrap pb-3">
{#if worker_tags?.length === 0}
<div class="text-xs text-secondary">No tags selected</div>
{/if}
{#each worker_tags as tag}
<div
class="flex items-center gap-1 px-2 py-1 rounded-full border border-primary text-2xs text-primary bg-surface-primary"
>
<span>{tag}</span>
{#if $superadmin && !disabled}
<Button
class="p-1 rounded-full hover:bg-surface-hover transition"
aria-label="Remove tag"
on:click={() => {
worker_tags = worker_tags?.filter((t) => t !== tag) ?? []
dispatch('dirty')
dispatch('deletePriorityTag', tag)
}}
>
<X size={12} />
</Button>
{/if}
</div>
{/each}
</div>
{#if $superadmin}
<div class="max-w-md space-y-2">
<Select
items={[...(customTags ?? []), ...createdTags, ...defaultTags, ...nativeTags]
.filter((x) => !worker_tags?.includes(x))
.map((x) => ({ value: x, label: x }))}
{disabled}
bind:value={newTag}
onFocus={() => dispatch('focus')}
onCreateItem={(c) => (createdTags.push(c), (createdTags = [...createdTags]), (newTag = c))}
createText="Press Enter to use this tag"
/>
<div class="flex gap-2">
<Button
variant="contained"
color="light"
size="xs"
startIcon={{ icon: Plus }}
disabled={disabled || newTag == '' || worker_tags?.includes(newTag)}
on:click={() => {
worker_tags = [...(worker_tags ?? []), newTag.replaceAll(' ', '_')]
newTag = ''
dispatch('dirty')
}}
>
Add tag
</Button>
<Button
variant="contained"
color="red"
size="xs"
startIcon={{ icon: Trash }}
disabled={disabled || worker_tags.length === 0}
on:click={() => {
worker_tags = worker_tags.filter((tag) => {
dispatch('deletePriorityTag', tag)
return false
})
dispatch('dirty')
}}
>
Remove all selected tags
</Button>
</div>
</div>
{/if}
<MultiSelect
items={safeSelectItems([...(customTags ?? []), ...worker_tags, ...defaultTags, ...nativeTags])}
bind:value={
() => worker_tags,
(w) => ((worker_tags = w.map((s) => s.replaceAll(' ', '_'))), dispatch('dirty'))
}
{disabled}
class={disabled ? 'border-0' : ''}
allowClear={!disabled}
onCreateItem={(c) => worker_tags.push(c)}
createText="Press Enter to use this tag"
/>
@@ -2,7 +2,7 @@
import { workspaceStore } from '$lib/stores'
import { RefreshCcw } from 'lucide-svelte'
import { WorkspaceService } from '$lib/gen'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { onMount } from 'svelte'
interface TeamItem {
@@ -7,13 +7,13 @@
onTrigger,
children,
showAnimation = true
} = $props<{
}: {
id: string | undefined
description: string | undefined
onTrigger?: (value?: string) => void // Function to call when the trigger is activated, if not provided, the component is discoverable for information purposes only
children?: () => any
showAnimation?: boolean
}>()
} = $props()
let isAnimating = $state(false)
+30 -49
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { Copy, Plus, RefreshCcwIcon, Settings, Trash, X } from 'lucide-svelte'
import { Alert, Badge, Button, Drawer } from './common'
import Multiselect from 'svelte-multiselect'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { ConfigService, WorkspaceService, type WorkerPing, type Workspace } from '$lib/gen'
@@ -9,7 +8,7 @@
import { createEventDispatcher } from 'svelte'
import { sendUserToast } from '$lib/toast'
import { emptyString, pluralize } from '$lib/utils'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { enterpriseLicense, superadmin, devopsRole } from '$lib/stores'
import Tooltip from './Tooltip.svelte'
import Editor from './Editor.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -20,7 +19,9 @@
import { defaultTags, nativeTags, type AutoscalingConfig } from './worker_group'
import AutoscalingConfigEditor from './AutoscalingConfigEditor.svelte'
import TagsToListenTo from './TagsToListenTo.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
function computeVCpuAndMemory(workers: [string, WorkerPing[]][]) {
let vcpus = 0
@@ -155,7 +156,7 @@
let vcpus_memory = $derived(computeVCpuAndMemory(workers))
let selected = $derived(nconfig?.dedicated_worker != undefined ? 'dedicated' : 'normal')
$effect(() => {
$superadmin && listWorkspaces()
($superadmin || $devopsRole) && listWorkspaces()
})
</script>
@@ -209,7 +210,7 @@
<Drawer bind:this={drawer} size="800px">
<DrawerContent
on:close={() => drawer?.closeDrawer()}
title={$superadmin ? `Edit worker config '${name}'` : `Worker config '${name}'`}
title={($superadmin || $devopsRole) ? `Edit worker config '${name}'` : `Worker config '${name}'`}
>
{#if !$enterpriseLicense}
<Alert type="warning" title="Worker management UI is EE only">
@@ -340,7 +341,7 @@
{#if defaultTagPerWorkspace}
<Select
bind:value={workspaceTag}
items={workspaces.map((w) => ({ value: w.id, label: w.id }))}
items={workspaces.map((w) => ({ value: w.id }))}
onCreateItem={(c) => (workspaceTag = c)}
placeholder="Workspace ID"
/>
@@ -358,36 +359,16 @@
{/if}
</Tooltip>
{/snippet}
<Multiselect
outerDivClass="text-secondary !bg-surface-disabled !border-0"
<MultiSelect
disabled={!$enterpriseLicense}
selected={Object.keys(nconfig?.priority_tags ?? {})}
onchange={(e) => {
if (e.type === 'add') {
if (nconfig.priority_tags) {
if (e.option && typeof e.option !== 'object') {
nconfig.priority_tags[e.option] = 100
}
}
bind:value={
() => new Array(...(nconfig?.priority_tags?.keys?.() ?? [])),
(v) => {
nconfig.priority_tags = new Map<string, number>(v.map((k) => [k, 100]))
dirty = true
} else if (e.type === 'remove') {
if (nconfig.priority_tags) {
if (e.option && typeof e.option !== 'object') {
delete nconfig.priority_tags[e.option]
}
}
dirty = true
} else if (e.type === 'removeAll') {
nconfig.priority_tags = new Map<string, number>()
dirty = true
} else {
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
}
}}
options={nconfig?.worker_tags}
selectedOptionsDraggable={false}
ulOptionsClass={'!bg-surface-secondary'}
placeholder="High priority tags"
}
items={safeSelectItems(nconfig?.worker_tags)}
/>
</Label>
{/if}
@@ -433,7 +414,7 @@
{:else if selected == 'dedicated'}
{#if nconfig?.dedicated_worker != undefined}
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
placeholder="<workspace>:<script path>"
type="text"
onchange={() => {
@@ -442,7 +423,7 @@
}}
bind:value={nconfig.dedicated_worker}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="py-2"
><Alert
type="info"
@@ -471,11 +452,11 @@
<div class="flex gap-1 items-center">
<input
type="text"
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
placeholder="/path/to/python3.X/site-packages"
bind:value={nconfig.additional_python_paths![i]}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -497,7 +478,7 @@
</div>
{/each}
{/if}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -523,12 +504,12 @@
{#each nconfig.pip_local_dependencies as _, i}
<div class="flex gap-1 items-center">
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
type="text"
placeholder="httpx"
bind:value={nconfig.pip_local_dependencies[i]}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -550,7 +531,7 @@
</div>
{/each}
{/if}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -584,7 +565,7 @@
{#each customEnvVars as envvar, i}
<div class="flex gap-1 items-center">
<input
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
type="text"
placeholder="ENV_VAR_NAME"
bind:value={envvar.key}
@@ -593,7 +574,7 @@
}}
/>
<ToggleButtonGroup
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
class="w-128"
bind:selected={envvar.type}
on:selected={(e) => {
@@ -610,13 +591,13 @@
</ToggleButtonGroup>
<input
type="text"
disabled={!$superadmin || envvar.type === 'dynamic'}
disabled={!($superadmin || $devopsRole) || envvar.type === 'dynamic'}
placeholder={envvar.type === 'dynamic'
? 'value read from worker env var'
: 'static value'}
bind:value={envvar.value}
/>
{#if $superadmin}
{#if $superadmin || $devopsRole}
<button
class="rounded-full bg-surface/60 hover:bg-gray-200"
aria-label="Clear"
@@ -639,7 +620,7 @@
{/if}
</div>
{/each}
{#if $superadmin}
{#if $superadmin || $devopsRole}
<div class="flex">
<Button
variant="contained"
@@ -657,7 +638,7 @@
</div>
{/if}
</div>
{#if !superadmin}
{#if !($superadmin || $devopsRole)}
<div class="flex flex-wrap items-center gap-1 pt-2">
<Button
variant="contained"
@@ -745,7 +726,7 @@
>
{/if}
<Editor
disabled={!$superadmin}
disabled={!($superadmin || $devopsRole)}
class="flex flex-1 grow h-full w-full"
automaticLayout
scriptLang={'bash'}
@@ -824,7 +805,7 @@
}}
disabled={(!dirty && nconfig?.dedicated_worker == undefined) ||
!$enterpriseLicense ||
!$superadmin}
!($superadmin || $devopsRole)}
>
Apply changes
</Button>
@@ -19,7 +19,7 @@
import Tooltip from './Tooltip.svelte'
import { JobService, type QueuedJob } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { emptyString } from '$lib/utils'
let container: HTMLDivElement
@@ -3,19 +3,20 @@
import { WorkerService } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
let {
tag = $bindable(),
noLabel = false,
nullTag = undefined,
disabled = false
} = $props<{
}: {
tag: string | undefined
noLabel?: boolean
nullTag?: string | undefined
disabled?: boolean
}>()
} = $props()
loadWorkerGroups()
@@ -41,7 +42,7 @@
class="w-full"
{disabled}
placeholder={nullTag ? `default: ${nullTag}` : 'lang default'}
items={items.map((value) => ({ value }))}
items={safeSelectItems(items)}
bind:value={() => tag, (value) => ((tag = value), dispatch('change', value))}
/>
</div>
@@ -101,7 +101,6 @@
componentStyle={$app.css?.formcomponent}
/>
{/each}
<RunnableWrapper
{recomputeIds}
{render}
@@ -7,11 +7,11 @@
import InputValue from '../helpers/InputValue.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
let { id, configuration, render } = $props<{
let { id, configuration, render }: {
id: string
configuration: RichConfigurations
render: boolean
}>()
} = $props()
const { componentControl, worldStore, selectedComponent, connectingInput, mode } =
getContext<AppViewerContext>('AppViewerContext')
@@ -11,7 +11,8 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { twMerge } from 'tailwind-merge'
import { enUS, fr, de, pt, ja } from 'date-fns/locale'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -238,9 +239,10 @@
outputs.day.set(v ? Number(v) : undefined)
}
}
items={Array.from({ length: computeDayPerMonth(selectedMonth, selectedYear) }, (_, i) => {
return { label: String(i + 1), value: String(i + 1) }
})}
items={Array.from(
{ length: computeDayPerMonth(selectedMonth, selectedYear) },
(_, i) => ({ value: String(i + 1) })
)}
class={twMerge('text-clip min-w-0', css?.input?.class, 'wm-date-select')}
containerStyle={css?.input?.style}
placeholder="Pick a day"
@@ -280,9 +282,7 @@
outputs.year.set(selectedYear ? Number(selectedYear) : undefined)
}
}
items={Array.from({ length: 201 }, (_, i) => `${1900 + i}`).map((value) => ({
value
}))}
items={safeSelectItems(Array.from({ length: 201 }, (_, i) => `${1900 + i}`))}
placeholder="Pick a year"
class={twMerge('text-clip min-w-0', css?.input?.class, 'wm-date-select')}
containerStyle={css?.input?.style}
@@ -3,9 +3,6 @@
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
// @ts-ignore
import MultiSelect from 'svelte-multiselect'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'multiselectcomponent'> | undefined = undefined
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte'
import { getContext, onDestroy, untrack } from 'svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import type {
AppViewerContext,
@@ -16,61 +16,50 @@
// @ts-ignore
import Portal from '$lib/components/Portal.svelte'
import { createFloatingActions } from 'svelte-floating-ui'
import { extractCustomProperties } from '$lib/utils'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import { deepEqual } from 'fast-equals'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { twMerge } from 'tailwind-merge'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'multiselectcomponent'> | undefined = undefined
export let render: boolean
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
interface Props {
id: string
configuration: RichConfigurations
customCss?: ComponentCustomCSS<'multiselectcomponentv2'> | undefined
render: boolean
verticalAlignment?: 'top' | 'center' | 'bottom' | undefined
}
let {
id,
configuration,
customCss = undefined,
render,
verticalAlignment = undefined
}: Props = $props()
const iterContext = getContext<ListContext>('ListWrapperContext')
const listInputs: ListInputs | undefined = getContext<ListInputs>('ListInputs')
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
middleware: [offset(5), flip(), shift()]
})
const { app, worldStore, selectedComponent, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
let items: (string | { value: string; label: any })[] = []
const resolvedConfig = initConfig(
components['multiselectcomponent'].initialData.configuration,
configuration
let items: { value: string; label?: any }[] = $state([])
const resolvedConfig = $state(
initConfig(components['multiselectcomponentv2'].initialData.configuration, configuration)
)
const outputs = initOutput($worldStore, id, {
result: [] as string[]
})
let selectedItems: (number | string | { value: string; label: any })[] | undefined = [
...new Set(outputs?.result.peak())
] as (number | string | { value: string; label: any })[]
let selectedItems: string[] = $state([...new Set(outputs?.result.peak())].map(convertToValue))
$effect(() => setResultsFromSelectedItems(selectedItems))
function setResultsFromSelectedItems() {
const value = [
...(selectedItems?.map((item) => {
if (typeof item == 'number') {
return item.toString()
} else if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return item?.value ?? `NOT_STRING`
} else if (typeof item == 'string') {
return item
} else if (typeof item == 'object' && item.label != undefined) {
return item.label
} else {
return 'NOT_STRING'
}
}) ?? [])
]
let customItems: string[] = $state([])
function setResultsFromSelectedItems(value: string[]) {
outputs?.result.set(value)
setContextValue(value)
}
@@ -95,82 +84,60 @@
}
}
$: resolvedConfig.items && handleItems()
function handleItems() {
if (Array.isArray(resolvedConfig.items)) {
items = resolvedConfig.items?.map((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return item
}
if (typeof item == 'number') {
return item.toString()
}
return typeof item === 'string' ? item : `NOT_STRING`
})
items = resolvedConfig.items?.map(convertToItem)
}
}
$: resolvedConfig.defaultItems && setSelectedItemsFromValues(resolvedConfig.defaultItems)
function convertToItem(v: any) {
if (typeof v == 'object' && v.value != undefined && v.label != undefined) {
return v as { value: any; label?: string }
}
if (typeof v == 'number') return { value: v.toString() }
return { value: typeof v === 'string' ? v : `NOT_STRING` }
}
function convertToValue(item: any): string {
if (typeof item == 'object' && item.value != undefined) return item.value
if (typeof item == 'number') return item.toString()
if (typeof item == 'string') return item
return item?.toString?.() ?? 'NOT_STRING'
}
function setSelectedItemsFromValues(values: any[]) {
if (Array.isArray(values)) {
const nvalue = values
.map((value) => {
const x = items.find((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return deepEqual(item.value, value)
}
return item == value
})
return (
items.find((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return deepEqual(item.value, value)
}
return item == value
}) ??
(typeof x === 'object' ? x.value : x) ??
(typeof value == 'string' ? value : undefined) ??
(typeof value == 'number' ? value.toString() : undefined)
)
})
.filter((item) => item != undefined)
selectedItems = [...new Set(nvalue)]
setResultsFromSelectedItems()
setResultsFromSelectedItems(selectedItems)
}
}
let css = initCss($app.css?.multiselectcomponent, customCss)
let css = $state(initCss($app.css?.multiselectcomponentv2, customCss))
function setOuterDivStyle(outerDiv: HTMLDivElement, portalRef: HTMLDivElement, style: string) {
outerDiv.setAttribute('style', style)
// find ul in portalRef and set style
const ul = portalRef.querySelector('ul')
ul?.setAttribute('style', extractCustomProperties(style))
}
$: outerDiv &&
portalRef &&
css?.multiselect?.style &&
setOuterDivStyle(outerDiv, portalRef, css?.multiselect?.style)
let outerDiv: HTMLDivElement | undefined = undefined
let portalRef: HTMLDivElement | undefined = undefined
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$: if (render && portalRef && outerDiv && items?.length > 0) {
tick().then(() => {
moveOptionsToPortal()
})
}
let w = 0
let open: boolean = false
$effect(() => {
resolvedConfig.items && untrack(() => handleItems())
})
$effect(() => {
;[resolvedConfig.defaultItems]
untrack(() => setSelectedItemsFromValues(resolvedConfig.defaultItems))
})
</script>
{#each Object.keys(components['multiselectcomponent'].initialData.configuration) as key (key)}
{#each Object.keys(components['multiselectcomponentv2'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
@@ -185,7 +152,7 @@
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.multiselectcomponent}
componentStyle={$app.css?.multiselectcomponentv2}
/>
{/each}
@@ -193,74 +160,33 @@
<AlignWrapper {render} hFull {verticalAlignment}>
<div
class="w-full app-editor-input"
on:pointerdown={(e) => {
class={twMerge('w-full', resolvedConfig.allowOverflow ? '' : 'absolute inset-0')}
onpointerdown={(e) => {
$selectedComponent = [id]
if (!e.shiftKey) {
e.stopPropagation()
}
if (!e.shiftKey) e.stopPropagation()
selectedComponent.set([id])
}}
use:floatingRef
bind:clientWidth={w}
>
{#if !selectedItems || Array.isArray(selectedItems)}
<MultiSelect
bind:outerDiv
outerDivClass={`${resolvedConfig.allowOverflow ? '' : 'h-full'}`}
ulSelectedClass={`${resolvedConfig.allowOverflow ? '' : 'overflow-auto max-h-full'} `}
--sms-border={'none'}
--sms-min-height={'32px'}
--sms-focus-border={'none'}
bind:selected={selectedItems}
options={items}
placeholder={resolvedConfig.placeholder}
allowUserOptions={resolvedConfig.create}
on:change={(event) => {
if (event?.detail?.type === 'removeAll') {
outputs?.result.set([])
setContextValue([])
} else {
setResultsFromSelectedItems()
<MultiSelect
style={css.multiselect?.style}
class={twMerge(
'multiselect',
resolvedConfig.allowOverflow ? '' : 'max-h-full overflow-y-scroll'
)}
selectedUlClass="selected"
items={safeSelectItems([...items, ...customItems])}
placeholder={resolvedConfig.placeholder}
bind:value={selectedItems}
disabled={resolvedConfig.disabled}
onCreateItem={resolvedConfig.create
? (item) => {
customItems.push(item)
selectedItems.push(item)
customItems = customItems
selectedItems = selectedItems
}
}}
on:open={() => {
$selectedComponent = [id]
open = true
}}
on:close={() => {
open = false
}}
let:option
>
<!-- needed because portal doesn't work for mouseup event en mobile -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full"
on:mouseup|stopPropagation
on:pointerdown|stopPropagation={(e) => {
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
>
{typeof option == 'object' ? (option?.label ?? 'NO_LABEL') : option}
</div>
</MultiSelect>
<Portal name="app-multiselect-v2">
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
on:click|stopPropagation
></div>
</div>
</Portal>
{:else}
Value {selectedItems} is not an array
{/if}
: undefined}
onOpen={() => ($selectedComponent = [id])}
/>
</div>
</AlignWrapper>
@@ -19,7 +19,7 @@
import { Bug } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
id: string
@@ -24,19 +24,29 @@
let { id, componentContainerHeight, customCss = undefined, render, nodes }: Props = $props()
let resolvedConditions = $derived(
nodes.reduce((acc, node) => {
let resolvedConditions = $state(createResolvedConditions())
function createResolvedConditions() {
return nodes.reduce((acc, node) => {
acc[node.id] = acc[node.id] || []
return acc
}, {})
)
}
let resolvedNext = $derived(
nodes.reduce((acc, node) => {
let resolvedNext = $state(createResolvedNext())
function createResolvedNext() {
return nodes.reduce((acc, node) => {
acc[node.id] = acc[node.id] || false
return acc
}, {})
)
}
$effect(() => {
nodes
resolvedConditions = untrack(() => createResolvedConditions())
resolvedNext = untrack(() => createResolvedNext())
})
let everRender = $state(render)
$effect.pre(() => {
@@ -165,6 +175,8 @@
})
</script>
<!-- {JSON.stringify(resolvedConditions)}
{JSON.stringify(resolvedNext)} -->
{#if Object.keys(resolvedConditions).length === nodes.length}
{#each nodes ?? [] as node (node.id)}
{#each node.next ?? [] as next, conditionIndex}
@@ -1256,7 +1256,7 @@
<SecondaryMenu right />
{:else}
<div class="min-w-[150px] text-sm !text-secondary text-center py-8 px-2">
Select a component to see the settings&nbsp;for&nbsp;it
Select a component to see the settings for it
</div>
{/if}
</TabContent>
@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
import Dropdown from '$lib/components/DropdownV2.svelte'
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
@@ -7,18 +10,24 @@
import { isDebugging } from './settingsPanel/decisionTree/utils'
import { X, Bug } from 'lucide-svelte'
export let nodes: DecisionTreeNode[] = []
export let id: string
export let isSmall = false
export let componentIsDebugging = false
interface Props {
nodes?: DecisionTreeNode[]
id: string
isSmall?: boolean
componentIsDebugging?: boolean
}
$: componentIsDebugging = isDebugging($debuggingComponents, id)
let { nodes = [], id, isSmall = false, componentIsDebugging = $bindable(false) }: Props = $props()
$effect(() => {
componentIsDebugging = isDebugging($debuggingComponents, id)
})
const { componentControl, debuggingComponents, worldStore } =
getContext<AppViewerContext>('AppViewerContext')
const dispatch = createEventDispatcher()
let currentNodeId: string = $worldStore.outputsById[id]?.currentNodeId?.peak() ?? 'a'
let currentNodeId: string = $state($worldStore.outputsById[id]?.currentNodeId?.peak() ?? 'a')
function subscribeToCurrentNode(id: string) {
return $worldStore.outputsById[id]?.currentNodeId?.subscribe(
@@ -49,9 +58,11 @@
}
}
$: onDebugNode($debuggingComponents[id])
$effect(() => {
onDebugNode($debuggingComponents[id])
})
let renderCount: number = 0
let renderCount: number = $state(0)
let lastNodes: DecisionTreeNode[] = nodes
function onNodesChange(newNodes: DecisionTreeNode[]) {
@@ -67,7 +78,9 @@
}
}
$: onNodesChange(nodes)
$effect(() => {
onNodesChange(nodes)
})
async function getDropdownItems() {
return [
@@ -97,7 +110,7 @@
{#key renderCount}
<Dropdown items={getDropdownItems} class="w-fit h-auto" usePointerDownOutside={true}>
<svelte:fragment slot="buttonReplacement">
{#snippet buttonReplacement()}
<button
title={'Debug tabs'}
class={classNames(
@@ -106,15 +119,15 @@
? ' hover:bg-red-300 hover:text-red-800'
: 'text-blue-600 hover:bg-blue-300 hover:text-blue-800'
)}
on:click={() => dispatch('triggerInlineEditor')}
on:pointerdown|stopPropagation
onclick={() => dispatch('triggerInlineEditor')}
onpointerdown={stopPropagation(bubble('pointerdown'))}
>
{#if componentIsDebugging}
<div class="flex flex-row items-center gap-2">
{`${isSmall ? '' : 'Debugging node'} ${nodes[$debuggingComponents[id] ?? 0]?.id}`}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
on:click={() => {
onclick={() => {
$componentControl?.[id]?.setTab?.(0)
$debuggingComponents = Object.fromEntries(
@@ -131,6 +144,6 @@
>{`Debug nodes (current node: ${currentNodeId})`}</div
>{/if}
</button>
</svelte:fragment>
{/snippet}
</Dropdown>
{/key}
@@ -127,6 +127,7 @@
}
</script>
<input />
<div class="w-full z-[1000] overflow-visible h-full">
<div class={$app.hideLegacyTopBar ? 'hidden' : ''}>
<div
@@ -2354,6 +2354,11 @@ This is a paragraph.
tooltip:
'If too many items, the box overflow its container instead of having an internal scroll'
},
disabled: {
type: 'static',
value: false,
fieldType: 'boolean'
}
}
}
@@ -110,5 +110,7 @@ export const COMPONENT_SETS = [layout, tabs, buttons, inputs, tables, display, c
export const DEPRECATED_COMPONENTS = {
tablecomponent:
'We will be removing this component in the future. we recommend using the AgGrid table instead.'
'We will be removing this component in the future. we recommend using the AgGrid table instead.',
chartjscomponent: 'Use the new ChartJS v2 component instead',
multiselectcomponent: 'Use the new MultiSelect v2 component instead'
}
@@ -4,7 +4,7 @@
import { Badge, Button, ClearableInput, Tab, TabContent, Tabs } from '../../../common'
import type { AppViewerContext } from '../../types'
import ListItem from './ListItem.svelte'
import { ccomponents, components } from '../component'
import { ccomponents, components, DEPRECATED_COMPONENTS } from '../component'
import { customisationByComponent } from './cssUtils'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
@@ -60,7 +60,7 @@
ids: ['q'].map((id) => ({ id, forceStyle: true, forceClass: true }))
},
...Object.entries(ccomponents)
.filter(([key]) => key !== 'quillcomponent')
.filter(([key]) => !['quillcomponent', ...Object.keys(DEPRECATED_COMPONENTS)].includes(key))
.map(([type, { name, icon, customCss }]) => ({
type: type as keyof typeof components,
name,
@@ -676,8 +676,8 @@ export const customisationByComponent: Customisation[] = [
comment: 'buttons to remove a single or all selected options at once'
},
{
selector: '.multiselect > input[autocomplete]',
comment: 'input inside the top-level wrapper div'
selector: '.multiselect.dropdown',
comment: 'dropdown container'
},
{
selector: '.multiselect > ul.options',
@@ -686,23 +686,6 @@ export const customisationByComponent: Customisation[] = [
{
selector: '.multiselect > ul.options > li',
comment: 'dropdown list items'
},
{
selector: '.multiselect > ul.options > li.selected',
comment: 'selected options in the dropdown list'
},
{
selector: '.multiselect > ul.options > li:not(.selected):hover',
comment: 'unselected but hovered options in the dropdown list'
},
{
selector: '.multiselect > ul.options > li.active',
comment:
'active item, navigated to with up/down arrow keys and ready to be selected by pressing enter'
},
{
selector: '.multiselect > ul.options > li.disabled',
comment: 'options with disabled key set to true'
}
],
variables: [
@@ -57,7 +57,13 @@
</div>
</Pane>
<Pane size={40}>
<div class="h-full w-full bg-surface p-4 flex flex-col gap-6">
<div
class="h-full w-full bg-surface p-4 flex flex-col gap-6"
on:keydown={(e) => {
// Prevent keyboard events from bubbling to SvelteFlow
e.stopPropagation()
}}
>
{#if selectedNode}
<Section label="Conditions" class="w-full flex flex-col gap-2">
<svelte:fragment slot="action">
@@ -92,6 +98,10 @@
renderCount++
}, 300)()
}}
on:keydown={(e) => {
// Prevent keyboard events from bubbling to SvelteFlow
e.stopPropagation()
}}
/>
</Label>
@@ -1,16 +1,21 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
import MultiSelect from '$lib/components/multiselect/MultiSelectWrapper.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { twMerge } from 'tailwind-merge'
let { items, value = $bindable(), title, tooltip } = $props<{
let {
items,
value = $bindable(),
title,
tooltip
}: {
items: string[]
value: string[] | undefined
title: string
tooltip: string
}>()
} = $props()
let width = $state(0)
const inputWidth = 280
@@ -34,7 +39,10 @@
No components to recompute.
</div>
{:else}
<MultiSelect {items} bind:value />
<MultiSelect
items={safeSelectItems(items)}
bind:value={() => value ?? [], (v) => (value = v?.length ? v : undefined)}
/>
{/if}
</div>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { StaticInput, StaticOptions } from '../../../inputType'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
componentInput: StaticInput<any> | undefined
@@ -34,8 +34,9 @@
import { onDestroy, tick, untrack } from 'svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { safeSelectItems } from '../select/utils.svelte'
let usernames: string[] | undefined = $state()
let resources = usePromise(() => loadResources($workspaceStore!), { loadInit: false })
@@ -397,7 +398,7 @@
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
createText="Press enter to use this value"
bind:value={resource}
items={['all', ...(resources.value ?? [])].map((r) => ({ value: r, label: r }))}
items={safeSelectItems(['all', ...(resources.value ?? [])])}
inputClass="dark:!bg-gray-700"
RightIcon={ChevronDown}
/>
@@ -18,6 +18,7 @@
<Button
on:click={() => dispatch('close')}
on:pointerdown={(e) => e.stopPropagation()}
startIcon={{ icon: Icon ?? X }}
iconOnly
size="sm"
@@ -1,12 +1,23 @@
<script lang="ts">
import Portal from '$lib/components/Portal.svelte'
export let condition = false
export let target: string | HTMLElement | undefined = undefined
interface Props {
condition?: boolean
target?: string | HTMLElement | undefined
name?: string
children?: import('svelte').Snippet
}
let {
condition = false,
target = undefined,
name = 'conditional-portal',
children
}: Props = $props()
</script>
{#if condition}
<Portal name="conditional-portal" {target}><slot /></Portal>
<Portal {name} {target}>{@render children?.()}</Portal>
{:else}
<slot />
{@render children?.()}
{/if}
@@ -28,6 +28,7 @@
import { twMerge } from 'tailwind-merge'
import { onDestroy } from 'svelte'
import ProviderModelSelector from './chat/ProviderModelSelector.svelte'
import { aiChatManager, AIMode } from './chat/AIChatManager.svelte'
interface Props {
// props
@@ -38,6 +39,7 @@
inlineScript?: boolean
args: Record<string, any>
transformer?: boolean
openAiChat?: boolean
}
let {
@@ -47,7 +49,8 @@
diffEditor,
inlineScript = false,
args,
transformer = false
transformer = false,
openAiChat = false
}: Props = $props()
run(() => {
@@ -72,6 +75,54 @@
run(() => {
trimmedDesc = funcDesc.trim()
})
async function callCopilot() {
if (mode === 'edit') {
await copilot(
{
// @ts-ignore
language: transformer && lang === 'frontend' ? 'transformer' : lang!,
description: trimmedDesc,
code: editor?.getCode() || '',
dbSchema: dbSchema,
type: 'edit',
workspace: $workspaceStore!
},
generatedCode,
abortController
)
} else {
await copilot(
{
// @ts-ignore
language: transformer && lang === 'frontend' ? 'transformer' : lang!,
description: trimmedDesc,
dbSchema: dbSchema,
type: 'gen',
workspace: $workspaceStore!
},
generatedCode,
abortController
)
}
}
function handleAiButtonClick() {
if (editor && isInitialCode(editor.getCode())) {
mode = 'gen'
} else {
mode = 'edit'
}
if (openAiChat) {
// Open the AI chat in script mode
aiChatManager.openChat()
aiChatManager.changeMode(AIMode.SCRIPT)
} else {
setTimeout(() => {
autoResize()
}, 0)
}
}
async function onGenerate(closePopup: () => void) {
if (trimmedDesc.length <= 0) {
@@ -82,34 +133,7 @@
genLoading = true
blockPopupOpen = true
abortController = new AbortController()
if (mode === 'edit') {
await copilot(
{
// @ts-ignore
language: transformer && lang === 'frontend' ? 'transformer' : lang!,
description: trimmedDesc,
code: editor?.getCode() || '',
dbSchema: dbSchema,
type: 'edit',
workspace: $workspaceStore!
},
generatedCode,
abortController
)
} else {
await copilot(
{
// @ts-ignore
language: transformer && lang === 'frontend' ? 'transformer' : lang!,
description: trimmedDesc,
dbSchema: dbSchema,
type: 'gen',
workspace: $workspaceStore!
},
generatedCode,
abortController
)
}
await callCopilot()
setupDiff()
diffEditor?.setModified($generatedCode)
blockPopupOpen = false
@@ -205,14 +229,27 @@
}
let promptHistory: string[] = $state([])
function getPromptHistory() {
function getPromptStorageKey() {
return 'prompts-' + lang
}
function safeLocalStorageOperation<T>(operation: () => T, defaultValue?: T): T | undefined {
try {
promptHistory = JSON.parse(localStorage.getItem('prompts-' + lang) || '[]')
return operation()
} catch (e) {
console.error('error interacting with local storage', e)
return defaultValue
}
}
function getPromptHistory() {
const storageKey = getPromptStorageKey()
promptHistory =
safeLocalStorageOperation(() => JSON.parse(localStorage.getItem(storageKey) || '[]'), []) ||
[]
}
function savePrompt() {
if (promptHistory.includes(trimmedDesc)) {
return
@@ -221,20 +258,14 @@
while (promptHistory.length > 5) {
promptHistory.pop()
}
try {
localStorage.setItem('prompts-' + lang, JSON.stringify(promptHistory))
} catch (e) {
console.error('error interacting with local storage', e)
}
const storageKey = getPromptStorageKey()
safeLocalStorageOperation(() => localStorage.setItem(storageKey, JSON.stringify(promptHistory)))
}
function clearPromptHistory() {
promptHistory = []
try {
localStorage.setItem('prompts-' + lang, JSON.stringify(promptHistory))
} catch (e) {
console.error('error interacting with local storage', e)
}
const storageKey = getPromptStorageKey()
safeLocalStorageOperation(() => localStorage.setItem(storageKey, JSON.stringify(promptHistory)))
}
run(() => {
lang && getPromptHistory()
@@ -266,6 +297,12 @@
;(dbSchema as any).publicOnly = detail === 'true'
}
const aiChatScriptModeClasses = $derived(
aiChatManager.mode === AIMode.SCRIPT && aiChatManager.isOpen
? 'dark:bg-violet-900 bg-violet-100'
: ''
)
onDestroy(() => {
abortController?.abort()
})
@@ -343,22 +380,9 @@
<Button
size="xs"
color={genLoading ? 'red' : 'light'}
btnClasses={genLoading ? '!px-3 z-[5000]' : '!px-2'}
propagateEvent={!genLoading}
on:click={genLoading
? () => abortController?.abort()
: () => {
if (editor) {
if (isInitialCode(editor.getCode())) {
mode = 'gen'
} else {
mode = 'edit'
}
}
setTimeout(() => {
autoResize()
}, 0)
}}
btnClasses={twMerge(genLoading ? '!px-3 z-[5000]' : '!px-2', aiChatScriptModeClasses)}
propagateEvent={!genLoading && !openAiChat}
on:click={genLoading ? () => abortController?.abort() : handleAiButtonClick}
bind:element={button}
iconOnly
title="Generate code from prompt"
@@ -371,27 +395,15 @@
title="Generate code from prompt"
btnClasses={twMerge(
'!font-medium',
genLoading ? 'z-[5000]' : 'text-violet-800 dark:text-violet-400'
genLoading ? 'z-[5000]' : 'text-violet-800 dark:text-violet-400',
aiChatScriptModeClasses
)}
size="xs"
color={genLoading ? 'red' : 'light'}
spacingSize="md"
startIcon={genLoading ? { icon: Ban } : { icon: Wand2 }}
propagateEvent={!genLoading}
on:click={genLoading
? () => abortController?.abort()
: () => {
if (editor) {
if (isInitialCode(editor.getCode())) {
mode = 'gen'
} else {
mode = 'edit'
}
}
setTimeout(() => {
autoResize()
}, 0)
}}
propagateEvent={!genLoading && !openAiChat}
on:click={genLoading ? () => abortController?.abort() : handleAiButtonClick}
bind:element={button}
{iconOnly}
>
@@ -58,6 +58,7 @@ class AIChatManager {
mode = $state<AIMode>(AIMode.NAVIGATOR)
size = $state<number>(localStorage.getItem('ai-chat-open') === 'true' ? this.DEFAULT_SIZE : 0)
readonly isOpen = $derived(this.size > 0)
savedSize = $state<number>(0)
instructions = $state<string>('')
pendingPrompt = $state<string>('')
@@ -16,22 +16,37 @@
import TriggerableByAI from '../TriggerableByAI.svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
export let loading: boolean
export let disableStaticInputs = false
export let disableTutorials = false
export let disableAi = false
export let disableSettings = false
export let disabledFlowInputs = false
export let smallErrorHandler = false
export let newFlow: boolean = false
export let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
interface Props {
loading: boolean
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
disabledFlowInputs?: boolean
smallErrorHandler?: boolean
newFlow?: boolean
savedFlow?:
| (Flow & {
draft?: Flow | undefined
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
}
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined
let {
loading,
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
disabledFlowInputs = false,
smallErrorHandler = false,
newFlow = false,
savedFlow = undefined,
onDeployTrigger = () => {}
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
@@ -374,6 +374,7 @@
on:hideDiffMode={hideDiffMode}
{lastDeployedCode}
{diffMode}
openAiChat
/>
</div>
{/if}
@@ -5,10 +5,10 @@
import { WorkerService } from '$lib/gen'
import WorkerTagSelect from '$lib/components/WorkerTagSelect.svelte'
let { tag = $bindable(), nullTag = $bindable() } = $props<{
let { tag = $bindable(), nullTag = $bindable() }: {
tag: string | undefined
nullTag?: string | undefined
}>()
} = $props()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -1,828 +0,0 @@
<script lang="ts">
import { createEventDispatcher, tick } from 'svelte'
import { flip } from 'svelte/animate'
import VirtualList from 'svelte-tiny-virtual-list'
import { twMerge } from 'tailwind-merge'
import type { DispatchEvents, MultiSelectEvents, Option as T } from './types'
import { get_label, get_style, scroll_into_view_if_needed_polyfill } from './utils'
import { ChevronsUpDown, X } from 'lucide-svelte'
import type { FullAutoFill } from 'svelte/elements'
type Option = $$Generic<T>
export let activeIndex: number | null = null
export let activeOption: Option | null = null
export let createOptionMsg: string | null = `Create this option...`
export let allowUserOptions: boolean | 'append' = false
export let autocomplete: FullAutoFill = `off`
export let autoScroll: boolean = true
export let breakpoint: number = 800 // any screen with more horizontal pixels is considered desktop, below is mobile
export let defaultDisabledTitle: string = `This option is disabled`
export let disabled: boolean = false
export let disabledInputTitle: string = `This input is disabled`
// prettier-ignore
export let duplicateOptionMsg: string = `This option is already selected`
export let duplicates: boolean = false // whether to allow duplicate options
// takes two options and returns true if they are equal
// case-insensitive equality comparison after string coercion and looks only at the `label` key of object options by default
export let key: (opt: T) => unknown = (opt) => `${get_label(opt)}`.toLowerCase()
export let filterFunc = (opt: Option, searchText: string): boolean => {
if (!searchText) return true
return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase())
}
export let closeDropdownOnSelect: boolean | 'desktop' = `desktop`
export let form_input: HTMLInputElement | null = null
export let highlightMatches: boolean = true
export let id: string | null = null
export let input: HTMLInputElement | null = null
export let inputClass: string = ``
export let inputStyle: string | null = null
export let inputmode:
| 'search'
| 'text'
| 'none'
| 'tel'
| 'url'
| 'email'
| 'numeric'
| 'decimal'
| null
| undefined = null
export let invalid: boolean = false
export let liSelectedClass: string = ``
export let liSelectedStyle: string | null = null
export let loading: boolean = false
export let matchingOptions: Option[] = []
export let maxSelect: number | null = null // null means there is no upper limit for selected.length
export let name: string | null = null
export let noMatchingOptionsMsg: string = `No matching options`
export let open: boolean = false
export let options: Option[]
export let outerDiv: HTMLDivElement | null = null
export let outerDivClass: string = ``
export let parseLabelsAsHtml: boolean = false // should not be combined with allowUserOptions!
export let pattern: string | null = null
export let placeholder: string | null = null
export let removeAllTitle: string = `Remove all`
export let removeBtnTitle: string = `Remove`
export let minSelect: number | null = null // null means there is no lower limit for selected.length
export let required: boolean = false
export let resetFilterOnAdd: boolean = true
export let searchText: string = ``
export let selected: Option[] =
options
?.filter((opt: any) => opt instanceof Object && opt?.preselected)
.slice(0, maxSelect ?? undefined) ?? [] // don't allow more than maxSelect preselected options
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
export let selectedOptionsDraggable: boolean = !sortSelected
export let style: string | null = null
export let ulSelectedClass: string = ``
export let ulSelectedStyle: string | null = null
export let value: Option | Option[] | null = null
export let disableRemoveAll: boolean = false
const selected_to_value = (selected: Option[]) => {
value = maxSelect === 1 ? (selected[0] ?? null) : selected
}
const value_to_selected = (value: Option | Option[] | null) => {
if (maxSelect === 1) selected = value ? [value as Option] : []
else selected = (value as Option[]) ?? []
}
// if maxSelect=1, value is the single item in selected (or null if selected is empty)
// this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
// https://github.com/janosh/svelte-multiselect/issues/136
$: selected_to_value(selected)
$: value_to_selected(value)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type $$Events = MultiSelectEvents // for type-safe event listening on this component
const dispatch = createEventDispatcher<DispatchEvents<Option>>()
let option_msg_is_active: boolean = false // controls active state of <li>{createOptionMsg}</li>
let window_width: number
// options matching the current search text
$: matchingOptions = options?.filter(
(opt) =>
filterFunc(opt, searchText) &&
// remove already selected options from dropdown list unless duplicate selections are allowed
(!selected.map(key).includes(key(opt)) || duplicates)
)
// raise if matchingOptions[activeIndex] does not yield a value
if (activeIndex !== null && !matchingOptions[activeIndex]) {
throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`
}
// update activeOption when activeIndex changes
$: activeOption = matchingOptions?.[activeIndex ?? -1] ?? null
// add an option to selected list
function add(option: T, event: Event) {
if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) {
return
}
if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
option = Number(option) as Option // convert to number if possible
}
const is_duplicate = selected.map(key).includes(key(option))
if (
(maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
(duplicates || !is_duplicate)
) {
if (
// @ts-ignore
!options.includes(option) && // first check if we find option in the options list
// this has the side-effect of not allowing to user to add the same
// custom option twice in append mode
[true, `append`].includes(allowUserOptions) &&
searchText.length > 0
) {
// user entered text but no options match, so if allowUserOptions = true | 'append', we create
// a new option from the user-entered text
if (typeof options[0] === `object`) {
// if 1st option is an object, we create new option as object to keep type homogeneity
option = { label: searchText } as Option
} else {
if ([`number`, `undefined`].includes(typeof options[0]) && !isNaN(Number(searchText))) {
// create new option as number if it parses to a number and 1st option is also number or missing
option = Number(searchText) as Option
} else {
option = searchText as Option // else create custom option as string
}
// @ts-ignore
dispatch(`create`, { option })
}
// @ts-ignore
if (allowUserOptions === `append`) options = [...options, option]
}
if (resetFilterOnAdd) searchText = `` // reset search string on selection
if ([``, undefined, null].includes(option as string | null)) {
console.error(`MultiSelect: encountered falsy option ${option}`)
return
}
if (maxSelect === 1) {
// for maxSelect = 1 we always replace current option with new one
// @ts-ignore
selected = [option]
} else {
// @ts-ignore
selected = [...selected, option]
if (sortSelected === true) {
selected = selected.sort((op1: Option, op2: Option) => {
const [label1, label2] = [get_label(op1), get_label(op2)]
// coerce to string if labels are numbers
return `${label1}`.localeCompare(`${label2}`)
})
} else if (typeof sortSelected === `function`) {
selected = selected.sort(sortSelected)
}
}
const reached_max_select = selected.length === maxSelect
const dropdown_should_close =
closeDropdownOnSelect === true ||
(closeDropdownOnSelect === `desktop` && window_width < breakpoint)
if (reached_max_select || dropdown_should_close) {
close_dropdown(event)
} else if (!dropdown_should_close) {
input?.focus()
}
// @ts-ignore
dispatch(`add`, { option })
// @ts-ignore
dispatch(`change`, { option, type: `add` })
invalid = false // reset error status whenever new items are selected
form_input?.setCustomValidity(``)
}
}
// remove an option from selected list
function remove(to_remove: T) {
if (selected.length === 0) return
const idx = selected.findIndex((opt) => key(opt) === key(to_remove))
let [option] = selected.splice(idx, 1) // remove option from selected list
if (option === undefined && allowUserOptions) {
// if option with label could not be found but allowUserOptions is truthy,
// assume it was created by user and create corresponding option object
// on the fly for use as event payload
const other_ops_type = typeof options[0]
option = (other_ops_type ? { label: to_remove } : to_remove) as Option
}
if (option === undefined) {
return console.error(
`Multiselect can't remove selected option ${JSON.stringify(
to_remove
)}, not found in selected list`
)
}
selected = [...selected] // trigger Svelte rerender
invalid = false // reset error status whenever items are removed
form_input?.setCustomValidity(``)
dispatch(`remove`, { option })
dispatch(`change`, { option, type: `remove` })
}
function open_dropdown(event: Event) {
if (disabled) return
open = true
if (!(event instanceof FocusEvent)) {
// avoid double-focussing input when event that opened dropdown was already input FocusEvent
input?.focus()
}
dispatch(`open`, { event })
}
function close_dropdown(event: Event) {
open = false
input?.blur()
activeIndex = null
dispatch(`close`, { event })
}
// handle all keyboard events this component receives
async function handle_keydown(event: KeyboardEvent) {
// on escape or tab out of input: close options dropdown and reset search text
if (event.key === `Escape` || event.key === `Tab`) {
close_dropdown(event)
searchText = ``
}
// on enter key: toggle active option and reset search text
else if (event.key === `Enter`) {
event.preventDefault() // prevent enter key from triggering form submission
if (activeOption) {
selected.includes(activeOption) ? remove(activeOption) : add(activeOption, event)
searchText = ``
} else if (allowUserOptions && searchText.length > 0) {
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
add(searchText, event)
}
// no active option and no search text means the options dropdown is closed
// in which case enter means open it
else open_dropdown(event)
}
// on up/down arrow keys: update active option
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
// if no option is active yet, but there are matching options, make first one active
if (activeIndex === null && matchingOptions.length > 0) {
activeIndex = 0
return
} else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
// if allowUserOptions is truthy and user entered text but no options match, we make
// <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
option_msg_is_active = !option_msg_is_active
return
} else if (activeIndex === null) {
// if no option is active and no options are matching, do nothing
return
}
event.preventDefault()
// if none of the above special cases apply, we make next/prev option
// active with wrap around at both ends
const increment = event.key === `ArrowUp` ? -1 : 1
activeIndex = (activeIndex + increment) % matchingOptions.length
// in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
// need to do manual wrap around at 0
if (activeIndex < 0) activeIndex = matchingOptions.length - 1
if (autoScroll) {
await tick()
const li = document.querySelector(`ul.options > li.active`)
if (li) scroll_into_view_if_needed_polyfill(li)
}
}
// on backspace key: remove last selected option
else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
remove(selected.at(-1) as Option)
}
// make first matching option active on any keypress (if none of the above special cases match)
else if (matchingOptions.length > 0) {
activeIndex = 0
}
}
function remove_all() {
dispatch(`removeAll`, { options: selected })
dispatch(`change`, { options: selected, type: `removeAll` })
selected = []
searchText = ``
}
$: is_selected = (label: string | number) => selected.map(get_label).includes(label)
const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => {
if ([`Enter`, `Space`].includes(event.code)) {
event.preventDefault()
handler()
}
}
function on_click_outside(event: MouseEvent | TouchEvent) {
if (outerDiv && !outerDiv.contains(event.target as Node)) {
close_dropdown(event)
}
}
let drag_idx: number | null = null
// event handlers enable dragging to reorder selected options
const drop = (target_idx: number) => (event: DragEvent) => {
if (!event.dataTransfer) return
event.dataTransfer.dropEffect = `move`
const start_idx = parseInt(event.dataTransfer.getData(`text/plain`))
const new_selected = [...selected]
if (start_idx < target_idx) {
new_selected.splice(target_idx + 1, 0, new_selected[start_idx])
new_selected.splice(start_idx, 1)
} else {
new_selected.splice(target_idx, 0, new_selected[start_idx])
new_selected.splice(start_idx + 1, 1)
}
selected = new_selected
drag_idx = null
}
const dragstart = (idx: number) => (event: DragEvent) => {
if (!event.dataTransfer) return
// only allow moving, not copying (also affects the cursor during drag)
event.dataTransfer.effectAllowed = `move`
event.dataTransfer.dropEffect = `move`
event.dataTransfer.setData(`text/plain`, `${idx}`)
}
let ul_options: HTMLUListElement
// highlight text matching user-entered search text in available options
function highlight_matching_options(
event: Event & {
currentTarget: EventTarget & HTMLInputElement
}
) {
// @ts-ignore
if (!highlightMatches || !ul_options || typeof CSS == `undefined` || !CSS.highlights) return // abort if CSS highlight API not supported
// clear previous ranges from HighlightRegistry
// @ts-ignore
CSS.highlights.clear()
// get input's search query
const query = (event?.target as HTMLInputElement)?.value.trim().toLowerCase()
if (!query) return
const tree_walker = document.createTreeWalker(ul_options, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// don't highlight text in the "no matching options" message
if (node?.textContent === noMatchingOptionsMsg) return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
}
})
const text_nodes: Node[] = []
let current_node = tree_walker.nextNode()
while (current_node) {
text_nodes.push(current_node)
current_node = tree_walker.nextNode()
}
// iterate over all text nodes and find matches
const ranges = text_nodes.map((el) => {
const text = el.textContent?.toLowerCase()
const indices: number[] = []
let start_pos = 0
while (text && start_pos < text.length) {
const index = text.indexOf(query, start_pos)
if (index === -1) break
indices.push(index)
start_pos = index + query.length
}
// create range object for each str found in the text node
return indices.map((index) => {
const range = new Range()
range.setStart(el, index)
range.setEnd(el, index + query.length)
return range
})
})
// create Highlight object from ranges and add to registry
// @ts-ignore
CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()))
}
// reset form validation when required prop changes
// https://github.com/janosh/svelte-multiselect/issues/285
$: required, form_input?.setCustomValidity(``)
</script>
<svelte:window
on:click={on_click_outside}
on:touchstart={on_click_outside}
bind:innerWidth={window_width}
/>
<div
bind:this={outerDiv}
class:disabled
class:single={maxSelect === 1}
class:open
class:invalid
class="multiselect {outerDivClass}"
on:mouseup|stopPropagation={open_dropdown}
title={disabled ? disabledInputTitle : null}
data-id={id}
role="searchbox"
tabindex="-1"
{style}
>
<!-- form control input invisible to the user, only purpose is to abort form submission if this component fails data validation -->
<!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
<input
{name}
{required}
value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
tabindex="-1"
aria-hidden="true"
aria-label="ignore this, used only to prevent form submission if select is required but empty"
class="form-control"
bind:this={form_input}
on:invalid={() => {
invalid = true
let msg
if (maxSelect && maxSelect > 1 && Number(required) > 1) {
msg = `Please select between ${required} and ${maxSelect} options`
} else if (Number(required) > 1) {
msg = `Please select at least ${required} options`
} else {
msg = `Please select an option`
}
form_input?.setCustomValidity(msg)
}}
/>
<slot name="expand-icon" {open}>
<ChevronsUpDown size={16} />
</slot>
<ul class="selected {ulSelectedClass}" aria-label="selected options" style={ulSelectedStyle}>
{#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
<li
class={liSelectedClass}
role="option"
aria-selected="true"
animate:flip={{ duration: 100 }}
draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
on:dragstart={dragstart(idx)}
on:drop|preventDefault={drop(idx)}
on:dragenter={() => (drag_idx = idx)}
on:dragover|preventDefault
class:active={drag_idx === idx}
style="{get_style(option, `selected`)} {liSelectedStyle}"
>
<!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
<slot name="selected" {option} {idx}>
<slot {option} {idx}>
{#if parseLabelsAsHtml}
{@html get_label(option)}
{:else}
{get_label(option)}
{/if}
</slot>
</slot>
{#if !disabled && (minSelect === null || selected.length > minSelect)}
<button
on:mouseup|stopPropagation={() => remove(option)}
on:keydown={if_enter_or_space(() => remove(option))}
type="button"
title="{removeBtnTitle} {get_label(option)}"
class="remove"
>
<slot name="remove-icon">
<X size={20} class="text-primary dark:text-primary-inverse p-0.5" />
</slot>
</button>
{/if}
</li>
{/each}
<input
class={inputClass}
style={inputStyle}
bind:this={input}
bind:value={searchText}
on:mouseup|self|stopPropagation={(e) => open_dropdown(e)}
on:keydown|stopPropagation={(e) => handle_keydown(e)}
on:focus={(e) => open_dropdown(e)}
on:input={(e) => highlight_matching_options(e)}
{id}
{disabled}
{autocomplete}
{inputmode}
{pattern}
placeholder={selected.length === 0 ? placeholder : undefined}
aria-invalid={invalid ? 'true' : undefined}
on:blur
on:change
on:click
on:keydown
on:keyup
on:mousedown
on:mouseenter
on:mouseleave
on:touchcancel
on:touchend
on:touchmove
on:touchstart
/>
<!-- the above on:* lines forward potentially useful DOM events -->
<slot name="after-input" {selected} {disabled} {invalid} {id} {placeholder} {open} {required} />
</ul>
{#if loading}
<slot name="spinner">spinner</slot>
{/if}
{#if disabled}
<slot name="disabled-icon">disable</slot>
{:else if selected.length > 0}
{#if maxSelect !== 1 && selected.length > 1 && !disableRemoveAll}
<button
type="button"
class="remove remove-all"
title={removeAllTitle}
on:mouseup|stopPropagation={remove_all}
on:keydown={if_enter_or_space(remove_all)}
>
<slot name="remove-icon">
<X
size={24}
class="text-secondary p-1 rounded-full bg-surface hover:bg-surface-secondary"
/>
</slot>
</button>
{/if}
{/if}
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
{#if allowUserOptions || (searchText && noMatchingOptionsMsg) || options?.length > 0}
<div class="options bg-surface shadow-md rounded-component">
<VirtualList
width="100%"
height={Math.min(32 * matchingOptions.length, 320)}
itemCount={matchingOptions.length}
itemSize={32}
>
<div slot="item" let:index let:style {style}>
{@const option = matchingOptions[index]}
{@const {
label,
disabled = null,
title = null,
selectedTitle = null,
disabledTitle = defaultDisabledTitle
} = option instanceof Object ? option : { label: option }}
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
on:mousedown|stopPropagation
on:mouseup|stopPropagation={(event) => {
if (!disabled) add(option, event)
}}
title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
class={twMerge('hover:bg-blue-100 hover:dark:bg-gray-900 cursor-pointer !px-2 py-1')}
on:mouseover={() => {
if (!disabled) activeIndex = index
}}
on:focus={() => {
if (!disabled) activeIndex = index
}}
on:mouseout={() => (activeIndex = null)}
on:blur={() => (activeIndex = null)}
role="option"
aria-selected="false"
style={get_style(option, `option`)}
>
<slot name="option" {option} {index}>
<slot {option} {index}>
{#if parseLabelsAsHtml}
{@html get_label(option)}
{:else}
{get_label(option)}
{/if}
</slot>
</slot>
</div>
</div>
<div slot="footer" class="h-0"></div>
</VirtualList>
{#if searchText}
{@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
{@const no_match =
Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) && `no-match`}
{@const msgType = is_dupe || can_create || no_match}
{#if msgType}
{@const msg = {
dupe: duplicateOptionMsg,
create: createOptionMsg,
'no-match': noMatchingOptionsMsg
}[msgType]}
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
on:mousedown|stopPropagation
on:mouseup|stopPropagation={(event) => {
if (allowUserOptions) add(searchText, event)
}}
title={createOptionMsg}
class:active={option_msg_is_active}
on:mouseover={() => (option_msg_is_active = true)}
on:focus={() => (option_msg_is_active = true)}
on:mouseout={() => (option_msg_is_active = false)}
on:blur={() => (option_msg_is_active = false)}
role="option"
aria-selected="false"
class="user-msg p-1"
style:cursor={{
dupe: `not-allowed`,
create: `pointer`,
'no-match': `default`
}[msgType]}
>
<slot name="user-msg" {searchText} {msgType} {msg}>
{msg}
</slot>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<style>
:where(div.multiselect) {
position: relative;
align-items: center;
display: flex;
cursor: text;
box-sizing: border-box;
border: var(--sms-border, 1pt solid lightgray);
border-radius: var(--sms-border-radius, 3pt);
background: var(--sms-bg);
width: var(--sms-width);
max-width: var(--sms-max-width);
padding: var(--sms-padding, 0 3pt);
color: var(--sms-text-color);
font-size: var(--sms-font-size, inherit);
min-height: var(--sms-min-height, 22pt);
margin: var(--sms-margin);
}
:where(div.multiselect.open) {
/* increase z-index when open to ensure the dropdown of one <MultiSelect />
displays above that of another slightly below it on the page */
z-index: var(--sms-open-z-index, 4);
}
:where(div.multiselect:focus-within) {
border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
}
:where(div.multiselect.disabled) {
background: var(--sms-disabled-bg, lightgray);
cursor: not-allowed;
}
:where(div.multiselect > ul.selected) {
display: flex;
flex: 1;
padding: 0;
margin: 0;
flex-wrap: wrap;
}
:where(div.multiselect > ul.selected > li) {
align-items: center;
border-radius: 3pt;
display: flex;
margin: 2pt;
line-height: normal;
transition: 0.3s;
white-space: nowrap;
background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15));
padding: var(--sms-selected-li-padding, 1pt 5pt);
color: var(--sms-selected-text-color, var(--sms-text-color));
}
:where(div.multiselect > ul.selected > li[draggable='true']) {
cursor: grab;
}
:where(div.multiselect > ul.selected > li.active) {
background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
}
:where(div.multiselect button) {
border-radius: 50%;
display: flex;
transition: 0.2s;
color: inherit;
background: transparent;
border: none;
cursor: pointer;
outline: none;
padding: 0;
margin: 0 0 0 3pt; /* CSS reset */
}
:where(div.multiselect button.remove-all) {
margin: 0 3pt;
}
:where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
color: var(--sms-remove-btn-hover-color, lightskyblue);
background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
}
:where(div.multiselect input) {
margin: auto 0; /* CSS reset */
padding: 0; /* CSS reset */
}
:where(div.multiselect > ul.selected > input) {
border: none;
outline: none;
background: none;
flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
min-width: 2em;
/* ensure input uses text color and not --sms-selected-text-color */
color: var(--sms-text-color);
font-size: inherit;
cursor: inherit; /* needed for disabled state */
border-radius: 0; /* reset ul.selected > li */
}
/* don't wrap ::placeholder rules in :where() as it seems to be overpowered by browser defaults i.t.o. specificity */
div.multiselect > ul.selected > input::placeholder {
padding-left: 5pt;
color: var(--sms-placeholder-color);
opacity: var(--sms-placeholder-opacity);
}
:where(div.multiselect > input.form-control) {
width: 2em;
position: absolute;
background: transparent;
border: none;
outline: none;
z-index: -1;
opacity: 0;
pointer-events: none;
}
:where(div.multiselect > ul.options) {
list-style: none;
top: 100%;
left: 0;
width: 100%;
position: absolute;
overflow: auto;
transition: all 0.2s;
box-sizing: border-box;
background: var(--sms-options-bg, white);
max-height: var(--sms-options-max-height, 50vh);
overscroll-behavior: var(--sms-options-overscroll, none);
border: var(--sms-options-border);
border-width: var(--sms-options-border-width);
border-radius: var(--sms-options-border-radius, 1ex);
padding: var(--sms-options-padding);
margin: var(--sms-options-margin, inherit);
}
:where(div.multiselect > ul.options.hidden) {
visibility: hidden;
opacity: 0;
transform: translateY(50px);
}
:where(div.multiselect > ul.options > li) {
padding: 3pt 2ex;
cursor: pointer;
scroll-margin: var(--sms-options-scroll-margin, 100px);
}
:where(div.multiselect > ul.options .user-msg) {
/* block needed so vertical padding applies to span */
display: block;
padding: 3pt 2ex;
}
:where(div.multiselect > ul.options > li.selected) {
background: var(--sms-li-selected-bg);
color: var(--sms-li-selected-color);
}
:where(div.multiselect > ul.options > li.active) {
background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
}
:where(div.multiselect > ul.options > li.disabled) {
cursor: not-allowed;
background: var(--sms-li-disabled-bg, #f5f5f6);
color: var(--sms-li-disabled-text, #b8b8b8);
}
::highlight(sms-search-matches) {
color: mediumaquamarine;
}
</style>
@@ -1,130 +0,0 @@
<script lang="ts">
import Portal from '$lib/components/Portal.svelte'
import { createFloatingActions } from 'svelte-floating-ui'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { deepEqual } from 'fast-equals'
let {
items,
value = $bindable(),
placeholder = undefined,
target = undefined,
topPlacement = false,
allowUserOptions = undefined
} = $props<{
items: any[]
value?: string[]
placeholder?: string
target?: string | HTMLElement
topPlacement?: boolean
allowUserOptions?: boolean | 'append'
}>()
$effect.pre(() => {
if (value === undefined) value = []
})
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
placement: topPlacement ? 'top-start' : 'bottom-start',
middleware: [offset(5), flip(), shift()]
})
let outerDiv = $state<HTMLDivElement | undefined>(undefined)
let portalRef = $state<HTMLDivElement | undefined>(undefined)
let darkMode = $state(false)
let w = $state(0)
let open = $state(false)
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$effect(() => {
if (portalRef && outerDiv && (allowUserOptions || items?.length > 0)) {
tick().then(() => {
moveOptionsToPortal()
})
}
})
</script>
<DarkModeObserver bind:darkMode />
<div use:floatingRef bind:clientWidth={w}>
{#if !value || Array.isArray(value)}
<div class="border rounded-md border-gray-300 shadow-sm dark:border-gray-600 !w-full">
<MultiSelect
{allowUserOptions}
outerDivClass={`!text-xs`}
ulSelectedClass="overflow-auto"
bind:outerDiv
--sms-border={'none'}
--sms-min-height={'30px'}
--sms-focus-border={'none'}
--sms-selected-bg={darkMode ? '#c7d2fe' : '#e0e7ff'}
--sms-selected-text-color={darkMode ? '#312e81' : '#3730a3'}
bind:selected={
() => [...value],
(newVal) => {
if (!deepEqual(value, newVal)) {
value = newVal
}
}
}
{placeholder}
options={items}
on:close={() => {
open = false
}}
on:open={() => {
open = true
}}
let:option
disableRemoveAll
>
<div
class="w-full text-sm"
role="option"
tabindex="0"
onmouseup={(e) => {
e.stopPropagation()
}}
onpointerdown={(e) => {
e.stopPropagation()
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
aria-selected={value?.includes(option)}
>
{option}
</div>
</MultiSelect>
</div>
<Portal {target} name="multi-select">
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
onclick={(e) => {
e.stopPropagation()
}}
role="listbox"
tabindex="0"
></div>
</div>
</Portal>
{:else}
Value {value} is not an array
{/if}
</div>
@@ -1,47 +0,0 @@
export type Option = string | number | ObjectOption
// single CSS string or an object with keys 'option' and 'selected', each a string,
// which only apply to the dropdown list and list of selected options, respectively
export type OptionStyle = string | { option: string; selected: string }
export type ObjectOption = {
label: string | number // user-displayed text
value?: unknown // associated value, can be anything incl. objects (defaults to label if undefined)
title?: string // on-hover tooltip
disabled?: boolean // make this option unselectable
preselected?: boolean // make this option selected on page load (before any user interaction)
disabledTitle?: string // override the default disabledTitle = 'This option is disabled'
selectedTitle?: string // tooltip to display when this option is selected and hovered
style?: OptionStyle
[key: string]: unknown // allow any other keys users might want
}
export type DispatchEvents<T = Option> = {
add: { option: T }
create: { option: T }
remove: { option: T }
removeAll: { options: T[] }
change: {
option?: T
options?: T[]
type: 'add' | 'remove' | 'removeAll'
}
open: { event: Event }
close: { event: Event }
}
export type MultiSelectEvents = {
[key in keyof DispatchEvents]: CustomEvent<DispatchEvents[key]>
} & {
blur: FocusEvent
click: MouseEvent
focus: FocusEvent
keydown: KeyboardEvent
keyup: KeyboardEvent
mouseenter: MouseEvent
mouseleave: MouseEvent
touchcancel: TouchEvent
touchend: TouchEvent
touchmove: TouchEvent
touchstart: TouchEvent
}
@@ -1,64 +0,0 @@
import type { Option, OptionStyle } from './types'
// get the label key from an option object or the option itself if it's a string or number
export const get_label = (opt: Option) => {
if (opt instanceof Object) {
if (opt.label === undefined) {
console.error(`MultiSelect option ${JSON.stringify(opt)} is an object but has no label key`)
}
return opt.label
}
return `${opt}`
}
// this function is used extract CSS strings from a {selected, option} style object to be used in the style attribute of the option
// if the style is a string, it will be returned as is
export function get_style(
option: { style?: OptionStyle; [key: string]: unknown } | string | number,
key: 'selected' | 'option' | null = null
) {
let css_str = ``
if (![`selected`, `option`, null].includes(key)) {
console.error(`MultiSelect: Invalid key=${key} for get_style`)
}
if (typeof option == `object` && option.style) {
if (typeof option.style == `string`) {
css_str = option.style
}
if (typeof option.style == `object`) {
if (key && key in option.style) return option.style[key] ?? ``
else {
console.error(`Invalid style object for option=${JSON.stringify(option)}`)
}
}
}
// ensure css_str ends with a semicolon
if (css_str.trim() && !css_str.trim().endsWith(`;`)) css_str += `;`
return css_str
}
// Firefox lacks support for scrollIntoViewIfNeeded (https://caniuse.com/scrollintoviewifneeded).
// See https://github.com/janosh/svelte-multiselect/issues/87
// Polyfill copied from
// https://github.com/nuxodin/lazyfill/blob/a8e63/polyfills/Element/prototype/scrollIntoViewIfNeeded.js
// exported for testing
export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeeded: boolean = true) {
const observer = new IntersectionObserver(function ([entry]) {
const ratio = entry.intersectionRatio
if (ratio < 1) {
const place = ratio <= 0 && centerIfNeeded ? `center` : `nearest`
elem.scrollIntoView({
block: place,
inline: place
})
}
observer.disconnect()
}, {
root: null, // or specify a scrolling parent if needed
rootMargin: '0px 1000px', // Essentially making horizontal checks irrelevant
threshold: 0.1 // Adjust threshold to control when observer should trigger
})
observer.observe(elem)
return observer // return for testing
}
@@ -12,7 +12,8 @@
import { createEventDispatcher, untrack } from 'svelte'
import ToggleButtonMore from '../common/toggleButton-v2/ToggleButtonMore.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
interface Props {
// Filters
@@ -174,7 +175,7 @@
<div class="relative">
<span class="text-xs absolute -top-4">User</span>
<Select
items={usernames.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
@@ -190,7 +191,7 @@
<span class="text-xs absolute -top-4">Folder</span>
<Select
items={folders.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
@@ -203,7 +204,7 @@
<div class="relative">
<span class="text-xs absolute -top-4">Path</span>
<Select
items={paths.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
@@ -564,7 +565,7 @@
<Label label="User">
<Select
disablePortal
items={usernames.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
@@ -575,7 +576,7 @@
<Label label="Folder">
<Select
disablePortal
items={folders.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
@@ -586,7 +587,7 @@
<Label label="Path">
<Select
disablePortal
items={paths.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
@@ -8,7 +8,8 @@
import { Alert } from '../common'
import AddPropertyV2 from '$lib/components/schema/AddPropertyV2.svelte'
import { Plus } from 'lucide-svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
interface Props {
schema: Schema | undefined | any
@@ -146,7 +147,7 @@
File extension :
<Select
autofocus
items={suggestedFileExtensions.map((e) => ({ value: e, label: e }))}
items={safeSelectItems(suggestedFileExtensions)}
bind:value={formatExtension}
onCreateItem={(ext) => ((formatExtension = ext), suggestedFileExtensions.push(ext))}
/>
@@ -3,7 +3,7 @@
import Button from '../common/button/Button.svelte'
import { base } from '$lib/base'
let { aiId, aiDescription } = $props<{ aiId: string; aiDescription: string }>()
let { aiId, aiDescription }: { aiId: string; aiDescription: string } = $props()
</script>
<!-- Buttons -->
@@ -11,7 +11,7 @@
type Script,
type SearchJobsIndexResponse
} from '$lib/gen'
import { clickOutside, isMac } from '$lib/utils'
import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils'
import {
AlertTriangle,
BoxesIcon,
@@ -36,7 +36,6 @@
import { devopsRole, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import uFuzzy from '@leeoniya/ufuzzy'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import { scroll_into_view_if_needed_polyfill } from '../multiselect/utils'
import { Alert } from '../common'
import Popover from '../Popover.svelte'
import Logs from 'lucide-svelte/icons/logs'
@@ -0,0 +1,63 @@
<script lang="ts" generics="Item extends { label?: string; value: any }">
import { twMerge } from 'tailwind-merge'
import CloseButton from '../common/CloseButton.svelte'
type Props = {
items?: Item[]
allowClear?: boolean
onRemove: (item: Item) => void
onReorder?: (oldIndex: number, newIndex: number) => void
}
let { items, onRemove, onReorder, allowClear = true }: Props = $props()
let currentlyDraggingIndex: number | undefined = $state()
let dragPos = $state<[number, number]>([0, 0])
</script>
<svelte:window
onmousemove={(e) => {
if (currentlyDraggingIndex === undefined) return
dragPos = [dragPos[0] + e.movementX, dragPos[1] + e.movementY]
}}
onpointerup={() => {
currentlyDraggingIndex = undefined
dragPos = [0, 0]
}}
/>
{#each items ?? [] as item, index}
<li
role="listitem"
class={twMerge(
allowClear ? 'pr-1' : 'pr-3',
'pl-3 min-h-6 bg-surface-secondary rounded-full flex items-center gap-0.5',
currentlyDraggingIndex !== undefined ? 'hover:opacity-20' : ''
)}
style={currentlyDraggingIndex === index
? `transform: translate(${dragPos[0]}px, ${dragPos[1]}px); pointer-events: none;`
: ''}
draggable
onpointerdown={(e) => {
e.stopPropagation()
dragPos = [0, 0]
currentlyDraggingIndex = index
}}
onpointerup={(e) => {
if (currentlyDraggingIndex !== undefined) {
e.stopPropagation()
onReorder?.(currentlyDraggingIndex, index)
}
currentlyDraggingIndex = undefined
dragPos = [0, 0]
}}
>
<span class="text-sm select-none">{item.label || item.value}</span>
{#if allowClear}
<CloseButton
class="text-tertiary"
small
on:close={(e) => (onRemove(item), e.stopPropagation())}
/>
{/if}
</li>
{/each}

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