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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "greentic-setup"
version = "0.5.7"
version = "0.5.9"
edition = "2024"
license = "MIT"
rust-version = "1.95"
Expand Down
427 changes: 421 additions & 6 deletions assets/setup-ui/app.js

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions assets/setup-ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
body{font-family:var(--font);background:var(--bg);color:var(--fg);min-height:100dvh;display:flex;justify-content:center;padding:3rem 1rem}
.container{width:100%;max-width:620px}
/* Wider container when the form contains a table (kind: List) so 5–7
columns of inputs aren't squeezed. JS toggles this class on `<body>`
after rendering a question that includes `list_columns`. */
body.has-wide-form .container{max-width:1380px}
.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.row-table{min-width:max-content;table-layout:auto}

/* ── Animation ── */
.fade-in{animation:fadeIn .25s ease}
Expand Down Expand Up @@ -269,3 +275,53 @@ select{cursor:pointer;appearance:none;
.tunnel-option:hover{border-color:var(--primary);background:rgba(37,195,158,.05)}
.tunnel-option.selected{border-color:var(--primary);background:rgba(37,195,158,.1)}
.tunnel-option input[type="radio"]{margin-top:.25rem;accent-color:var(--primary)}

/* ── Table-style (kind: List) repeating-row input ── */
.table-wrap{margin-top:.5rem}
.row-table{width:100%;border-collapse:separate;border-spacing:0;margin-bottom:.5rem;font-size:.8125rem}
.row-table thead th{font-size:.8125rem;font-weight:500;text-align:left;padding:.4rem .5rem;color:var(--muted);border-bottom:1px solid var(--border);white-space:nowrap}
.row-table thead th .required{margin-left:.2rem;color:#dc2626}
.row-table tbody td{padding:.4rem .35rem;vertical-align:top;min-width:140px}
.row-table thead th[data-multilingual="true"],
.row-table tbody td[data-multilingual="true"]{min-width:260px}
.row-table thead th[data-boolean="true"],
.row-table tbody td[data-boolean="true"]{min-width:80px;width:80px}
.row-table thead th[data-col="num"],
.row-table tbody td[data-col="num"]{min-width:90px;width:100px}
.row-table thead th[data-col="url"],
.row-table tbody td[data-col="url"]{min-width:200px}
.row-table tbody tr:not(:last-child) td{border-bottom:1px solid var(--border)}
.row-table tbody td input[type="text"],
.row-table tbody td select{
width:100%;height:2.25rem;padding:0 .75rem;
font-size:.8125rem;font-family:var(--font);line-height:1;
background:var(--input-bg);color:var(--fg);
border:1px solid var(--input-border);border-radius:calc(var(--radius) - 2px)
}
.row-table tbody td input[type="text"]:focus,
.row-table tbody td select:focus{border-color:var(--primary);box-shadow:0 0 0 3px var(--ring);outline:none}
.row-table__action{width:2.5rem;text-align:right;vertical-align:middle}
.row-table__remove{background:transparent;border:1px solid var(--border);color:var(--muted);width:1.75rem;height:1.75rem;border-radius:var(--radius);cursor:pointer;font-size:.8125rem;line-height:1;padding:0;transition:border-color .15s,color .15s}
.row-table__remove:hover{border-color:#dc2626;color:#dc2626}
.btn-secondary{background:transparent;border:1px solid var(--border);color:var(--fg);padding:.4rem .75rem;border-radius:var(--radius);font-size:.8125rem;font-family:var(--font);cursor:pointer;transition:border-color .15s,background .15s}
.btn-secondary:hover{border-color:var(--primary);background:rgba(37,195,158,.05)}
.btn-secondary:disabled{opacity:.5;cursor:not-allowed}

/* ── Multilingual cell (column.multilingual: true) ── */
.i18n-cell{display:flex;flex-direction:column;gap:.4rem;min-width:240px}
.i18n-cell__locale-row{display:flex;gap:.4rem;align-items:center;flex-wrap:nowrap}
.i18n-cell__locale-label{font-size:.6875rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;min-width:1.8rem;text-align:center;padding:.25rem .4rem;border:1px solid var(--border);border-radius:calc(var(--radius) - 4px);background:var(--bg);flex:0 0 auto;white-space:nowrap}
.i18n-cell__locale-row input[type="text"]{flex:1 1 auto;min-width:0}

/* ── Row-level language toolbar (shared by all multilingual cells) ── */
.row-table tbody tr.row-table__lang-row td{padding:.5rem .35rem .9rem;border-bottom:1px solid var(--border);background:transparent}
.row-table__lang-toolbar{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;font-size:.75rem}
.lang-toolbar__hint{color:var(--muted);font-weight:500;letter-spacing:.02em;margin-right:.1rem}
.lang-toolbar__chips{display:inline-flex;gap:.3rem;flex-wrap:wrap}
.lang-toolbar__chip{display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .25rem .2rem .5rem;font-size:.6875rem;font-weight:600;color:var(--fg);text-transform:uppercase;letter-spacing:.06em;background:var(--input-bg);border:1px solid var(--border);border-radius:9999px;line-height:1}
.lang-toolbar__chip--baseline{padding-right:.55rem;color:var(--muted)}
.lang-toolbar__chip-remove{background:transparent;border:0;color:var(--muted);width:1.1rem;height:1.1rem;border-radius:9999px;cursor:pointer;font-size:.7rem;line-height:1;padding:0;display:inline-flex;align-items:center;justify-content:center}
.lang-toolbar__chip-remove:hover{color:#dc2626;background:rgba(220,38,38,.08)}
.lang-toolbar__picker{height:1.85rem;padding:0 .5rem;font-size:.75rem;font-family:var(--font);background:var(--input-bg);color:var(--fg);border:1px solid var(--input-border);border-radius:calc(var(--radius) - 2px);width:auto;margin-left:auto}
.lang-toolbar__add{background:transparent;border:1px solid var(--border);color:var(--primary);padding:.3rem .7rem;border-radius:var(--radius);font-size:.75rem;font-family:var(--font);cursor:pointer}
.lang-toolbar__add:hover{background:rgba(37,195,158,.05);border-color:var(--primary)}
7 changes: 7 additions & 0 deletions src/bin/greentic_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ fn run_ui_mode(cli: &Cli, i18n: &CliI18n) -> Result<()> {
let bundle_dir = resolve_bundle_source(&bundle_path, i18n)?;
bundle::validate_bundle_exists(&bundle_dir).context(i18n.t("cli.error.invalid_bundle"))?;

// Compute the write-back target up front so that after the UI session
// completes the extracted bundle dir gets re-packed (or copied) back
// to the user's original input — matches the behaviour of
// run_simple_setup which calls gtbundle::create_gtbundle directly.
let output_target = setup_output_target(&bundle_path)?;

// Load answers from --answers file for UI pre-fill (values + scope).
let (prefill_answers, answers_tenant, answers_team, answers_env) = if let Some(answers_path) =
&cli.answers
Expand Down Expand Up @@ -375,6 +381,7 @@ fn run_ui_mode(cli: &Cli, i18n: &CliI18n) -> Result<()> {
cli.locale.as_deref(),
prefill_answers,
scope_from_answers,
output_target,
))
}

Expand Down
1 change: 1 addition & 0 deletions src/cli_helpers/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub fn resolve_bundle_source(path: &std::path::Path, i18n: &CliI18n) -> Result<P
}

/// Persistent output target for simple setup flows.
#[derive(Clone)]
pub enum SetupOutputTarget {
Directory(PathBuf),
Archive(PathBuf),
Expand Down
8 changes: 8 additions & 0 deletions src/engine/executors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,14 @@ pub fn execute_apply_pack_setup(
}

// Sync `nav_links_json` answer to tenant config JSON for webchat-gui providers
if provider_id.contains("webchat-gui") && config.verbose {
let preview = answers
.as_object()
.and_then(|m| m.get("nav_links"))
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "<unserializable>".into()))
.unwrap_or_else(|| "<absent>".into());
println!(" [nav_links] received answer for {provider_id}: {preview}");
}
match crate::tenant_config::sync_nav_links_to_tenant_config(
bundle_path,
&config.tenant,
Expand Down
125 changes: 123 additions & 2 deletions src/qa/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use std::io::{self, Write as _};

use anyhow::{Result, anyhow};
use qa_spec::spec::question::ListSpec;
use qa_spec::{FormSpec, QuestionSpec, QuestionType, VisibilityMode, resolve_visibility};
use rpassword::prompt_password;
use serde_json::{Map as JsonMap, Value};
Expand Down Expand Up @@ -67,8 +68,13 @@ pub fn prompt_form_spec_answers_with_existing(
{
continue;
}
// In normal mode, skip optional missing questions.
if !advanced && !question.required {
// In normal mode, skip optional missing questions — except `List`
// (table) kinds. A table is a structural hand-off to the operator
// ("here's where nav-links / repeating data go") that doesn't make
// sense to silently hide based on the required flag, even when
// optional. They get the table; if they want to skip rows they
// answer "n" to "Add a row?" and move on.
if !advanced && !question.required && question.kind != QuestionType::List {
continue;
}
if let Some(value) = ask_form_spec_question(question)? {
Expand Down Expand Up @@ -122,6 +128,16 @@ pub fn answer_satisfies_question(question: &QuestionSpec, value: &Value) -> bool

/// Prompt for a single FormSpec question and return the answer.
pub fn ask_form_spec_question(question: &QuestionSpec) -> Result<Option<Value>> {
// Table / repeating-row questions (kind: List) get a dedicated row loop
// — see `ask_list_question` for the prompt protocol. Falls through to
// the scalar path if the question is List-typed but missing its
// `list` schema (defensive: shouldn't happen with a well-formed spec).
if question.kind == QuestionType::List
&& let Some(ref list) = question.list
{
return ask_list_question(question, list);
}

// Print question header
let marker = if question.required {
" (required)"
Expand Down Expand Up @@ -189,6 +205,111 @@ pub fn ask_form_spec_question(question: &QuestionSpec) -> Result<Option<Value>>
}
}

/// Prompt for a `QuestionType::List` (repeating-row) question. Loops
/// "Add another?" / row-by-row prompts and returns a `Value::Array` of
/// per-row JSON objects whose keys match the column field IDs.
///
/// Constraints:
/// - `list.min_items` / `max_items` enforce row count bounds.
/// - The outer question's `required` flag is honoured: when required and
/// no rows were collected, we re-prompt instead of returning `None`.
/// - Rows whose required columns are all empty are dropped silently
/// (lets the operator type a blank line and "step out" mid-table).
fn ask_list_question(question: &QuestionSpec, list: &ListSpec) -> Result<Option<Value>> {
let marker = if question.required {
" (required)"
} else {
" (optional)"
};
println!();
println!(" {}{marker}", question.title);
if let Some(ref desc) = question.description
&& !desc.is_empty()
{
println!(" {desc}");
}

let max = list.max_items;
let min = list.min_items.unwrap_or(0);

let mut rows: Vec<Value> = Vec::new();
loop {
if let Some(cap) = max
&& rows.len() >= cap
{
println!(" (max {} rows reached)", cap);
break;
}

// Ask whether to add another row.
let prompt = if rows.is_empty() {
" > Add a row? [y/N] "
} else {
" > Add another row? [y/N] "
};
let input = read_input(prompt, false)?;
let trimmed = input.trim().to_ascii_lowercase();
let yes = matches!(trimmed.as_str(), "y" | "yes" | "1" | "true");
if !yes {
if rows.len() < min {
println!(
" At least {min} row(s) required — got {}. Type 'y' to add another.",
rows.len()
);
continue;
}
break;
}

// Prompt each column for the new row.
println!(" Row #{}:", rows.len() + 1);
let mut row_obj = JsonMap::new();
for column in &list.fields {
if let Some(value) = ask_form_spec_question(column)? {
row_obj.insert(column.id.clone(), value);
}
}

// Drop the row if every required column ended up empty — lets the
// operator back out by hitting Enter through every column.
let row_has_required_content = list.fields.iter().all(|c| {
!c.required
|| row_obj
.get(&c.id)
.map(|v| !is_empty_answer(v))
.unwrap_or(false)
});
if !row_has_required_content {
println!(" (row dropped — required columns were empty)");
continue;
}

rows.push(Value::Object(row_obj));
}

if rows.is_empty() {
if question.required {
println!(" This field is required — at least one row needed.");
return Ok(None);
}
return Ok(None);
}

Ok(Some(Value::Array(rows)))
}

/// Treat an empty string, false bool, or null as "no answer" for the
/// row-required check.
fn is_empty_answer(v: &Value) -> bool {
match v {
Value::Null => true,
Value::String(s) => s.trim().is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
}
}

/// Build the prompt string for a FormSpec question.
fn build_form_spec_prompt(question: &QuestionSpec) -> String {
let mut prompt = String::from(" > ");
Expand Down
49 changes: 49 additions & 0 deletions src/setup_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,55 @@ pub struct SetupQuestion {
/// URL to external setup documentation.
#[serde(default)]
pub docs_url: Option<String>,
/// Column definitions for `kind: table` questions. Each row's answer is a
/// JSON object whose keys match the columns' `key` field.
#[serde(default)]
pub columns: Vec<SetupTableColumn>,
/// Minimum required row count for a `kind: table` question.
#[serde(default)]
pub min_rows: Option<u16>,
/// Maximum row count for a `kind: table` question.
#[serde(default)]
pub max_rows: Option<u16>,
}

/// One column in a `kind: table` setup question.
#[derive(Debug, Default, Deserialize)]
pub struct SetupTableColumn {
/// JSON object key the column's value is stored under (e.g. `"label"`).
/// Stable identifier — do not rename without a migration.
#[serde(default)]
pub key: String,
/// Header label shown above the column / next to each row's input.
#[serde(default)]
pub title: Option<String>,
/// Column scalar kind. Same vocabulary as top-level `kind` — but nested
/// tables are not supported.
#[serde(default = "default_kind")]
pub kind: String,
/// Whether the column must be filled for a row to count as non-empty.
#[serde(default)]
pub required: bool,
/// Optional inline help.
#[serde(default)]
pub help: Option<String>,
/// Optional placeholder shown when the cell is empty.
#[serde(default)]
pub placeholder: Option<String>,
/// Optional pre-defined choices for `kind: choice` columns.
#[serde(default)]
pub choices: Vec<String>,
/// Optional per-row default applied to new rows.
#[serde(default)]
pub default: Option<Value>,
/// When true, the wizard renders a multi-locale cell instead of a
/// scalar input. Operator types the primary (English) value and may
/// add per-locale translations via "+ Add language". Persisted as a
/// locale-keyed object `{en: "...", id: "...", ...}` (or a plain
/// string when only one locale was filled). Only meaningful for
/// `kind: string` columns.
#[serde(default)]
pub multilingual: bool,
}

/// Conditional visibility for a setup question.
Expand Down
Loading
Loading