Feat v0.7.8 bug fixes admin gaps (#62)
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #62.
This commit is contained in:
63
ROADMAP.md
63
ROADMAP.md
@@ -268,15 +268,66 @@ sidecar auth (v0.10.x).
|
|||||||
| `gate_permission` manifest field | done | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
|
| `gate_permission` manifest field | done | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
|
||||||
| `req["permissions"]` in request dict | done | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
|
| `req["permissions"]` in request dict | done | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
|
||||||
|
|
||||||
### v0.7.8 — Deferred Test Coverage
|
### v0.7.8 — Bug Fixes & Admin Gaps
|
||||||
|
|
||||||
Remaining test gap items deferred from v0.7.6.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| store/ unit tests | | Direct tests for PG + SQLite store implementations. Priority: packages, ext_data, workflows. Target: ≥30% SLOC ratio. |
|
| Workflow landing page Start | done | Added `StartBySlug` handler + `/api/v1/workflow-entry/:scope/:slug` route. Landing page Start button now resolves scope/slug → workflow ID and creates instance. |
|
||||||
| InstallPackage decomposition | | 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. Each independently testable. |
|
| Workflow delete guard | done | Admin `DELETE /api/v1/workflows/:id` rejects team-scoped workflows (403). Team workflows must use team endpoint. Prevents accidental global template deletion. |
|
||||||
| workflow/ engine integration tests | | Engine lifecycle tests with mock stores (Start, Advance, Cancel, signoff gate). |
|
| Package button cleanup | done | Delete button hidden for `bundled` packages via `IMMUTABLE_SOURCES` guard, matching Export/Update. |
|
||||||
|
| Package export fetch | done | Export uses `fetch()` with auth token instead of `window.open()`. Handles errors, shows toast on missing assets. |
|
||||||
|
| Settings CSS fix | done | Bottom padding increased to `--sp-12` on `.admin-settings-form`. Save button no longer cut off. |
|
||||||
|
| Admin stage builder + links | done | Shared `StageForm` component between admin and team-admin. Public entry URL with copy button in admin workflow editor. |
|
||||||
|
|
||||||
|
### v0.7.9 — Workflow Independence Audit
|
||||||
|
|
||||||
|
Workflows must work end-to-end without chat or any 3rd-party package
|
||||||
|
dependency. Chat, notifications, and other packages should *enhance*
|
||||||
|
workflows but never be required for core operation.
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. |
|
||||||
|
| Stage mode degradation | | All stage modes (`form_only`, `form_chat`, `review`) degrade gracefully when optional packages missing. `form_chat` without chat falls back to form-only. |
|
||||||
|
| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. |
|
||||||
|
| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. |
|
||||||
|
| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. |
|
||||||
|
| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. |
|
||||||
|
|
||||||
|
### v0.7.10 — Query & HTTP Ergonomics
|
||||||
|
|
||||||
|
Four additions to existing sandbox modules. No new files beyond tests,
|
||||||
|
no schema changes. Motivated by real-world extension porting feedback:
|
||||||
|
complex SQL decomposition and synchronous HTTP fan-out are the two
|
||||||
|
remaining friction points for extensions migrating from native Go/Python.
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `db.count()` | | `db.count(table, filters={})` → int. `SELECT count(*) FROM ext_{pkg}_{table} WHERE ...`. Uses existing `starlarkFiltersToSQL` builder. Permission: `db.read`. ~40 lines in `db_module.go`. |
|
||||||
|
| `db.aggregate()` | | `db.aggregate(table, column, op, filters={})` → single value. `op` ∈ {`count`, `sum`, `avg`, `min`, `max`}. Column name validated same as filter keys. Returns int/float/None. Permission: `db.read`. ~60 lines in `db_module.go`. |
|
||||||
|
| `db.query_batch()` | | `db.query_batch(queries)` → list of result sets. Each query spec is a dict with `table` (required), `filters`, `order`, `limit`, `before`, `after`, `search_like` (all optional, same as `db.query`). Loops existing query builder internally — one Starlark↔Go boundary crossing instead of N. Permission: `db.read`. ~30 lines in `db_module.go`. |
|
||||||
|
| `http.batch()` | | `http.batch(requests)` → list of response dicts (ordered). Each request dict: `method`, `url`, `body`, `headers`. Concurrent dispatch via goroutines capped at 10, `sync.WaitGroup` collection. Reuses existing `httpDo` plumbing (SSRF checks, domain allowlist, body cap, timeout). Individual failures return error response dicts, don't abort batch. Permission: `api.http`. ~80 lines in `http_module.go`. |
|
||||||
|
| Tests | | Unit tests for all four builtins. `db.count`/`db.aggregate` test empty table, filtered, type coercion. `db.query_batch` test mixed specs, empty list. `http.batch` test concurrent execution, partial failure, ordering. PG + SQLite for db tests. |
|
||||||
|
|
||||||
|
### v0.7.11 — Concurrent Execution Primitive
|
||||||
|
|
||||||
|
New `batch` sandbox module. Enables extensions to parallelize arbitrary
|
||||||
|
Starlark callables — including library function calls via `lib.require()`.
|
||||||
|
The kernel manages concurrency (multiple `starlark.Thread` instances in
|
||||||
|
goroutines); extensions stay single-threaded and synchronous from the
|
||||||
|
author's perspective.
|
||||||
|
|
||||||
|
Key insight: library exports loaded via `lib.require()` are frozen structs
|
||||||
|
(immutable, safe to share across threads). Each concurrent branch gets
|
||||||
|
fresh module instances (`db`, `http`, etc.) — same pattern as
|
||||||
|
`buildRestrictedModules` in `triggers/schedule.go`.
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| Design doc | | `docs/DESIGN-batch-exec.md`. Thread isolation model, module construction, error semantics (partial results on failure), permission model, concurrency cap. |
|
||||||
|
| `batch.exec()` | | `batch.exec(callables)` → `(results, errors)` tuple. List of Starlark callables (lambdas, function refs). Each runs in its own `starlark.Thread` with fresh modules. Concurrent goroutines capped at 8. Ordered results. Errors are per-callable (None on success, string on failure). Permission: `batch.exec` (new). New file: `sandbox/batch_module.go`. |
|
||||||
|
| Runner wiring | | `buildModulesWithLibCtx` creates `batch` module when `batch.exec` permission granted. Module receives runner reference for per-branch module construction. |
|
||||||
|
| Tests | | Unit tests: parallel execution ordering, partial failure, cap enforcement, frozen library sharing, permission gating. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1183,9 +1183,16 @@ func (h *PackageHandler) ExportPackage(c *gin.Context) {
|
|||||||
|
|
||||||
// Walk packagesDir/{id}/ and add all asset files
|
// Walk packagesDir/{id}/ and add all asset files
|
||||||
if h.packagesDir == "" {
|
if h.packagesDir == "" {
|
||||||
|
log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID)
|
||||||
|
c.Header("X-Export-Warning", "no-assets")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pkgDir := filepath.Join(h.packagesDir, pkgID)
|
pkgDir := filepath.Join(h.packagesDir, pkgID)
|
||||||
|
if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) {
|
||||||
|
log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID)
|
||||||
|
c.Header("X-Export-Warning", "no-assets")
|
||||||
|
return
|
||||||
|
}
|
||||||
filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -36,6 +36,51 @@ type publicInstanceResponse struct {
|
|||||||
UpdatedAt any `json:"updated_at"`
|
UpdatedAt any `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartBySlug resolves a scope+slug to a workflow and starts a public instance.
|
||||||
|
// POST /api/v1/workflow-entry/:scope/:slug
|
||||||
|
// Called by the workflow landing page (/w/:scope/:slug).
|
||||||
|
func (h *WorkflowPublicHandler) StartBySlug(c *gin.Context) {
|
||||||
|
scope := c.Param("scope")
|
||||||
|
slug := c.Param("slug")
|
||||||
|
|
||||||
|
var teamID *string
|
||||||
|
if scope != "global" {
|
||||||
|
teamID = &scope
|
||||||
|
}
|
||||||
|
|
||||||
|
wf, err := h.stores.Workflows.GetBySlug(c.Request.Context(), teamID, slug)
|
||||||
|
if err != nil || wf == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
|
||||||
|
body.Data = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if len(body.Data) == 0 {
|
||||||
|
body.Data = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
inst, err := h.engine.StartPublic(c.Request.Context(), wf.ID, body.Data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return redirect for the landing page JS
|
||||||
|
redirectTo := "/w/" + inst.ID
|
||||||
|
if inst.EntryToken != nil {
|
||||||
|
redirectTo = "/w/" + inst.ID + "?token=" + *inst.EntryToken
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": inst.ID,
|
||||||
|
"redirect_to": redirectTo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StartPublic creates an anonymous workflow instance.
|
// StartPublic creates an anonymous workflow instance.
|
||||||
// POST /api/v1/public/workflows/:id/start
|
// POST /api/v1/public/workflows/:id/start
|
||||||
func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) {
|
func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Team-Scoped Workflow Wrappers ────────────────
|
// ── Team-Scoped Workflow Wrappers ────────────────
|
||||||
@@ -39,13 +42,15 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
|||||||
|
|
||||||
// ── Adopt Global Workflow ────────────────────
|
// ── Adopt Global Workflow ────────────────────
|
||||||
|
|
||||||
// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team.
|
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
||||||
|
// The global original is left untouched so other teams can also adopt it.
|
||||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
wfID := c.Param("id")
|
srcID := c.Param("id")
|
||||||
|
|
||||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
src, err := h.stores.Workflows.GetByID(ctx, srcID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
@@ -54,20 +59,78 @@ func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if w.TeamID != nil {
|
if src.TeamID != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
|
c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
patch := models.WorkflowPatch{TeamID: &teamID}
|
// Load stages from the global workflow
|
||||||
if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil {
|
stages, err := h.stores.Workflows.ListStages(ctx, srcID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"})
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the updated workflow
|
// Clone the workflow into this team (global original stays untouched)
|
||||||
c.Set("id", wfID)
|
clone := &models.Workflow{
|
||||||
h.Get(c)
|
TeamID: &teamID,
|
||||||
|
Name: src.Name,
|
||||||
|
Slug: src.Slug,
|
||||||
|
Description: src.Description,
|
||||||
|
Branding: src.Branding,
|
||||||
|
EntryMode: src.EntryMode,
|
||||||
|
IsActive: false,
|
||||||
|
Version: 0,
|
||||||
|
OnComplete: src.OnComplete,
|
||||||
|
Retention: src.Retention,
|
||||||
|
WebhookURL: src.WebhookURL,
|
||||||
|
WebhookSecret: src.WebhookSecret,
|
||||||
|
StalenessTimeoutHours: src.StalenessTimeoutHours,
|
||||||
|
CreatedBy: c.GetString("user_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Workflows.Create(ctx, clone); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
clone.Slug = src.Slug + "-" + store.NewID()[:6]
|
||||||
|
if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err2.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone stages
|
||||||
|
for _, st := range stages {
|
||||||
|
cloneSt := &models.WorkflowStage{
|
||||||
|
WorkflowID: clone.ID,
|
||||||
|
Ordinal: st.Ordinal,
|
||||||
|
Name: st.Name,
|
||||||
|
AssignmentTeamID: st.AssignmentTeamID,
|
||||||
|
FormTemplate: st.FormTemplate,
|
||||||
|
StageMode: st.StageMode,
|
||||||
|
Audience: st.Audience,
|
||||||
|
StageType: st.StageType,
|
||||||
|
AutoTransition: st.AutoTransition,
|
||||||
|
StageConfig: st.StageConfig,
|
||||||
|
BranchRules: st.BranchRules,
|
||||||
|
StarlarkHook: st.StarlarkHook,
|
||||||
|
SurfacePkgID: st.SurfacePkgID,
|
||||||
|
SLASeconds: st.SLASeconds,
|
||||||
|
}
|
||||||
|
if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil {
|
||||||
|
log.Printf("[workflows] adopt: failed to clone stage %s: %v", st.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID)
|
||||||
|
if clonedStages == nil {
|
||||||
|
clonedStages = []models.WorkflowStage{}
|
||||||
|
}
|
||||||
|
clone.Stages = clonedStages
|
||||||
|
c.JSON(http.StatusCreated, clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListGlobalWorkflows returns unowned workflows available for adoption.
|
// ListGlobalWorkflows returns unowned workflows available for adoption.
|
||||||
|
|||||||
@@ -149,8 +149,24 @@ func (h *WorkflowHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
// Delete deletes a workflow and all its stages/versions (CASCADE).
|
// Delete deletes a workflow and all its stages/versions (CASCADE).
|
||||||
// DELETE /api/v1/workflows/:id
|
// DELETE /api/v1/workflows/:id
|
||||||
|
// Admin-only: only allows deleting global (team_id IS NULL) workflows.
|
||||||
|
// Team-scoped workflows must be deleted via the team endpoint.
|
||||||
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
||||||
if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
ctx := c.Request.Context()
|
||||||
|
wfID := c.Param("id")
|
||||||
|
|
||||||
|
// Guard: prevent accidental deletion of team-scoped workflows from the admin endpoint
|
||||||
|
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wf.TeamID != nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "team-scoped workflows must be deleted via the team endpoint"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Workflows.Delete(ctx, wfID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ func main() {
|
|||||||
publicWf.POST("/advance/:token", publicWfH.AdvancePublic)
|
publicWf.POST("/advance/:token", publicWfH.AdvancePublic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Landing-page entry: POST /api/v1/workflow-entry/:scope/:slug
|
||||||
|
// Resolves scope+slug → workflow ID, starts instance, returns redirect.
|
||||||
|
api.POST("/workflow-entry/:scope/:slug", authLimiter.Limit(), publicWfH.StartBySlug)
|
||||||
|
|
||||||
// ── Workflow Scanner ──────────
|
// ── Workflow Scanner ──────────
|
||||||
wfScanner := workflow.NewScanner(stores, bus)
|
wfScanner := workflow.NewScanner(stores, bus)
|
||||||
wfScanner.Start()
|
wfScanner.Start()
|
||||||
|
|||||||
@@ -238,7 +238,7 @@
|
|||||||
.bar-chart-label { font-size: 10px; color: var(--text-3); }
|
.bar-chart-label { font-size: 10px; color: var(--text-3); }
|
||||||
|
|
||||||
/* ── Admin Settings Form (flat sections, prototype match) ── */
|
/* ── Admin Settings Form (flat sections, prototype match) ── */
|
||||||
.admin-settings-form { max-width: 600px; }
|
.admin-settings-form { max-width: 600px; padding-bottom: var(--sp-12); }
|
||||||
.admin-settings-form .settings-section {
|
.admin-settings-form .settings-section {
|
||||||
background: none; border: none; border-radius: 0;
|
background: none; border: none; border-radius: 0;
|
||||||
padding: 0 0 16px; margin-bottom: 20px;
|
padding: 0 0 16px; margin-bottom: 20px;
|
||||||
|
|||||||
177
src/js/sw/components/stage-form.js
Normal file
177
src/js/sw/components/stage-form.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Shared Stage Form Component
|
||||||
|
*
|
||||||
|
* Used by both admin/workflows.js and team-admin/workflow-editor.js
|
||||||
|
* for creating and editing workflow stages.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* stage - existing stage object (null for new)
|
||||||
|
* teams - array of { id, name } for team assignment dropdown
|
||||||
|
* onSave - (data) => void — called with stage payload
|
||||||
|
* onCancel - () => void
|
||||||
|
*/
|
||||||
|
const { html } = window;
|
||||||
|
const { useState, useEffect } = hooks;
|
||||||
|
|
||||||
|
export const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||||
|
export const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||||
|
export const AUDIENCES = ['team', 'public', 'system'];
|
||||||
|
|
||||||
|
export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||||
|
const [name, setName] = useState(stage?.name || '');
|
||||||
|
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||||
|
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||||
|
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||||
|
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||||
|
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||||
|
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||||
|
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||||
|
const [branchRules, setBranchRules] = useState(
|
||||||
|
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||||
|
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
||||||
|
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
||||||
|
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
||||||
|
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
||||||
|
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assignTeam) return;
|
||||||
|
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
||||||
|
}, [assignTeam]);
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const stageConfig = {};
|
||||||
|
if (requiredRole) stageConfig.required_role = requiredRole;
|
||||||
|
if (valApprovals) {
|
||||||
|
stageConfig.validation = {
|
||||||
|
required_approvals: parseInt(valApprovals, 10),
|
||||||
|
...(valRole ? { required_role: valRole } : {}),
|
||||||
|
reject_action: valReject || 'cancel',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let parsedBranch = null;
|
||||||
|
if (branchRules.trim()) {
|
||||||
|
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||||
|
}
|
||||||
|
onSave({
|
||||||
|
name,
|
||||||
|
stage_mode: mode,
|
||||||
|
audience,
|
||||||
|
stage_type: stageType,
|
||||||
|
starlark_hook: starlarkHook || null,
|
||||||
|
assignment_team_id: assignTeam || null,
|
||||||
|
auto_transition: autoTransition,
|
||||||
|
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||||
|
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||||
|
branch_rules: parsedBranch || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input value=${name} onInput=${e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select value=${mode} onChange=${e => setMode(e.target.value)}>
|
||||||
|
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Audience</label>
|
||||||
|
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||||
|
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Stage Type</label>
|
||||||
|
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||||
|
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${stageType !== 'simple' && html`
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:1;">
|
||||||
|
<label>Starlark Hook</label>
|
||||||
|
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||||
|
placeholder="package_id:entry_point" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Queue to Team</label>
|
||||||
|
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
|
||||||
|
<option value="">\u2014 none (visitor stage) \u2014</option>
|
||||||
|
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SLA (seconds)</label>
|
||||||
|
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${assignTeam && html`
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Required Role (claim)</label>
|
||||||
|
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||||
|
<option value="">\u2014 any member \u2014</option>
|
||||||
|
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||||
|
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||||
|
<div class="form-row" style="margin-top:4px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Required Approvals</label>
|
||||||
|
<input type="number" min="0" value=${valApprovals}
|
||||||
|
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Signoff Role</label>
|
||||||
|
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||||
|
<option value="">\u2014 any member \u2014</option>
|
||||||
|
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>On Reject</label>
|
||||||
|
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||||
|
<option value="cancel">Cancel instance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<details style="margin-top:8px;">
|
||||||
|
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||||
|
<div class="form-group" style="margin-top:4px;">
|
||||||
|
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||||
|
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||||
|
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
|
||||||
|
<span class="toggle-track"></span><span>Auto-advance when complete</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||||
|
<button type="button" class="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
||||||
|
${stage ? 'Update' : 'Add Stage'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -146,7 +146,11 @@ export function createDomains(restClient) {
|
|||||||
workflows: {
|
workflows: {
|
||||||
...crud(rc, '/api/v1/workflows'),
|
...crud(rc, '/api/v1/workflows'),
|
||||||
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
|
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
|
||||||
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
|
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
|
||||||
|
createStage: (id, data) => rc.post(`/api/v1/workflows/${id}/stages`, data),
|
||||||
|
updateStage: (id, sid, data) => rc.put(`/api/v1/workflows/${id}/stages/${sid}`, data),
|
||||||
|
deleteStage: (id, sid) => rc.del(`/api/v1/workflows/${id}/stages/${sid}`),
|
||||||
|
reorderStages: (id, ids) => rc.patch(`/api/v1/workflows/${id}/stages/reorder`, { ordered_ids: ids }),
|
||||||
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
||||||
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
||||||
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Dropdown } from '../../primitives/dropdown.js';
|
|||||||
|
|
||||||
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow', 'library'];
|
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow', 'library'];
|
||||||
const CORE_IDS = new Set(['admin']);
|
const CORE_IDS = new Set(['admin']);
|
||||||
|
const IMMUTABLE_SOURCES = new Set(['core', 'bundled']);
|
||||||
|
|
||||||
function typeBadge(type) {
|
function typeBadge(type) {
|
||||||
const cls = type === 'surface' ? 'badge-active'
|
const cls = type === 'surface' ? 'badge-active'
|
||||||
@@ -115,8 +116,30 @@ export default function PackagesSection() {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPkg(id) {
|
async function exportPkg(id) {
|
||||||
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
|
try {
|
||||||
|
const resp = await fetch(`${BASE}/api/v1/admin/packages/${id}/export`, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + (sw.auth._getToken() || '') },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
sw.toast(err.error || 'Export failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const warning = resp.headers.get('X-Export-Warning');
|
||||||
|
if (warning === 'no-assets') {
|
||||||
|
sw.toast('Export contains manifest only — asset files are missing', 'warn');
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = resp.headers.get('Content-Disposition')?.match(/filename="?([^"]+)"?/)?.[1] || `${id}.pkg`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) { sw.toast('Export failed: ' + e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePkg(pkg) {
|
function updatePkg(pkg) {
|
||||||
@@ -302,11 +325,13 @@ export default function PackagesSection() {
|
|||||||
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
||||||
</button>
|
</button>
|
||||||
`}
|
`}
|
||||||
${pkg.source !== 'core' && html`
|
${!IMMUTABLE_SOURCES.has(pkg.source) && html`
|
||||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => updatePkg(pkg)}>Update</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => updatePkg(pkg)}>Update</button>
|
||||||
`}
|
`}
|
||||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
${!IMMUTABLE_SOURCES.has(pkg.source) && html`
|
||||||
${pkg.source !== 'core' && !pkg.is_system && html`
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
||||||
|
`}
|
||||||
|
${!IMMUTABLE_SOURCES.has(pkg.source) && !pkg.is_system && html`
|
||||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deletePkg(pkg)}>Delete</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deletePkg(pkg)}>Delete</button>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
*/
|
*/
|
||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect, useCallback } = hooks;
|
const { useState, useEffect, useCallback } = hooks;
|
||||||
|
import { StageForm } from '../../components/stage-form.js';
|
||||||
|
|
||||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
|
||||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||||
|
|
||||||
export default function WorkflowsSection() {
|
export default function WorkflowsSection() {
|
||||||
const [workflows, setWorkflows] = useState([]);
|
const [workflows, setWorkflows] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState(null); // null | 'new' | workflow
|
const [editing, setEditing] = useState(null); // null | workflow
|
||||||
const [stages, setStages] = useState([]);
|
const [stages, setStages] = useState([]);
|
||||||
|
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
@@ -28,12 +30,21 @@ export default function WorkflowsSection() {
|
|||||||
|
|
||||||
async function openEdit(wf) {
|
async function openEdit(wf) {
|
||||||
setEditing(wf);
|
setEditing(wf);
|
||||||
|
setEditingStage(null);
|
||||||
try {
|
try {
|
||||||
const detail = await sw.api.workflows.get(wf.id);
|
const detail = await sw.api.workflows.get(wf.id);
|
||||||
setStages(detail.stages || []);
|
setStages(detail.stages || []);
|
||||||
|
setTeams(sw.auth?.teams || []);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStages() {
|
||||||
|
try {
|
||||||
|
const s = await sw.api.workflows.stages(editing.id);
|
||||||
|
setStages(s || []);
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
async function createWorkflow(e) {
|
async function createWorkflow(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
@@ -77,6 +88,32 @@ export default function WorkflowsSection() {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage CRUD ──────────────────────────
|
||||||
|
async function addStage(data) {
|
||||||
|
try {
|
||||||
|
await sw.api.workflows.createStage(editing.id, data);
|
||||||
|
setEditingStage(null);
|
||||||
|
loadStages();
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStage(stageId, data) {
|
||||||
|
try {
|
||||||
|
await sw.api.workflows.updateStage(editing.id, stageId, data);
|
||||||
|
setEditingStage(null);
|
||||||
|
loadStages();
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStage(stageId) {
|
||||||
|
const ok = await sw.confirm('Delete this stage?', true);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await sw.api.workflows.deleteStage(editing.id, stageId);
|
||||||
|
loadStages();
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||||
|
|
||||||
if (error) return html`
|
if (error) return html`
|
||||||
@@ -108,23 +145,63 @@ export default function WorkflowsSection() {
|
|||||||
<span class="toggle-track"></span><span>Active</span>
|
<span class="toggle-track"></span><span>Active</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
|
${editing.entry_mode === 'public_link' && html`
|
||||||
<div class="admin-list">
|
<div style="margin:8px 0;">
|
||||||
${stages.map((s, i) => html`
|
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||||
<div class="admin-surface-row" key=${s.id}>
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
<a href=${(window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '')}
|
||||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
target="_blank" rel="noopener"
|
||||||
<span class="badge">${s.stage_mode}</span>
|
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--accent);font-family:var(--mono);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">
|
||||||
<span class="badge">${s.audience || 'team'}</span>
|
${window.location.origin + (window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '')}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||||
|
const url = window.location.origin + (window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '');
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
e.target.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
}}>Copy</button>
|
||||||
</div>
|
</div>
|
||||||
`)}
|
</div>
|
||||||
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
`}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||||
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Save</button>
|
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Stage Editor -->
|
||||||
|
<div style="margin-top:24px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||||
|
<h5 style="margin:0;">Stages (${stages.length})</h5>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage('new')}>+ Add Stage</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-list">
|
||||||
|
${stages.map((s, i) => html`
|
||||||
|
<div class="admin-surface-row" key=${s.id || i}>
|
||||||
|
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||||
|
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||||
|
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||||
|
<span class="badge">${s.audience || 'team'}</span>
|
||||||
|
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||||
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||||
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${editingStage && html`
|
||||||
|
<${StageForm}
|
||||||
|
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
|
||||||
|
teams=${teams}
|
||||||
|
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||||
|
onCancel=${() => setEditingStage(null)}
|
||||||
|
/>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect, useCallback } = hooks;
|
const { useState, useEffect, useCallback } = hooks;
|
||||||
|
import { StageForm } from '../../components/stage-form.js';
|
||||||
|
|
||||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
|
||||||
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
|
||||||
const AUDIENCES = ['team', 'public', 'system'];
|
|
||||||
|
|
||||||
// ── Workflow Editor (with Stage Editor) ─────
|
// ── Workflow Editor (with Stage Editor) ─────
|
||||||
|
|
||||||
@@ -121,11 +119,13 @@ export function WorkflowEditor({ teamId, workflow, onBack }) {
|
|||||||
<div style="margin:8px 0;">
|
<div style="margin:8px 0;">
|
||||||
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||||
<div style="display:flex;gap:6px;align-items:center;">
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
<a href=${(window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '')}
|
||||||
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
target="_blank" rel="noopener"
|
||||||
onClick=${e => e.target.select()} />
|
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--accent);font-family:var(--mono);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">
|
||||||
|
${window.location.origin + (window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '')}
|
||||||
|
</a>
|
||||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||||
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
const url = window.location.origin + (window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '');
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
e.target.textContent = 'Copied!';
|
e.target.textContent = 'Copied!';
|
||||||
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||||
@@ -175,163 +175,3 @@ export function WorkflowEditor({ teamId, workflow, onBack }) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage Form ──────────────────────────────
|
|
||||||
|
|
||||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
|
||||||
const [name, setName] = useState(stage?.name || '');
|
|
||||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
|
||||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
|
||||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
|
||||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
|
||||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
|
||||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
|
||||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
|
||||||
const [branchRules, setBranchRules] = useState(
|
|
||||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
|
||||||
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
|
||||||
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
|
||||||
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
|
||||||
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
|
||||||
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!assignTeam) return;
|
|
||||||
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
|
||||||
}, [assignTeam]);
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const stageConfig = {};
|
|
||||||
if (requiredRole) stageConfig.required_role = requiredRole;
|
|
||||||
if (valApprovals) {
|
|
||||||
stageConfig.validation = {
|
|
||||||
required_approvals: parseInt(valApprovals, 10),
|
|
||||||
...(valRole ? { required_role: valRole } : {}),
|
|
||||||
reject_action: valReject || 'cancel',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let parsedBranch = null;
|
|
||||||
if (branchRules.trim()) {
|
|
||||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
|
||||||
}
|
|
||||||
onSave({
|
|
||||||
name,
|
|
||||||
stage_mode: mode,
|
|
||||||
audience,
|
|
||||||
stage_type: stageType,
|
|
||||||
starlark_hook: starlarkHook || null,
|
|
||||||
assignment_team_id: assignTeam || null,
|
|
||||||
auto_transition: autoTransition,
|
|
||||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
|
||||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
|
||||||
branch_rules: parsedBranch || [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Name</label>
|
|
||||||
<input value=${name} onInput=${e => setName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Mode</label>
|
|
||||||
<select value=${mode} onChange=${e => setMode(e.target.value)}>
|
|
||||||
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Audience</label>
|
|
||||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
|
||||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Stage Type</label>
|
|
||||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
|
||||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${stageType !== 'simple' && html`
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group" style="flex:1;">
|
|
||||||
<label>Starlark Hook</label>
|
|
||||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
|
||||||
placeholder="package_id:entry_point" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Queue to Team</label>
|
|
||||||
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
|
|
||||||
<option value="">\u2014 none (visitor stage) \u2014</option>
|
|
||||||
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>SLA (seconds)</label>
|
|
||||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${assignTeam && html`
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Required Role (claim)</label>
|
|
||||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
|
||||||
<option value="">\u2014 any member \u2014</option>
|
|
||||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
|
||||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
|
||||||
<div class="form-row" style="margin-top:4px;">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Required Approvals</label>
|
|
||||||
<input type="number" min="0" value=${valApprovals}
|
|
||||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Signoff Role</label>
|
|
||||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
|
||||||
<option value="">\u2014 any member \u2014</option>
|
|
||||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>On Reject</label>
|
|
||||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
|
||||||
<option value="cancel">Cancel instance</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
<details style="margin-top:8px;">
|
|
||||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
|
||||||
<div class="form-group" style="margin-top:4px;">
|
|
||||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
|
||||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
|
||||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<label class="toggle-label">
|
|
||||||
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
|
|
||||||
<span class="toggle-track"></span><span>Auto-advance when complete</span>
|
|
||||||
</label>
|
|
||||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
|
||||||
<button type="button" class="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
|
||||||
${stage ? 'Update' : 'Add Stage'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user