Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.5.9

- Select existing workspace categories or add a new category in form settings.
- Export portable form JSON from the list or editor. Import validated files as new, unsaved drafts using the destination workspace’s access defaults. Existing forms, submissions, and connection settings are preserved.

## 0.5.8

- Replace the checkbox default text input with a Checked / Unchecked selector so both boolean defaults can be set reliably.

## 0.5.7

- Make catalog Open form controls compact buttons with keyboard focus styling. In dark mode, use a dark iridescent fill that lights up on hover or keyboard focus, with a shine animation on hover.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ ActionStack is an AI-driven project attempting to build something useful for the
- Automation catalog with search, categories, favorites, and pagination.
- Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections.
- Drafts, publishing, version history, cloning, and recoverable form deletion.
- Reusable workspace categories and JSON form export/import.
- Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks.
- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data.
- Optional receipt timeline and JSON receipt / CSV submission exports for audit review.
Expand All @@ -33,6 +34,14 @@ Open ActionStack as a Splunk administrator. The setup wizard creates the first w

See [Deployment](DEPLOYMENT.md) for configuration and permissions, and the [SOAR event contract](SOAR_EVENT_CONTRACT.md) for submitted fields.

## Moving forms between workspaces or instances

Use **Export** in the form list, or **Export form** in the editor, to download a JSON definition. The editor export includes unsaved edits. Exports include fields, defaults, validation, conditional sections, appearance, category, lookup configuration, and SOAR mapping. They exclude source workspace permissions, form IDs, version history, submissions, and connection credentials.

Choose **Import form**, select the JSON file (one form, up to 200 KB), and choose a destination workspace. ActionStack validates the file and opens a new, unsaved draft with a new ID and the destination workspace's default access. Review SOAR labels and lookup sources before saving or publishing; they must exist on the destination instance. Importing does not overwrite an existing form or create a SOAR event.

In the editor's **Settings** tab, **Category** lists categories used by accessible forms in the current workspace. Choose **Add a new category…** to create another; it becomes reusable when the form is saved.

## Development

Requires Node.js 22 and Python 3.9 or 3.13.
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/CategoryPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from "react";

export function CategoryPicker({
value,
categories,
onChange,
}: {
value: string;
categories: string[];
onChange: (value: string) => void;
}) {
const [adding, setAdding] = useState(false);
const choices = Array.from(
new Set(
["General", ...categories, value].map((c) => c.trim()).filter(Boolean),
),
).sort((a, b) => a.localeCompare(b));
return (
<div>
<label className="field">
Category
<select
value={adding ? "new" : "category:" + (value.trim() || "General")}
onChange={(e) => {
if (e.target.value === "new") {
setAdding(true);
onChange("");
} else {
setAdding(false);
onChange(e.target.value.slice(9));
}
}}
>
{choices.map((c) => (
<option key={c} value={"category:" + c}>
{c}
</option>
))}
<option value="new">Add a new category…</option>
</select>
</label>
{adding && (
<label className="field">
New category
<input
autoFocus
maxLength={80}
value={value}
placeholder="e.g. Identity and access"
onChange={(e) => onChange(e.target.value)}
/>
<small>Available on other forms after you save this form.</small>
</label>
)}
</div>
);
}
117 changes: 117 additions & 0 deletions frontend/src/ImportForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useState } from "react";
import { api } from "./api";
import type { Form, Workspace } from "./types";
import { MAX_FORM_FILE_BYTES, parseFormFile } from "./form-transfer.js";

export function ImportForm({
workspaces,
workspaceId,
onImported,
onCancel,
}: {
workspaces: Workspace[];
workspaceId: string;
onImported: (form: Form) => void;
onCancel: () => void;
}) {
const [destination, setDestination] = useState(
workspaces.some((w) => w.id === workspaceId)
? workspaceId
: workspaces[0]?.id || "",
);
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
if (!file || !destination) return;
setBusy(true);
setError("");
try {
if (file.size > MAX_FORM_FILE_BYTES)
throw new Error(
"Choose an ActionStack form JSON file under 200 KB.",
);
const document = parseFormFile(await file.text());
const form = await api<Form>("/admin/forms/import", {
document,
workspace_id: destination,
});
onImported(form);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}}
>
<div className="modal-body">
<p className="muted">
Open an exported form as a new draft. Review its settings, then save
or publish.
</p>
<label className="field">
Form JSON file
<input
type="file"
accept=".json,application/json"
required
disabled={busy}
onChange={(e) => {
setFile(e.target.files?.[0] || null);
setError("");
}}
/>
<small>One ActionStack form export, up to 200 KB.</small>
</label>
<label className="field">
Destination workspace
<select
required
value={destination}
disabled={busy}
onChange={(e) => setDestination(e.target.value)}
>
{!workspaces.length && (
<option value="">No workspace available</option>
)}
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</label>
<p className="muted">
The workspace’s default access applies. SOAR mapping and lookup
searches are included; check that their labels, apps, and lookups
exist here. Existing forms are kept.
</p>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
</div>
<div className="modal-actions">
<button
type="button"
className="button"
disabled={busy}
onClick={onCancel}
>
Cancel
</button>
<button
type="submit"
className="button primary"
disabled={busy || !file || !destination}
>
{busy ? "Checking form…" : "Open imported draft"}
</button>
</div>
</form>
);
}
40 changes: 40 additions & 0 deletions frontend/src/form-transfer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { cloneDefinition } from "./browser-compat.js";

export const MAX_FORM_FILE_BYTES = 200 * 1024;

/** Export a portable definition, excluding environment-specific access and history.
* @param {import('./types').Form} form @param {string} appVersion */
export function formExport(form, appVersion) {
const { title, description, category, icon, accent, intro, fields, mapping } =
form;
return {
format: "actionstack-form",
format_version: 1,
app_version: appVersion,
form: cloneDefinition({
title,
description,
category,
icon,
accent,
intro,
fields,
mapping,
}),
};
}

/** @param {string} text @returns {unknown} */
export function parseFormFile(text) {
if (new TextEncoder().encode(text).length > MAX_FORM_FILE_BYTES)
throw new Error("Choose an ActionStack form JSON file under 200 KB.");
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error("This file is not valid JSON.");
}
if (!data || data.format !== "actionstack-form" || data.format_version !== 1)
throw new Error("Choose an ActionStack form export (format version 1).");
return data;
}
Loading
Loading