Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: data-driven frontend, frontend test, and extra project templates #3907

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
8 changes: 8 additions & 0 deletions e2e/tests-dfx/completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ teardown() {
assert_contains "dfx;nns;install"
assert_contains "dfx;help;sns;deploy"
}

@test "completion scripts include built-in project template names" {
assert_command dfx completion bash
assert_contains "motoko"
assert_contains "sveltekit"
assert_contains "bitcoin"
assert_contains "frontend-tests"
}
31 changes: 27 additions & 4 deletions src/dfx-core/src/config/project_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ pub enum ResourceLocation {
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Category {
Backend,
Frontend,
FrontendTest,
Extra,
}

#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct ProjectTemplateName(pub String);

#[derive(Debug, Clone)]
Expand All @@ -41,10 +44,26 @@ pub struct ProjectTemplate {
/// If true, patch in the any_js template files
pub has_js: bool,

/// If true, run npm install
pub install_node_dependencies: bool,

/// The sort order is fixed rather than settable in properties:
/// - motoko=0
/// - rust=1
/// - everything else=2 (and then by display name)
/// For backend:
/// - motoko=0
/// - rust=1
/// - everything else=2 (and then by display name)
/// For frontend:
/// - SvelteKit=0
/// - React=1
/// - Vue=2
/// - Vanilla JS=3
/// - No JS Template=4
/// - everything else=5 (and then by display name)
/// For extras:
/// - Internet Identity=0
/// - Bitcoin=1
/// - everything else=2 (and then by display name)
/// - Frontend Tests
pub sort_order: u32,
}

Expand All @@ -65,6 +84,10 @@ pub fn get_project_template(name: &ProjectTemplateName) -> ProjectTemplate {
PROJECT_TEMPLATES.get().unwrap().get(name).cloned().unwrap()
}

pub fn find_project_template(name: &ProjectTemplateName) -> Option<ProjectTemplate> {
PROJECT_TEMPLATES.get().unwrap().get(name).cloned()
}

pub fn get_sorted_templates(category: Category) -> Vec<ProjectTemplate> {
PROJECT_TEMPLATES
.get()
Expand Down
221 changes: 109 additions & 112 deletions src/dfx/src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use clap::builder::PossibleValuesParser;
use clap::{Parser, ValueEnum};
use console::{style, Style};
use dfx_core::config::project_templates::{
get_project_template, get_sorted_templates, project_template_cli_names, Category,
ProjectTemplateName, ResourceLocation,
find_project_template, get_project_template, get_sorted_templates, project_template_cli_names,
Category, ProjectTemplate, ProjectTemplateName, ResourceLocation,
};
use dfx_core::json::{load_json_file, save_json_file};
use dialoguer::theme::ColorfulTheme;
Expand Down Expand Up @@ -61,8 +61,8 @@ pub struct NewOpts {
dry_run: bool,

/// Choose the type of frontend in the starter project. Defaults to vanilla.
#[arg(long, value_enum, default_missing_value = "vanilla")]
frontend: Option<FrontendType>,
#[arg(long, value_parser=frontend_project_template_name_parser(), default_missing_value = "vanilla")]
frontend: Option<String>,

/// Skip installing the frontend code example.
#[arg(long, conflicts_with = "frontend")]
Expand All @@ -73,43 +73,24 @@ pub struct NewOpts {
#[arg(long, requires("frontend"))]
agent_version: Option<String>,

#[arg(long, value_enum)]
extras: Vec<Extra>,
#[arg(long, value_parser=extras_project_template_name_parser())]
extras: Vec<String>,
}

fn backend_project_template_name_parser() -> PossibleValuesParser {
PossibleValuesParser::new(project_template_cli_names(Category::Backend))
}

#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
enum FrontendType {
#[value(name = "sveltekit")]
SvelteKit,
Vanilla,
Vue,
React,
SimpleAssets,
None,
}

impl Display for FrontendType {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::SvelteKit => "SvelteKit",
Self::Vanilla => "Vanilla JS",
Self::Vue => "Vue",
Self::React => "React",
Self::SimpleAssets => "No JS template",
Self::None => "No frontend canister",
}
.fmt(f)
}
fn frontend_project_template_name_parser() -> PossibleValuesParser {
let mut options = project_template_cli_names(Category::Frontend);
options.push("none".to_string());
PossibleValuesParser::new(options)
}

impl FrontendType {
fn has_js(&self) -> bool {
!matches!(self, Self::None | Self::SimpleAssets)
}
fn extras_project_template_name_parser() -> PossibleValuesParser {
let mut options = project_template_cli_names(Category::Extra);
options.push("frontend-tests".to_string());
PossibleValuesParser::new(options)
}

#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -327,8 +308,8 @@ fn scaffold_frontend_code(
env: &dyn Environment,
dry_run: bool,
project_name: &Path,
frontend: FrontendType,
extras: &[Extra],
frontend: ProjectTemplate,
frontend_tests: Option<ProjectTemplate>,
agent_version: &Option<String>,
variables: &BTreeMap<String, String>,
) -> DfxResult {
Expand Down Expand Up @@ -356,37 +337,20 @@ fn scaffold_frontend_code(
project_name_str.to_uppercase(),
);

let mut new_project_files = match frontend {
FrontendType::Vanilla => assets::new_project_vanillajs_files(),
FrontendType::SvelteKit => assets::new_project_svelte_files(),
FrontendType::Vue => assets::new_project_vue_files(),
FrontendType::React => assets::new_project_react_files(),
FrontendType::SimpleAssets => assets::new_project_assets_files(),
FrontendType::None => unreachable!(),
}?;
write_files_from_entries(
log,
&mut new_project_files,
project_name,
dry_run,
&variables,
)?;
if extras.contains(&Extra::FrontendTests) {
let mut test_files = match frontend {
FrontendType::SvelteKit => assets::new_project_svelte_test_files(),
FrontendType::React => assets::new_project_react_test_files(),
FrontendType::Vue => assets::new_project_vue_test_files(),
FrontendType::Vanilla => assets::new_project_vanillajs_test_files(),
FrontendType::SimpleAssets => {
bail!("Cannot add frontend tests to --frontend-type simple-assets")
}
FrontendType::None => bail!("Cannot add frontend tests to --no-frontend"),
}?;
write_files_from_entries(log, &mut test_files, project_name, dry_run, &variables)?;
write_project_template_resources(log, &frontend, project_name, dry_run, &variables)?;

if let Some(frontend_tests) = frontend_tests {
write_project_template_resources(
log,
&frontend_tests,
project_name,
dry_run,
&variables,
)?;
}

// Only install node dependencies if we're not running in dry run.
if !dry_run && frontend != FrontendType::SimpleAssets {
if !dry_run && frontend.install_node_dependencies {
// Install node modules. Error is not blocking, we just show a message instead.
if node_installed {
let b = env.new_spinner("Installing node dependencies...".into());
Expand Down Expand Up @@ -541,67 +505,67 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
&variables,
)?;

let frontend = if opts.no_frontend {
FrontendType::None
} else {
opts.frontend.unwrap_or(FrontendType::Vanilla)
};
let frontend: Option<ProjectTemplate> =
if opts.no_frontend || matches!(opts.frontend.as_ref(), Some(s) if s == "none") {
None
} else {
let name = ProjectTemplateName(opts.frontend.unwrap_or("vanilla".to_string()));
Some(get_project_template(&name))
};

let backend = get_project_template(&backend_template_name);

if backend.has_js || frontend.has_js() {
write_files_from_entries(
log,
&mut assets::new_project_js_files().context("Failed to get JS config archive.")?,
project_name,
dry_run,
&variables,
)?;
}
let extras: Vec<ProjectTemplate> = opts
.extras
.iter()
.filter(|s| *s != "frontend-tests")
.map(|s| get_project_template(&ProjectTemplateName(s.clone())))
.collect();

match backend.resource_location {
ResourceLocation::Bundled { get_archive_fn } => {
let mut new_project_files = get_archive_fn()
.with_context(|| format!("Failed to get {} archive.", backend.name.0))?;
write_files_from_entries(
log,
&mut new_project_files,
project_name,
dry_run,
&variables,
)?;
}
let frontend_tests = if opts.extras.iter().any(|s| s == "frontend-tests") {
let Some(ref frontend) = frontend else {
bail!("Cannot add frontend tests to --no-frontend")
};

let template_name = ProjectTemplateName(format!("{}-tests", frontend.name.0));
let Some(template) = find_project_template(&template_name) else {
bail!(format!(
"Cannot add frontend tests to --frontend-type {}",
frontend.name.0
))
};
Some(template)
} else {
None
};

if opts.extras.contains(&Extra::InternetIdentity) {
if backend.has_js || frontend.as_ref().map_or(false, |t| t.has_js) {
write_files_from_entries(
log,
&mut assets::new_project_internet_identity_files()?,
&mut assets::new_project_js_files().context("Failed to get JS config archive.")?,
project_name,
dry_run,
&variables,
)?;
}
if opts.extras.contains(&Extra::Bitcoin) {
write_files_from_entries(
log,
&mut assets::new_project_bitcoin_files()?,
project_name,
dry_run,
&variables,
)?;

write_project_template_resources(log, &backend, project_name, dry_run, &variables)?;

for extra in extras {
write_project_template_resources(log, &extra, project_name, dry_run, &variables)?;
}
if frontend != FrontendType::None {

if let Some(frontend) = frontend {
scaffold_frontend_code(
env,
dry_run,
project_name,
frontend,
&opts.extras,
frontend_tests,
&opts.agent_version,
&variables,
)?;
}
};

if !dry_run {
// If on mac, we should validate that XCode toolchain was installed.
Expand Down Expand Up @@ -670,9 +634,20 @@ pub fn exec(env: &dyn Environment, mut opts: NewOpts) -> DfxResult {
Ok(())
}

fn write_project_template_resources(
logger: &Logger,
template: &ProjectTemplate,
project_name: &Path,
dry_run: bool,
variables: &BTreeMap<String, String>,
) -> DfxResult {
let mut resources = match template.resource_location {
ResourceLocation::Bundled { get_archive_fn } => get_archive_fn()?,
};
write_files_from_entries(logger, &mut resources, project_name, dry_run, variables)
}

fn get_opts_interactively(opts: NewOpts) -> DfxResult<NewOpts> {
use Extra::*;
use FrontendType::*;
let theme = ColorfulTheme::default();
let backend_templates = get_sorted_templates(Category::Backend);
let backends_list = backend_templates
Expand All @@ -686,27 +661,49 @@ fn get_opts_interactively(opts: NewOpts) -> DfxResult<NewOpts> {
.with_prompt("Select a backend language:")
.interact()?;
let backend = &backend_templates[backend];
let frontends_list = [SvelteKit, React, Vue, Vanilla, SimpleAssets, None];
let frontend_templates = get_sorted_templates(Category::Frontend);
let mut frontends_list = frontend_templates
.iter()
.map(|t| t.display.clone())
.collect::<Vec<_>>();
frontends_list.push("None".to_string());
let frontend = FuzzySelect::with_theme(&theme)
.items(&frontends_list)
.default(0)
.with_prompt("Select a frontend framework:")
.interact()?;
let frontend = frontends_list[frontend];
let mut extras_list = vec![InternetIdentity, Bitcoin];
if frontend != None && frontend != SimpleAssets {
extras_list.push(FrontendTests);
let frontend = frontend_templates.get(frontend);

let extra_templates: Vec<_> = get_sorted_templates(Category::Extra);
let mut extras_display_names = extra_templates
.iter()
.map(|t| t.display.clone())
.collect::<Vec<_>>();

let mut extras_template_names = extra_templates
.iter()
.map(|t| t.name.0.clone())
.collect::<Vec<_>>();
if let Some(frontend_template) = frontend {
let fe_tests = ProjectTemplateName(format!("{}-tests", frontend_template.name.0));
if find_project_template(&fe_tests).is_some() {
extras_display_names.push("Frontend tests".to_string());
extras_template_names.push("frontend-tests".to_string());
}
}
let extras = MultiSelect::with_theme(&theme)
.items(&extras_list)
.items(&extras_display_names)
.with_prompt("Add extra features (space to select, enter to confirm)")
.interact()?;

let extras = extras.into_iter().map(|x| extras_list[x]).collect();
let extras = extras
.into_iter()
.map(|x| extras_template_names[x].clone())
.collect();

let opts = NewOpts {
extras,
frontend: Some(frontend),
frontend: frontend.map(|f| f.name.0.clone()),
r#type: Some(backend.name.0.clone()),
..opts
};
Expand Down
Loading
Loading