branch: main
forms.rs
12935 bytesRaw
use crate::{api, presentation, web};
use worker::*;

use super::{load_branches, Url};
use presentation::{Action, ActionField, Hint, NegotiatedRepresentation};

struct IssueFormPage {
    owner: String,
    repo_name: String,
    default_branch: String,
}

impl IssueFormPage {
    fn new(sql: &SqlStorage, owner: &str, repo_name: &str) -> Result<Self> {
        let (default_branch, _) = web::resolve_default_branch(sql)?;
        Ok(Self {
            owner: owner.to_string(),
            repo_name: repo_name.to_string(),
            default_branch,
        })
    }

    fn form_path(&self) -> String {
        format!("/{}/{}/issues/new", self.owner, self.repo_name)
    }

    fn submit_path(&self) -> String {
        format!("/{}/{}/issues", self.owner, self.repo_name)
    }
}

struct PullFormPage {
    owner: String,
    repo_name: String,
    default_branch: String,
    branches: Vec<String>,
    preselect_source: String,
    preselect_target: String,
}

impl PullFormPage {
    fn new(sql: &SqlStorage, owner: &str, repo_name: &str, url: &Url) -> Result<Self> {
        let (default_branch, _) = web::resolve_default_branch(sql)?;
        let branches = load_branches(sql)?;
        let preselect_source = api::get_query(url, "source").unwrap_or_default();
        let preselect_target =
            api::get_query(url, "target").unwrap_or_else(|| default_branch.clone());

        Ok(Self {
            owner: owner.to_string(),
            repo_name: repo_name.to_string(),
            default_branch,
            branches,
            preselect_source,
            preselect_target,
        })
    }

    fn form_path(&self) -> String {
        format!("/{}/{}/pulls/new", self.owner, self.repo_name)
    }

    fn submit_path(&self) -> String {
        format!("/{}/{}/pulls", self.owner, self.repo_name)
    }

    fn has_enough_branches(&self) -> bool {
        self.branches.len() >= 2
    }
}

pub fn page_new_issue(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    actor_name: Option<&str>,
) -> Result<Response> {
    let page = IssueFormPage::new(sql, owner, repo_name)?;

    let content = format!(
        r#"<h1>New Issue</h1>
        <form method="POST" action="/{owner}/{repo}/issues" class="new-issue-form">
          <div class="form-group">
            <label class="form-label" for="title">Title</label>
            <input type="text" id="title" name="title" class="form-input"
                   placeholder="Issue title" required autofocus>
          </div>
          <div class="form-group">
            <label class="form-label" for="body">Description <span class="form-hint">(Markdown)</span></label>
            <textarea id="body" name="body" class="form-textarea"
                      placeholder="Describe the issue..." rows="12"></textarea>
          </div>
          <div class="form-actions">
            <button type="submit" class="btn-primary">Submit new issue</button>
            <a href="/{owner}/{repo}/issues" class="btn-action">Cancel</a>
          </div>
        </form>"#,
        owner = web::html_escape(&page.owner),
        repo = web::html_escape(&page.repo_name),
    );

    web::html_response(&web::layout(
        "New Issue",
        &page.owner,
        &page.repo_name,
        &page.default_branch,
        actor_name,
        &content,
    ))
}

pub fn page_new_issue_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    _actor_name: Option<&str>,
    selection: &NegotiatedRepresentation,
) -> Result<Response> {
    let page = IssueFormPage::new(sql, owner, repo_name)?;
    presentation::markdown_response(&render_new_issue_markdown(&page, selection), selection)
}

pub fn page_new_pull(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    actor_name: Option<&str>,
) -> Result<Response> {
    let page = PullFormPage::new(sql, owner, repo_name, url)?;

    if !page.has_enough_branches() {
        let content = r#"<p>You need at least two branches to open a pull request.</p>"#;
        return web::html_response(&web::layout(
            "New Pull Request",
            &page.owner,
            &page.repo_name,
            &page.default_branch,
            actor_name,
            content,
        ));
    }

    let source_options: String = page
        .branches
        .iter()
        .map(|branch| {
            let selected = if branch == &page.preselect_source {
                " selected"
            } else {
                ""
            };
            format!(
                r#"<option value="{value}"{selected}>{value}</option>"#,
                value = web::html_escape(branch),
                selected = selected
            )
        })
        .collect();

    let target_options: String = page
        .branches
        .iter()
        .map(|branch| {
            let selected = if branch == &page.preselect_target {
                " selected"
            } else {
                ""
            };
            format!(
                r#"<option value="{value}"{selected}>{value}</option>"#,
                value = web::html_escape(branch),
                selected = selected
            )
        })
        .collect();

    let content = format!(
        r#"<h1>New Pull Request</h1>
        <form method="POST" action="/{owner}/{repo}/pulls" class="new-issue-form">
          <div class="form-group form-branch-row">
            <div>
              <label class="form-label">Base branch <span class="form-hint">(merge into)</span></label>
              <select name="target" class="branch-input">{target_options}</select>
            </div>
            <span class="arrow">←</span>
            <div>
              <label class="form-label">Compare branch <span class="form-hint">(changes from)</span></label>
              <select name="source" class="branch-input">{source_options}</select>
            </div>
          </div>
          <div class="form-group">
            <label class="form-label" for="pr-title">Title</label>
            <input type="text" id="pr-title" name="title" class="form-input"
                   placeholder="Pull request title" required autofocus>
          </div>
          <div class="form-group">
            <label class="form-label" for="pr-body">Description <span class="form-hint">(Markdown)</span></label>
            <textarea id="pr-body" name="body" class="form-textarea"
                      placeholder="Describe your changes..." rows="10"></textarea>
          </div>
          <div class="form-actions">
            <button type="submit" class="btn-primary">Open pull request</button>
            <a href="/{owner}/{repo}/pulls" class="btn-action">Cancel</a>
          </div>
        </form>"#,
        owner = web::html_escape(&page.owner),
        repo = web::html_escape(&page.repo_name),
        source_options = source_options,
        target_options = target_options,
    );

    web::html_response(&web::layout(
        "New Pull Request",
        &page.owner,
        &page.repo_name,
        &page.default_branch,
        actor_name,
        &content,
    ))
}

pub fn page_new_pull_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    _actor_name: Option<&str>,
    selection: &NegotiatedRepresentation,
) -> Result<Response> {
    let page = PullFormPage::new(sql, owner, repo_name, url)?;
    presentation::markdown_response(&render_new_pull_markdown(&page, selection), selection)
}

fn render_new_issue_markdown(page: &IssueFormPage, selection: &NegotiatedRepresentation) -> String {
    let mut markdown = format!(
        "# New issue - {}/{}\n\nPOST endpoint: `{}`\nAuthentication: required\nRequired fields: `title`\nOptional fields: `body`\nCancel path: `{}`\nList path: `{}`\nResult: creates a new open issue.\n",
        page.owner,
        page.repo_name,
        page.submit_path(),
        page.submit_path(),
        page.submit_path(),
    );

    markdown.push_str("\n## Related Paths (GET paths)\n");
    markdown.push_str(&format!("- `{}`\n", page.form_path()));
    markdown.push_str(&format!("- `{}`\n", page.submit_path()));

    let actions = vec![
        Action::post(page.submit_path(), "create a new issue")
            .with_fields(vec![
                ActionField::required("title", "short issue summary; must be non-empty"),
                ActionField::optional("body", "markdown description for the issue body"),
            ])
            .with_requires("authenticated user")
            .with_effect("stores an open issue and redirects to its detail page"),
        Action::get(page.submit_path(), "list existing issues"),
        Action::get(page.form_path(), "reload this form description"),
    ];

    let hints = vec![
        presentation::text_navigation_hint(*selection),
        Hint::new("The HTML form marks `title` as required and autofocuses it."),
        Hint::new("`body` accepts markdown and may be left empty."),
        Hint::new("Use the issues list path as the cancel destination for this form."),
    ];

    markdown.push_str(&presentation::render_actions_section(&actions));
    markdown.push_str(&presentation::render_hints_section(&hints));
    markdown
}

fn render_new_pull_markdown(page: &PullFormPage, selection: &NegotiatedRepresentation) -> String {
    let mut markdown = format!(
        "# New pull request - {}/{}\n\nPOST endpoint: `{}`\nAuthentication: required\nRequired fields: `target`, `source`, `title`\nOptional fields: `body`\nConstraint: `source` and `target` must differ.\nCancel path: `{}`\nList path: `{}`\nDefault target branch: `{}`\nPreselected source branch: `{}`\nPreselected target branch: `{}`\n",
        page.owner,
        page.repo_name,
        page.submit_path(),
        page.submit_path(),
        page.submit_path(),
        page.default_branch,
        if page.preselect_source.is_empty() {
            "none"
        } else {
            &page.preselect_source
        },
        if page.preselect_target.is_empty() {
            "none"
        } else {
            &page.preselect_target
        },
    );

    if page.has_enough_branches() {
        markdown.push_str("Pull request creation is available from this form.\n");
    } else {
        markdown.push_str("Pull request creation is currently unavailable because fewer than two branches exist.\n");
    }

    markdown.push_str("\n## Branches\n");
    if page.branches.is_empty() {
        markdown.push_str("No branches found.\n");
    } else {
        for branch in &page.branches {
            let mut suffix = String::new();
            if branch == &page.default_branch {
                suffix.push_str(" (default target)");
            }
            if branch == &page.preselect_source {
                suffix.push_str(" (preselected source)");
            }
            if branch == &page.preselect_target {
                suffix.push_str(" (preselected target)");
            }
            markdown.push_str(&format!("- `{}`{}\n", branch, suffix));
        }
    }

    markdown.push_str("\n## Related Paths (GET paths)\n");
    markdown.push_str(&format!("- `{}`\n", page.form_path()));
    markdown.push_str(&format!("- `{}`\n", page.submit_path()));

    let mut actions = vec![
        Action::get(page.submit_path(), "list existing pull requests"),
        Action::get(page.form_path(), "reload this form description"),
    ];

    let post_action = Action::post(page.submit_path(), "create a new pull request")
        .with_fields(vec![
            ActionField::required("target", "base branch to merge into"),
            ActionField::required("source", "compare branch containing the proposed changes"),
            ActionField::required("title", "pull request summary; must be non-empty"),
            ActionField::optional("body", "markdown description for the pull request body"),
        ])
        .with_requires("authenticated user")
        .with_effect("stores an open pull request and redirects to its detail page");

    if page.has_enough_branches() {
        actions.insert(0, post_action);
    } else {
        actions.insert(
            0,
            post_action.with_effect(
                "requires at least two existing branches before the HTML form can be used",
            ),
        );
    }

    let hints = vec![
        presentation::text_navigation_hint(*selection),
        Hint::new("`source` and `target` are both required, must name existing branches, and must differ."),
        Hint::new("The HTML form defaults `target` to the repository default branch when no query override is provided."),
        Hint::new("Use `?source=<branch>&target=<branch>` on the new-pull path to preselect branch choices."),
        Hint::new("Use the pulls list path as the cancel destination for this form."),
    ];

    markdown.push_str(&presentation::render_actions_section(&actions));
    markdown.push_str(&presentation::render_hints_section(&hints));
    markdown
}