From 56ed50fb2b37c773debbaea01726d076c3cf798e Mon Sep 17 00:00:00 2001 From: Collins Muriuki Date: Tue, 11 Jun 2024 19:54:54 +0300 Subject: [PATCH] feat: Add react template (#295) * WIP * update ui module * add react to matched ui framework strings * hbsify templates * add base react components * react collection * react detail component * react create component * react edit template * react entry type for linked from component * field types * react link type components * fix field types * template swoop; fix issues * add missing imports, strip out eslint * fix images * refactor holochain provider * fix entry-type templates * fix link-type templates * correctly name field type widgets * add forum example * fix holochain context * check for links lengths before setting state * fix text field * styling improvments * extend base styles * improve example styling * fix mismatched components * remove ui readme * fix templates * fix imports * update elements and imports * type app signal callback * test with react template in ci * update styling * fix global styles * rename and move client context * make client context compatible with holo * fix indentation in templates * update client context template * fix entry-types for linked from component props * update detail components * add default field types * fix example app.tsx * fix vec detail render * fix vec edit render * fix timestamp * fix client context not updating loading state * fix vec input * fix vec input setters in edit components * Update templates/react/field-types/u32/NumberInput/edit/render.hbs Co-authored-by: Paul d'Aoust * Update templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs Co-authored-by: Paul d'Aoust * Update templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs Co-authored-by: Paul d'Aoust * set default and initial values for boolean inputs to false * ensure previous zip file is deleted * fix templates --------- Co-authored-by: Paul d'Aoust --- .github/workflows/test.yaml | 2 +- src/cli.rs | 10 +- src/scaffold/web_app/uis.rs | 16 +- templates/react/collection.instructions.hbs | 4 + ...{{kebab_case collection_name}}.test.ts.hbs | 77 +++++ .../{{pascal_case collection_name}}.tsx.hbs | 82 +++++ .../{{zome_manifest.name}}/common.ts.hbs | 3 + .../{{zome_manifest.name}}/types.ts.hbs | 38 +++ .../common.ts.hbs | 53 ++++ ...{{kebab_case entry_type.name}}.test.ts.hbs | 279 ++++++++++++++++++ ...ate{{pascal_case entry_type.name}}.tsx.hbs | 87 ++++++ .../types.ts.hbs | 25 ++ ...e}}.tsx{{\302\241if}}{{\302\241each}}.hbs" | 71 +++++ ...e entry_type.name}}.tsx{{\302\241if}}.hbs" | 138 +++++++++ ...ascal_case entry_type.name}}Detail.tsx.hbs | 140 +++++++++ templates/react/example.instructions.hbs | 6 + templates/react/example/ui/src/App.tsx.hbs | 25 ++ .../react/field-types/ActionHash/sample.hbs | 1 + .../react/field-types/ActionHash/type.hbs | 1 + .../react/field-types/AgentPubKey/sample.hbs | 1 + .../react/field-types/AgentPubKey/type.hbs | 1 + .../react/field-types/DnaHash/sample.hbs | 1 + templates/react/field-types/DnaHash/type.hbs | 1 + .../react/field-types/EntryHash/sample.hbs | 1 + .../react/field-types/EntryHash/type.hbs | 1 + .../field-types/Enum/Select/detail/render.hbs | 1 + .../field-types/Enum/Select/edit/render.hbs | 7 + .../field-types/Enum/Select/initial-value.hbs | 1 + .../field-types/Enum/Select/is-valid.hbs | 1 + templates/react/field-types/Enum/default.hbs | 1 + templates/react/field-types/Enum/sample.hbs | 1 + templates/react/field-types/Enum/type.hbs | 1 + .../String/TextArea/detail/render.hbs | 1 + .../String/TextArea/edit/render.hbs | 25 ++ .../String/TextArea/initial-value.hbs | 1 + .../field-types/String/TextArea/is-valid.hbs | 1 + .../String/TextField/detail/render.hbs | 1 + .../String/TextField/edit/render.hbs | 22 ++ .../String/TextField/initial-value.hbs | 1 + .../field-types/String/TextField/is-valid.hbs | 1 + .../react/field-types/String/default.hbs | 1 + templates/react/field-types/String/sample.hbs | 1 + templates/react/field-types/String/type.hbs | 1 + .../DateTimePicker/detail/render.hbs | 1 + .../Timestamp/DateTimePicker/edit/render.hbs | 26 ++ .../DateTimePicker/initial-value.hbs | 1 + .../Timestamp/DateTimePicker/is-valid.hbs | 1 + .../react/field-types/Timestamp/default.hbs | 1 + .../react/field-types/Timestamp/sample.hbs | 1 + .../react/field-types/Timestamp/type.hbs | 1 + templates/react/field-types/Vec/default.hbs | 1 + .../react/field-types/Vec/detail/render.hbs | 6 + .../react/field-types/Vec/edit/render.hbs | 12 + templates/react/field-types/Vec/type.hbs | 1 + .../bool/Checkbox/detail/render.hbs | 1 + .../field-types/bool/Checkbox/edit/render.hbs | 25 ++ .../bool/Checkbox/initial-value.hbs | 1 + .../field-types/bool/Checkbox/is-valid.hbs | 1 + templates/react/field-types/bool/default.hbs | 1 + templates/react/field-types/bool/sample.hbs | 1 + templates/react/field-types/bool/type.hbs | 1 + .../f32/NumberInput/detail/render.hbs | 1 + .../f32/NumberInput/edit/render.hbs | 22 ++ .../f32/NumberInput/initial-value.hbs | 1 + .../field-types/f32/NumberInput/is-valid.hbs | 1 + templates/react/field-types/f32/default.hbs | 1 + templates/react/field-types/f32/sample.hbs | 1 + templates/react/field-types/f32/type.hbs | 1 + .../i32/NumberInput/detail/render.hbs | 1 + .../i32/NumberInput/edit/render.hbs | 22 ++ .../i32/NumberInput/initial-value.hbs | 1 + .../field-types/i32/NumberInput/is-valid.hbs | 1 + templates/react/field-types/i32/default.hbs | 1 + templates/react/field-types/i32/sample.hbs | 1 + templates/react/field-types/i32/type.hbs | 1 + .../u32/NumberInput/detail/render.hbs | 1 + .../u32/NumberInput/edit/render.hbs | 23 ++ .../u32/NumberInput/initial-value.hbs | 1 + .../field-types/u32/NumberInput/is-valid.hbs | 1 + templates/react/field-types/u32/default.hbs | 1 + templates/react/field-types/u32/sample.hbs | 1 + templates/react/field-types/u32/type.hbs | 1 + ...nceable.name)}}.test.ts{{\302\241if}}.hbs" | 158 ++++++++++ ...eferenceable.name}}.tsx{{\302\241if}}.hbs" | 72 +++++ ...eferenceable.name}}.tsx{{\302\241if}}.hbs" | 72 +++++ .../web-app/.github/workflows/test.yaml.hbs | 30 ++ templates/react/web-app/README.md.hbs | 59 ++++ templates/react/web-app/package.json.hbs | 38 +++ .../react/web-app/tests/package.json.hbs | 15 + .../react/web-app/tests/tsconfig.json.hbs | 9 + .../react/web-app/tests/vitest.config.ts.hbs | 9 + templates/react/web-app/ui/.gitignore.hbs | 24 ++ templates/react/web-app/ui/index.html.hbs | 12 + templates/react/web-app/ui/package.json.hbs | 31 ++ templates/react/web-app/ui/src/App.css.hbs | 30 ++ templates/react/web-app/ui/src/App.tsx.hbs | 32 ++ .../web-app/ui/src/ClientContext.tsx.hbs | 98 ++++++ .../ui/src/assets/holochainLogo.svg.hbs | 45 +++ templates/react/web-app/ui/src/index.css.hbs | 259 ++++++++++++++++ templates/react/web-app/ui/src/main.tsx.hbs | 14 + .../react/web-app/ui/src/vite-env.d.ts.hbs | 1 + templates/react/web-app/ui/tsconfig.json.hbs | 26 ++ .../react/web-app/ui/tsconfig.node.json.hbs | 11 + templates/react/web-app/ui/vite.config.ts.hbs | 7 + 104 files changed, 2344 insertions(+), 9 deletions(-) create mode 100644 templates/react/collection.instructions.hbs create mode 100644 templates/react/collection/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case collection_name}}.test.ts.hbs create mode 100644 templates/react/collection/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case collection_name}}.tsx.hbs create mode 100644 templates/react/coordinator-zome/tests/src/{{dna_role_name}}/{{zome_manifest.name}}/common.ts.hbs create mode 100644 templates/react/coordinator-zome/ui/src/{{dna_role_name}}/{{zome_manifest.name}}/types.ts.hbs create mode 100644 templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/common.ts.hbs create mode 100644 templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case entry_type.name}}.test.ts.hbs create mode 100644 templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/Create{{pascal_case entry_type.name}}.tsx.hbs create mode 100644 templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/types.ts.hbs create mode 100644 "templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#each entry_type.fields}}{{#if (and linked_from (not (eq linked_from.hash_type 'AgentPubKey') ) )}}{{pascal_case (plural ..\302\241entry_type.name)}}For{{pascal_case linked_from.name}}.tsx{{\302\241if}}{{\302\241each}}.hbs" create mode 100644 "templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if crud.update}}Edit{{pascal_case entry_type.name}}.tsx{{\302\241if}}.hbs" create mode 100644 templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case entry_type.name}}Detail.tsx.hbs create mode 100644 templates/react/example.instructions.hbs create mode 100644 templates/react/example/ui/src/App.tsx.hbs create mode 100644 templates/react/field-types/ActionHash/sample.hbs create mode 100644 templates/react/field-types/ActionHash/type.hbs create mode 100644 templates/react/field-types/AgentPubKey/sample.hbs create mode 100644 templates/react/field-types/AgentPubKey/type.hbs create mode 100644 templates/react/field-types/DnaHash/sample.hbs create mode 100644 templates/react/field-types/DnaHash/type.hbs create mode 100644 templates/react/field-types/EntryHash/sample.hbs create mode 100644 templates/react/field-types/EntryHash/type.hbs create mode 100644 templates/react/field-types/Enum/Select/detail/render.hbs create mode 100644 templates/react/field-types/Enum/Select/edit/render.hbs create mode 100644 templates/react/field-types/Enum/Select/initial-value.hbs create mode 100644 templates/react/field-types/Enum/Select/is-valid.hbs create mode 100644 templates/react/field-types/Enum/default.hbs create mode 100644 templates/react/field-types/Enum/sample.hbs create mode 100644 templates/react/field-types/Enum/type.hbs create mode 100644 templates/react/field-types/String/TextArea/detail/render.hbs create mode 100644 templates/react/field-types/String/TextArea/edit/render.hbs create mode 100644 templates/react/field-types/String/TextArea/initial-value.hbs create mode 100644 templates/react/field-types/String/TextArea/is-valid.hbs create mode 100644 templates/react/field-types/String/TextField/detail/render.hbs create mode 100644 templates/react/field-types/String/TextField/edit/render.hbs create mode 100644 templates/react/field-types/String/TextField/initial-value.hbs create mode 100644 templates/react/field-types/String/TextField/is-valid.hbs create mode 100644 templates/react/field-types/String/default.hbs create mode 100644 templates/react/field-types/String/sample.hbs create mode 100644 templates/react/field-types/String/type.hbs create mode 100644 templates/react/field-types/Timestamp/DateTimePicker/detail/render.hbs create mode 100644 templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs create mode 100644 templates/react/field-types/Timestamp/DateTimePicker/initial-value.hbs create mode 100644 templates/react/field-types/Timestamp/DateTimePicker/is-valid.hbs create mode 100644 templates/react/field-types/Timestamp/default.hbs create mode 100644 templates/react/field-types/Timestamp/sample.hbs create mode 100644 templates/react/field-types/Timestamp/type.hbs create mode 100644 templates/react/field-types/Vec/default.hbs create mode 100644 templates/react/field-types/Vec/detail/render.hbs create mode 100644 templates/react/field-types/Vec/edit/render.hbs create mode 100644 templates/react/field-types/Vec/type.hbs create mode 100644 templates/react/field-types/bool/Checkbox/detail/render.hbs create mode 100644 templates/react/field-types/bool/Checkbox/edit/render.hbs create mode 100644 templates/react/field-types/bool/Checkbox/initial-value.hbs create mode 100644 templates/react/field-types/bool/Checkbox/is-valid.hbs create mode 100644 templates/react/field-types/bool/default.hbs create mode 100644 templates/react/field-types/bool/sample.hbs create mode 100644 templates/react/field-types/bool/type.hbs create mode 100644 templates/react/field-types/f32/NumberInput/detail/render.hbs create mode 100644 templates/react/field-types/f32/NumberInput/edit/render.hbs create mode 100644 templates/react/field-types/f32/NumberInput/initial-value.hbs create mode 100644 templates/react/field-types/f32/NumberInput/is-valid.hbs create mode 100644 templates/react/field-types/f32/default.hbs create mode 100644 templates/react/field-types/f32/sample.hbs create mode 100644 templates/react/field-types/f32/type.hbs create mode 100644 templates/react/field-types/i32/NumberInput/detail/render.hbs create mode 100644 templates/react/field-types/i32/NumberInput/edit/render.hbs create mode 100644 templates/react/field-types/i32/NumberInput/initial-value.hbs create mode 100644 templates/react/field-types/i32/NumberInput/is-valid.hbs create mode 100644 templates/react/field-types/i32/default.hbs create mode 100644 templates/react/field-types/i32/sample.hbs create mode 100644 templates/react/field-types/i32/type.hbs create mode 100644 templates/react/field-types/u32/NumberInput/detail/render.hbs create mode 100644 templates/react/field-types/u32/NumberInput/edit/render.hbs create mode 100644 templates/react/field-types/u32/NumberInput/initial-value.hbs create mode 100644 templates/react/field-types/u32/NumberInput/is-valid.hbs create mode 100644 templates/react/field-types/u32/default.hbs create mode 100644 templates/react/field-types/u32/sample.hbs create mode 100644 templates/react/field-types/u32/type.hbs create mode 100644 "templates/react/link-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if to_referenceable}}{{kebab_case from_referenceable.name}}-to-{{kebab_case (plural to_referenceable.name)}}.test.ts{{\302\241if}}.hbs" create mode 100644 "templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and bidireccional (and to_referenceable (ne from_referenceable.hash_type 'AgentPubKey')))}}{{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}.tsx{{\302\241if}}.hbs" create mode 100644 "templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and to_referenceable (ne to_referenceable.hash_type 'AgentPubKey'))}}{{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}.tsx{{\302\241if}}.hbs" create mode 100644 templates/react/web-app/.github/workflows/test.yaml.hbs create mode 100644 templates/react/web-app/README.md.hbs create mode 100644 templates/react/web-app/package.json.hbs create mode 100644 templates/react/web-app/tests/package.json.hbs create mode 100644 templates/react/web-app/tests/tsconfig.json.hbs create mode 100644 templates/react/web-app/tests/vitest.config.ts.hbs create mode 100644 templates/react/web-app/ui/.gitignore.hbs create mode 100644 templates/react/web-app/ui/index.html.hbs create mode 100644 templates/react/web-app/ui/package.json.hbs create mode 100644 templates/react/web-app/ui/src/App.css.hbs create mode 100644 templates/react/web-app/ui/src/App.tsx.hbs create mode 100644 templates/react/web-app/ui/src/ClientContext.tsx.hbs create mode 100644 templates/react/web-app/ui/src/assets/holochainLogo.svg.hbs create mode 100644 templates/react/web-app/ui/src/index.css.hbs create mode 100644 templates/react/web-app/ui/src/main.tsx.hbs create mode 100644 templates/react/web-app/ui/src/vite-env.d.ts.hbs create mode 100644 templates/react/web-app/ui/tsconfig.json.hbs create mode 100644 templates/react/web-app/ui/tsconfig.node.json.hbs create mode 100644 templates/react/web-app/ui/vite.config.ts.hbs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 06d8f221d..c45969d1c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - template: [ svelte, vue, lit, vanilla ] + template: [ svelte, vue, lit, react, vanilla ] steps: - uses: actions/checkout@v4 diff --git a/src/cli.rs b/src/cli.rs index 41f9d0b48..acd7f0e53 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,7 +45,7 @@ use structopt::StructOpt; pub struct HcScaffold { #[structopt(short, long)] /// The template to use for the scaffold command - /// Can either be an option from the built-in templates: "vanilla", "vue", "lit", "svelte", "headless" + /// Can either be an option from the built-in templates: "vanilla", "vue", "lit", "svelte", "react", "headless" /// Or a path to a custom template template: Option, @@ -233,7 +233,7 @@ impl HcScaffold { // get the template file tree and the ui framework name or custom template path let (template, template_file_tree) = match template { Some(template) => match template.to_lowercase().as_str() { - "lit" | "svelte" | "vanilla" | "vue" | "headless" => { + "lit" | "svelte" | "vanilla" | "vue" | "react" | "headless" => { let ui_framework = UiFramework::from_str(template)?; (ui_framework.name(), ui_framework.template_filetree()?) } @@ -938,11 +938,7 @@ impl HcScaffoldTemplate { match self { HcScaffoldTemplate::Clone { .. } => { - println!( - r#"Template initialized to folder {:?} -"#, - target_template - ); + println!(r#"Template initialized to folder {:?} "#, target_template); } } Ok(()) diff --git a/src/scaffold/web_app/uis.rs b/src/scaffold/web_app/uis.rs index 0427882c3..26d1c78c1 100644 --- a/src/scaffold/web_app/uis.rs +++ b/src/scaffold/web_app/uis.rs @@ -12,6 +12,7 @@ static LIT_TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates static SVELTE_TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/svelte"); static VUE_TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/vue"); static VANILLA_TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/vanilla"); +static REACT_TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/react"); static HEADLESS_TEMPLATE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/headless"); #[derive(Debug, Clone)] @@ -20,6 +21,7 @@ pub enum UiFramework { Lit, Svelte, Vue, + React, Headless, } @@ -31,6 +33,7 @@ impl UiFramework { UiFramework::Lit => "lit", UiFramework::Svelte => "svelte", UiFramework::Vue => "vue", + UiFramework::React => "react", UiFramework::Headless => "headless", }; name.to_string() @@ -42,6 +45,7 @@ impl UiFramework { UiFramework::Vanilla => &VANILLA_TEMPLATES, UiFramework::Svelte => &SVELTE_TEMPLATES, UiFramework::Vue => &VUE_TEMPLATES, + UiFramework::React => &REACT_TEMPLATES, UiFramework::Headless => &HEADLESS_TEMPLATE, }; dir_to_file_tree(dir) @@ -52,6 +56,7 @@ impl UiFramework { UiFramework::Lit, UiFramework::Svelte, UiFramework::Vue, + UiFramework::React, UiFramework::Vanilla, UiFramework::Headless, ]; @@ -64,7 +69,12 @@ impl UiFramework { } pub fn choose_non_vanilla() -> ScaffoldResult { - let frameworks = [UiFramework::Lit, UiFramework::Svelte, UiFramework::Vue]; + let frameworks = [ + UiFramework::Lit, + UiFramework::Svelte, + UiFramework::React, + UiFramework::Vue, + ]; let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Choose UI framework: (Use arrow-keys. Return to submit)") .default(0) @@ -98,6 +108,8 @@ impl TryFrom<&FileTree> for UiFramework { return Ok(UiFramework::Svelte); } else if ui_package_json.contains("vue") { return Ok(UiFramework::Vue); + } else if ui_package_json.contains("react") { + return Ok(UiFramework::React); } else if !dir_exists(app_file_tree, &PathBuf::from("ui/src")) { return Ok(UiFramework::Vanilla); } @@ -112,6 +124,7 @@ impl std::fmt::Display for UiFramework { UiFramework::Vanilla => "vanilla".yellow(), UiFramework::Lit => "lit".bright_blue(), UiFramework::Svelte => "svelte".bright_red(), + UiFramework::React => "react".cyan(), UiFramework::Vue => "vue".green(), UiFramework::Headless => "headless (no ui)".italic(), }; @@ -127,6 +140,7 @@ impl FromStr for UiFramework { "vanilla" => Ok(UiFramework::Vanilla), "svelte" => Ok(UiFramework::Svelte), "vue" => Ok(UiFramework::Vue), + "react" => Ok(UiFramework::React), "lit" => Ok(UiFramework::Lit), "headless" => Ok(UiFramework::Headless), value => Err(ScaffoldError::MalformedTemplate(format!( diff --git a/templates/react/collection.instructions.hbs b/templates/react/collection.instructions.hbs new file mode 100644 index 000000000..e9aba65a1 --- /dev/null +++ b/templates/react/collection.instructions.hbs @@ -0,0 +1,4 @@ +{{#if (eq collection_type.type "Global")}} +At first, the UI for this application is empty. If you want the newly scaffolded collection to be the entry point for its UI, import the newly +generated <{{pascal_case collection_name}} /> component +{{/if}} \ No newline at end of file diff --git a/templates/react/collection/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case collection_name}}.test.ts.hbs b/templates/react/collection/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case collection_name}}.test.ts.hbs new file mode 100644 index 000000000..7694dcbda --- /dev/null +++ b/templates/react/collection/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case collection_name}}.test.ts.hbs @@ -0,0 +1,77 @@ +import { assert, test } from "vitest"; + +import { runScenario, dhtSync, CallableCell } from '@holochain/tryorama'; +import { + NewEntryAction, + ActionHash, + Record, + Link, + AppBundleSource, + fakeActionHash, + fakeAgentPubKey, + fakeEntryHash +} from '@holochain/client'; +import { decode } from '@msgpack/msgpack'; + +import { create{{pascal_case referenceable.name}} } from './common.js'; + +test('create a {{pascal_case referenceable.name}} and get {{lower_case collection_name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + + // Bob gets {{lower_case collection_name}} + let collectionOutput: Link[] = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case collection_name}}", + payload: {{#if (eq collection_type.type "Global")}}null{{else}}alice.agentPubKey{{/if}} + }); + assert.equal(collectionOutput.length, 0); + + // Alice creates a {{pascal_case referenceable.name}} + const createRecord: Record = await create{{pascal_case referenceable.name}}(alice.cells[0]); + assert.ok(createRecord); + + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets {{lower_case collection_name}} again + collectionOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case collection_name}}", + payload: {{#if (eq collection_type.type "Global")}}null{{else}}alice.agentPubKey{{/if}} + }); + assert.equal(collectionOutput.length, 1); + assert.deepEqual({{#if (eq referenceable.hash_type "EntryHash")}}(createRecord.signed_action.hashed.content as NewEntryAction).entry_hash{{else}}createRecord.signed_action.hashed.hash{{/if}}, collectionOutput[0].target); +{{#if (and deletable (eq referenceable.hash_type "ActionHash"))}} + + // Alice deletes the {{pascal_case referenceable.name}} + await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "delete_{{snake_case referenceable.name}}", + payload: createRecord.signed_action.hashed.hash + }); + + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets {{lower_case collection_name}} again + collectionOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case collection_name}}", + payload: {{#if (eq collection_type.type "Global")}}null{{else}}alice.agentPubKey{{/if}} + }); + assert.equal(collectionOutput.length, 0); +{{/if}} + }); +}); diff --git a/templates/react/collection/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case collection_name}}.tsx.hbs b/templates/react/collection/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case collection_name}}.tsx.hbs new file mode 100644 index 000000000..c9c83cfcb --- /dev/null +++ b/templates/react/collection/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case collection_name}}.tsx.hbs @@ -0,0 +1,82 @@ +import { Link, AppSignal, HolochainError{{#if (eq collection_type.type "ByAuthor")}}, AgentPubKey{{/if}}{{#if (eq referenceable.hash_type "EntryHash")}}, NewEntryAction{{/if}} } from '@holochain/client'; +import { FC, useCallback, useState, useEffect, useContext } from 'react'; + +import type { {{pascal_case coordinator_zome_manifest.name}}Signal } from './types'; +import {{pascal_case referenceable.name}}Detail from './{{pascal_case referenceable.name}}Detail'; +import { ClientContext } from '../../ClientContext'; + +const {{pascal_case collection_name}}: FC{{#if (eq collection_type.type "ByAuthor")}}<{{pascal_case collection_name}}Props>{{/if}} = ({{#if (eq collection_type.type "ByAuthor")}}{author}{{/if}}) => { + const {client} = useContext(ClientContext); + const [hashes, setHashes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const fetch{{pascal_case (plural referenceable.name)}} = useCallback(async () => { + setLoading(true) + try { + const links: Link[] = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{snake_case coordinator_zome_manifest.name}}', + fn_name: 'get_{{snake_case collection_name}}', + payload: {{#if (eq collection_type.type "ByAuthor")}}author{{else}}null{{/if}}, + }); + if (links?.length) { + setHashes(links.map((l) => l.target)); + } + } catch (e) { + setError(e as HolochainError); + } finally { + setLoading(false); + } + }, [client{{#if (eq collection_type.type "ByAuthor")}}, author{{/if}}]); + + const handleSignal = useCallback((signal: AppSignal) => { + if (signal.zome_name !== '{{coordinator_zome_manifest.name}}') return; + const payload = signal.payload as {{pascal_case coordinator_zome_manifest.name}}Signal; + if (payload.type !== 'EntryCreated') return; + if (payload.app_entry.type !== '{{pascal_case referenceable.name}}') return; +{{#if (eq collection_type.type "ByAuthor")}} + if (author.toString() !== client?.myPubKey.toString()) return; +{{/if}} + setHashes((prevHashes) => [...prevHashes, {{#if (eq referenceable.hash_type "ActionHash")}}payload.action.hashed.hash{{else}}(payload.action.hashed.content as NewEntryAction).entry_hash{{/if}}]); + }, [setHashes]); + + useEffect(() => { +{{#if (eq collection_type.type "ByAuthor")}} + if (author === undefined) { + throw new Error(`The author prop is required for the {{pascal_case collection_name}} element`); + } +{{/if}} + fetch{{pascal_case (plural referenceable.name)}}(); + client?.on('signal', handleSignal); + }, [client, handleSignal, fetch{{pascal_case (plural referenceable.name)}}{{#if (eq collection_type.type "ByAuthor")}}, author{{/if}}]); + + if (loading) { + return ; + } + + return ( +
+ {error ? ( + Error fetching the {{lower_case (plural referenceable.name)}}: {error.message} + ) : hashes.length > 0 ? ( +
+ {hashes.map((hash, i) => ( + <{{pascal_case referenceable.name}}Detail key={i} {{camel_case referenceable.name}}Hash={hash} on{{pascal_case referenceable.name}}Deleted={fetch{{pascal_case (plural referenceable.name)}}} /> + ))} +
+ ) : ( +
No {{lower_case (plural referenceable.name)}} found{{#if (eq collection_type.type "ByAuthor")}} for this author{{/if}}.
+ )} +
+ ); +}; + +{{#if (eq collection_type.type "ByAuthor")}} +interface {{pascal_case collection_name}}Props { + author: AgentPubKey +} +{{/if}} + +export default {{pascal_case collection_name}}; diff --git a/templates/react/coordinator-zome/tests/src/{{dna_role_name}}/{{zome_manifest.name}}/common.ts.hbs b/templates/react/coordinator-zome/tests/src/{{dna_role_name}}/{{zome_manifest.name}}/common.ts.hbs new file mode 100644 index 000000000..e1b810614 --- /dev/null +++ b/templates/react/coordinator-zome/tests/src/{{dna_role_name}}/{{zome_manifest.name}}/common.ts.hbs @@ -0,0 +1,3 @@ +import { CallableCell } from '@holochain/tryorama'; +import { NewEntryAction, ActionHash, Record, AppBundleSource, fakeActionHash, fakeAgentPubKey, fakeEntryHash, fakeDnaHash } from '@holochain/client'; + diff --git a/templates/react/coordinator-zome/ui/src/{{dna_role_name}}/{{zome_manifest.name}}/types.ts.hbs b/templates/react/coordinator-zome/ui/src/{{dna_role_name}}/{{zome_manifest.name}}/types.ts.hbs new file mode 100644 index 000000000..78ccdd343 --- /dev/null +++ b/templates/react/coordinator-zome/ui/src/{{dna_role_name}}/{{zome_manifest.name}}/types.ts.hbs @@ -0,0 +1,38 @@ +import type { + Record, + ActionHash, + DnaHash, + SignedActionHashed, + EntryHash, + AgentPubKey, + Create, + Update, + Delete, + CreateLink, + DeleteLink +} from '@holochain/client'; + +export type {{pascal_case zome_manifest.name}}Signal = { + type: 'EntryCreated'; + action: SignedActionHashed; + app_entry: EntryTypes; +} | { + type: 'EntryUpdated'; + action: SignedActionHashed; + app_entry: EntryTypes; + original_app_entry: EntryTypes; +} | { + type: 'EntryDeleted'; + action: SignedActionHashed; + original_app_entry: EntryTypes; +} | { + type: 'LinkCreated'; + action: SignedActionHashed; + link_type: string; +} | { + type: 'LinkDeleted'; + action: SignedActionHashed; + link_type: string; +}; + +export type EntryTypes = {}; diff --git a/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/common.ts.hbs b/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/common.ts.hbs new file mode 100644 index 000000000..6009ec1a2 --- /dev/null +++ b/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/common.ts.hbs @@ -0,0 +1,53 @@ +{{previous_file_content}} + +export async function sample{{pascal_case entry_type.name}}(cell: CallableCell, partial{{pascal_case entry_type.name}} = {}) { + return { + ...{ +{{#each entry_type.fields}} + {{#if linked_from}} + {{#if (ne linked_from.hash_type "AgentPubKey")}} + {{#if (eq cardinality "vector")}} + {{#if (eq (pascal_case linked_from.name) (pascal_case ../entry_type.name))}} + {{field_name}}: [], + {{else}} + {{#if (eq linked_from.hash_type "ActionHash")}} + {{field_name}}: [(await create{{pascal_case linked_from.name}}(cell)).signed_action.hashed.hash], + {{else}} + {{field_name}}: [((await create{{pascal_case linked_from.name}}(cell)).signed_action.hashed.content as NewEntryAction).entry_hash], + {{/if}} + {{/if}} + {{else}} + {{#if (eq (pascal_case linked_from.name) (pascal_case ../entry_type.name))}} + {{field_name}}: null, + {{else}} + {{#if (eq linked_from.hash_type "ActionHash")}} + {{field_name}}: (await create{{pascal_case linked_from.name}}(cell)).signed_action.hashed.hash, + {{else}} + {{field_name}}: ((await create{{pascal_case linked_from.name}}(cell)).signed_action.hashed.content as NewEntryAction).entry_hash, + {{/if}} + {{/if}} + {{/if}} + {{else}} + {{field_name}}: cell.cell_id[1], + {{/if}} + {{else}} + {{#if (eq cardinality "vector")}} + {{field_name}}: [{{> (concat field_type.type "/sample") field_type=field_type}}], + {{else}} + {{field_name}}: {{> (concat field_type.type "/sample") field_type=field_type}}, + {{/if}} + {{/if}} +{{/each}} + }, + ...partial{{pascal_case entry_type.name}} + }; +} + +export async function create{{pascal_case entry_type.name}}(cell: CallableCell, {{camel_case entry_type.name}} = undefined): Promise { + return cell.callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "create_{{snake_case entry_type.name}}", + payload: {{camel_case entry_type.name}} || await sample{{pascal_case entry_type.name}}(cell), + }); +} + diff --git a/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case entry_type.name}}.test.ts.hbs b/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case entry_type.name}}.test.ts.hbs new file mode 100644 index 000000000..8d5608c64 --- /dev/null +++ b/templates/react/entry-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{kebab_case entry_type.name}}.test.ts.hbs @@ -0,0 +1,279 @@ +import { assert, test } from "vitest"; + +import { runScenario, dhtSync, CallableCell } from '@holochain/tryorama'; +import { + NewEntryAction, + ActionHash, + Record, + Link, + CreateLink, + DeleteLink, + SignedActionHashed, + AppBundleSource, + fakeActionHash, + fakeAgentPubKey, + fakeEntryHash +} from '@holochain/client'; +import { decode } from '@msgpack/msgpack'; + +import { create{{pascal_case entry_type.name}}, sample{{pascal_case entry_type.name}} } from './common.js'; + +test('create {{pascal_case entry_type.name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + + // Alice creates a {{pascal_case entry_type.name}} + const record: Record = await create{{pascal_case entry_type.name}}(alice.cells[0]); + assert.ok(record); + }); +}); + +test('create and read {{pascal_case entry_type.name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + + const sample = await sample{{pascal_case entry_type.name}}(alice.cells[0]); + + // Alice creates a {{pascal_case entry_type.name}} + const record: Record = await create{{pascal_case entry_type.name}}(alice.cells[0], sample); + assert.ok(record); + + // Wait for the created entry to be propagated to the other node. + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the created {{pascal_case entry_type.name}} + const createReadOutput: Record = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "{{#if crud.update}}get_original_{{snake_case entry_type.name}}{{else}}get_{{snake_case entry_type.name}}{{/if}}", + payload: {{#if entry_type.reference_entry_hash}}(record.signed_action.hashed.content as NewEntryAction).entry_hash{{else}}record.signed_action.hashed.hash{{/if}}, + }); + assert.deepEqual(sample, decode((createReadOutput.entry as any).Present.entry) as any); + + {{#each entry_type.fields}} + {{#if linked_from}} + {{#if (ne (pascal_case linked_from.name) (pascal_case ../entry_type.name))}} + // Bob gets the {{pascal_case (plural linked_from.name)}} for the new {{pascal_case ../entry_type.name}} + let linksTo{{pascal_case (plural linked_from.name)}}: Link[] = await bob.cells[0].callZome({ + zome_name: "{{../coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case (plural ../entry_type.name)}}_for_{{snake_case linked_from.name}}", + payload: {{#if (eq cardinality "vector")}}sample.{{field_name}}[0]{{else}}sample.{{field_name}}{{/if}} + }); + assert.equal(linksTo{{pascal_case (plural linked_from.name)}}.length, 1); + assert.deepEqual(linksTo{{pascal_case (plural linked_from.name)}}[0].target, {{#if ../entry_type.reference_entry_hash}}(record.signed_action.hashed.content as NewEntryAction).entry_hash{{else}}record.signed_action.hashed.hash{{/if}}); + {{/if}} + {{/if}} + {{/each}} + }); +}); + +{{#if crud.update}} +test('create and update {{pascal_case entry_type.name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + + // Alice creates a {{pascal_case entry_type.name}} + const record: Record = await create{{pascal_case entry_type.name}}(alice.cells[0]); + assert.ok(record); + + const originalActionHash = record.signed_action.hashed.hash; + + // Alice updates the {{pascal_case entry_type.name}} + let contentUpdate: any = await sample{{pascal_case entry_type.name}}(alice.cells[0]); + let updateInput = { +{{#if link_from_original_to_each_update}} + original_{{snake_case entry_type.name}}_hash: originalActionHash, +{{/if}} + previous_{{snake_case entry_type.name}}_hash: originalActionHash, + updated_{{snake_case entry_type.name}}: contentUpdate, + }; + + let updatedRecord: Record = await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "update_{{snake_case entry_type.name}}", + payload: updateInput, + }); + assert.ok(updatedRecord); + + // Wait for the updated entry to be propagated to the other node. + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the updated {{pascal_case entry_type.name}} + const readUpdatedOutput0: Record = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_latest_{{snake_case entry_type.name}}", + payload: updatedRecord.signed_action.hashed.hash, + }); + assert.deepEqual(contentUpdate, decode((readUpdatedOutput0.entry as any).Present.entry) as any); + + // Alice updates the {{pascal_case entry_type.name}} again + contentUpdate = await sample{{pascal_case entry_type.name}}(alice.cells[0]); + updateInput = { +{{#if link_from_original_to_each_update}} + original_{{snake_case entry_type.name}}_hash: originalActionHash, +{{/if}} + previous_{{snake_case entry_type.name}}_hash: updatedRecord.signed_action.hashed.hash, + updated_{{snake_case entry_type.name}}: contentUpdate, + }; + + updatedRecord = await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "update_{{snake_case entry_type.name}}", + payload: updateInput, + }); + assert.ok(updatedRecord); + + // Wait for the updated entry to be propagated to the other node. + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the updated {{pascal_case entry_type.name}} + const readUpdatedOutput1: Record = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_latest_{{snake_case entry_type.name}}", + payload: updatedRecord.signed_action.hashed.hash, + }); + assert.deepEqual(contentUpdate, decode((readUpdatedOutput1.entry as any).Present.entry) as any); + + // Bob gets all the revisions for {{pascal_case entry_type.name}} + const revisions: Record[] = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_all_revisions_for_{{snake_case entry_type.name}}", + payload: originalActionHash, + }); + assert.equal(revisions.length, 3); + assert.deepEqual(contentUpdate, decode((revisions[2].entry as any).Present.entry) as any); + }); +}); +{{/if}} + +{{#if crud.delete}} +test('create and delete {{pascal_case entry_type.name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + + const sample = await sample{{pascal_case entry_type.name}}(alice.cells[0]); + + // Alice creates a {{pascal_case entry_type.name}} + const record: Record = await create{{pascal_case entry_type.name}}(alice.cells[0], sample); + assert.ok(record); + + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + {{#each entry_type.fields}} + {{#if linked_from}} + {{#if (ne (pascal_case linked_from.name) (pascal_case ../entry_type.name))}} + // Bob gets the {{pascal_case (plural linked_from.name)}} for the new {{pascal_case ../entry_type.name}} + let linksTo{{pascal_case (plural linked_from.name)}}: Link[] = await bob.cells[0].callZome({ + zome_name: "{{../coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case (plural ../entry_type.name)}}_for_{{snake_case linked_from.name}}", + payload: {{#if (eq cardinality "vector")}}sample.{{field_name}}[0]{{else}}sample.{{field_name}}{{/if}} + }); + assert.equal(linksTo{{pascal_case (plural linked_from.name)}}.length, 1); + assert.deepEqual(linksTo{{pascal_case (plural linked_from.name)}}[0].target, {{#if ../entry_type.reference_entry_hash}}(record.signed_action.hashed.content as NewEntryAction).entry_hash{{else}}record.signed_action.hashed.hash{{/if}}); + {{/if}} + {{/if}} + {{/each}} + + // Alice deletes the {{pascal_case entry_type.name}} + const deleteActionHash = await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "delete_{{snake_case entry_type.name}}", + payload: record.signed_action.hashed.hash, + }); + assert.ok(deleteActionHash); + + // Wait for the entry deletion to be propagated to the other node. + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the oldest delete for the {{pascal_case entry_type.name}} + const oldestDeleteFor{{pascal_case entry_type.name}}: SignedActionHashed = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_oldest_delete_for_{{snake_case entry_type.name}}", + payload: record.signed_action.hashed.hash, + }); + assert.ok(oldestDeleteFor{{pascal_case entry_type.name}}); + + // Bob gets the deletions for the {{pascal_case entry_type.name}} + const deletesFor{{pascal_case entry_type.name}}: SignedActionHashed[] = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_all_deletes_for_{{snake_case entry_type.name}}", + payload: record.signed_action.hashed.hash, + }); + assert.equal(deletesFor{{title_case entry_type.name}}.length, 1); + + {{#each entry_type.fields}} + {{#if linked_from}} + {{#if (ne (pascal_case linked_from.name) (pascal_case ../entry_type.name))}} + // Bob gets the {{pascal_case (plural linked_from.name)}} for the {{pascal_case ../entry_type.name}} again + linksTo{{pascal_case (plural linked_from.name)}} = await bob.cells[0].callZome({ + zome_name: "{{../coordinator_zome_manifest.name}}", + fn_name: "get_{{snake_case (plural ../entry_type.name)}}_for_{{snake_case linked_from.name}}", + payload: {{#if (eq cardinality "vector")}}sample.{{field_name}}[0]{{else}}sample.{{field_name}}{{/if}} + }); + assert.equal(linksTo{{pascal_case (plural linked_from.name)}}.length, 0); + + // Bob gets the deleted {{pascal_case (plural linked_from.name)}} for the {{pascal_case ../entry_type.name}} + const deletedLinksTo{{pascal_case (plural linked_from.name)}}: Array<[SignedActionHashed, SignedActionHashed[]]> = await bob.cells[0].callZome({ + zome_name: "{{../coordinator_zome_manifest.name}}", + fn_name: "get_deleted_{{snake_case (plural ../entry_type.name)}}_for_{{snake_case linked_from.name}}", + payload: {{#if (eq cardinality "vector")}}sample.{{field_name}}[0]{{else}}sample.{{field_name}}{{/if}} + }); + assert.equal(deletedLinksTo{{pascal_case (plural linked_from.name)}}.length, 1); + {{/if}} + {{/if}} + {{/each}} + + }); +}); +{{/if}} diff --git a/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/Create{{pascal_case entry_type.name}}.tsx.hbs b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/Create{{pascal_case entry_type.name}}.tsx.hbs new file mode 100644 index 000000000..3b5e80733 --- /dev/null +++ b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/Create{{pascal_case entry_type.name}}.tsx.hbs @@ -0,0 +1,87 @@ +import type { AppClient, Record, EntryHash, AgentPubKey, ActionHash, DnaHash } from '@holochain/client'; +import { FC, useState, useContext, useEffect } from 'react'; + +import type { {{pascal_case entry_type.name}}{{#each entry_type.fields}}{{#if (eq field_type.type "Enum")}}, {{field_type.label}}{{/if}}{{/each}} } from './types'; +import { ClientContext } from '../../ClientContext'; +{{#uniq_lines}} + {{#each entry_type.fields}} + {{#if widget}} +{{> (concat field_type.type "/" widget "/edit/imports") }} + + {{/if}} + {{/each}} +{{/uniq_lines}} + +const Create{{pascal_case entry_type.name}}: FC = ({ on{{pascal_case entry_type.name}}Created{{#each entry_type.fields}}{{#if (not widget) }}{{#if (eq cardinality "vector")}}, {{camel_case field_name}}{{else}}, {{camel_case field_name}}{{/if}}{{/if}}{{/each}} }) => { + const {client} = useContext(ClientContext); +{{#each entry_type.fields}} + {{#if widget }} + {{#if (not (eq cardinality "vector" ) )}} + const [{{camel_case field_name}}, set{{pascal_case field_name}}] = useState<{{> (concat field_type.type "/type")}}>({{> (concat field_type.type "/default")}}); + {{else}} + const [{{camel_case field_name}}, set{{pascal_case field_name}}] = useState<{{> (concat field_type.type "/type")}}[]>({{> (concat field_type.type "/default")}}); + {{/if}} + {{/if}} +{{/each}} + const [is{{pascal_case entry_type.name}}Valid, setIs{{pascal_case entry_type.name}}Valid] = useState(false); + + const create{{pascal_case entry_type.name}} = async () => { + const {{camel_case entry_type.name}}Entry: {{pascal_case entry_type.name}} = { +{{#each entry_type.fields}} + {{snake_case field_name}}: {{camel_case field_name}}{{#if (eq cardinality "single")}}!{{/if}}, +{{/each}} + }; + try { + const record = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{coordinator_zome_manifest.name}}', + fn_name: 'create_{{snake_case entry_type.name}}', + payload: {{camel_case entry_type.name}}Entry, + }); + on{{pascal_case entry_type.name}}Created && on{{pascal_case entry_type.name}}Created(record.signed_action.hashed.hash); + } catch (e) { + console.error(e) + } + }; + + useEffect(() => { + setIs{{pascal_case entry_type.name}}Valid(true{{#each entry_type.fields}}{{#if widget}}{{#if (eq cardinality "single")}} && {{> (concat field_type.type "/" widget "/is-valid") variable_to_validate=(camel_case field_name) }}{{/if}}{{#if (eq cardinality "vector")}} && {{camel_case field_name}}.every(e => {{> (concat field_type.type "/" widget "/is-valid") variable_to_validate="e" }}){{/if}}{{/if}}{{/each}}); + }, [{{#each entry_type.fields}}{{#if widget}}{{camel_case field_name}}{{#unless @last}}, {{/unless}}{{/if}}{{/each}}]); + + return ( +
+

Create {{pascal_case entry_type.name}}

+{{#each entry_type.fields}} + {{#if widget}} +
+ {{#if (not (eq cardinality "vector") )}} + {{> (concat field_type.type "/" widget "/edit/render") label=(title_case field_name) variable_to_read=(camel_case field_name) variable_to_change=(camel_case field_name) required=(eq cardinality "single") }} + {{else}} + {{> Vec/edit/render field_name=field_name field_type=field_type widget=widget }} + {{/if}} +
+ + {{/if}} +{{/each}} + +
+ ); +}; + +interface Create{{pascal_case entry_type.name}}Props { + on{{pascal_case entry_type.name}}Created?: (hash?: Uint8Array) => void, +{{#each entry_type.fields}} + {{#if (not widget) }} + {{#if (eq cardinality "vector")}} + {{camel_case field_name}}: {{> (concat field_type.type "/type") }}[], + {{else}} + {{camel_case field_name}}{{#if (eq cardinality "single")}}{{/if}}: {{> (concat field_type.type "/type") }}{{#if (eq cardinality "option")}} | undefined{{/if}}, + {{/if}} + {{/if}} +{{/each}} +} + +export default Create{{pascal_case entry_type.name}}; diff --git a/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/types.ts.hbs b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/types.ts.hbs new file mode 100644 index 000000000..1e8d8bf32 --- /dev/null +++ b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/types.ts.hbs @@ -0,0 +1,25 @@ +{{#if (includes previous_file_content "export type EntryTypes = {};")}} +{{replace previous_file_content "export type EntryTypes = {};" (concat "export type EntryTypes =\n | ({ type: '" (pascal_case entry_type.name) "'; } & " (pascal_case entry_type.name) ");")}} +{{else}} +{{replace previous_file_content "export type EntryTypes =" (concat "export type EntryTypes =\n | ({ type: '" (pascal_case entry_type.name) "'; } & " (pascal_case entry_type.name) ")")}} +{{/if}} + +{{#each entry_type.fields}} + {{#if (eq field_type.type "Enum")}} +export interface {{field_type.label}} { + type: + {{#each field_type.variants}} + | '{{this}}' + {{/each}}; +} + {{/if}} +{{/each}} + +export interface {{pascal_case entry_type.name}} { {{#each entry_type.fields}} + {{#if (not (eq cardinality "vector" ) )}} + {{snake_case field_name}}: {{> (concat field_type.type "/type") }}{{#if (eq cardinality "option")}} | undefined{{/if}}; + {{else}} + {{snake_case field_name}}: Array<{{> (concat field_type.type "/type") }}>; + {{/if}} + {{/each}} +} \ No newline at end of file diff --git "a/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#each entry_type.fields}}{{#if (and linked_from (not (eq linked_from.hash_type 'AgentPubKey') ) )}}{{pascal_case (plural ..\302\241entry_type.name)}}For{{pascal_case linked_from.name}}.tsx{{\302\241if}}{{\302\241each}}.hbs" "b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#each entry_type.fields}}{{#if (and linked_from (not (eq linked_from.hash_type 'AgentPubKey') ) )}}{{pascal_case (plural ..\302\241entry_type.name)}}For{{pascal_case linked_from.name}}.tsx{{\302\241if}}{{\302\241each}}.hbs" new file mode 100644 index 000000000..3e72bbf29 --- /dev/null +++ "b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#each entry_type.fields}}{{#if (and linked_from (not (eq linked_from.hash_type 'AgentPubKey') ) )}}{{pascal_case (plural ..\302\241entry_type.name)}}For{{pascal_case linked_from.name}}.tsx{{\302\241if}}{{\302\241each}}.hbs" @@ -0,0 +1,71 @@ +import { Link, AppSignal, HolochainError } from '@holochain/client'; +import { FC, useCallback, useState, useEffect, useContext } from 'react'; + +import {{pascal_case ../entry_type.name}}Detail from './{{pascal_case ../entry_type.name}}Detail'; +import type { {{pascal_case ../coordinator_zome_manifest.name}}Signal } from './types'; +import { ClientContext } from '../../ClientContext'; + +const {{pascal_case (plural ../entry_type.name)}}For{{pascal_case linked_from.name}}: FC<{{pascal_case (plural ../entry_type.name)}}For{{pascal_case linked_from.name}}Props> = ({ {{camel_case linked_from.singular_arg}} }) => { + const {client} = useContext(ClientContext); + const [hashes, setHashes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const fetch{{pascal_case (plural ../entry_type.name)}} = useCallback(async () => { + setLoading(true) + try { + const links: Link[] = await client?.callZome({ + cap_secret: null, + role_name: '{{../dna_role_name}}', + zome_name: '{{../coordinator_zome_manifest.name}}', + fn_name: 'get_{{snake_case (plural ../entry_type.name)}}_for_{{snake_case linked_from.name}}', + payload: {{camel_case linked_from.singular_arg}} + }); + if (links?.length) { + setHashes(links.map((l) => l.target)); + } + } catch (e) { + setError(e as HolochainError); + } finally { + setLoading(false); + } + }, [client]); + + const handleSignal = useCallback(async (signal: AppSignal) => { + if (signal.zome_name !== '{{../coordinator_zome_manifest.name}}') return; + const payload = signal.payload as {{pascal_case ../coordinator_zome_manifest.name}}Signal; + if (!(payload.type === 'EntryCreated' && payload.app_entry.type === '{{pascal_case ../entry_type.name}}')) return; + await fetch{{pascal_case (plural ../entry_type.name)}}(); + }, [fetch{{pascal_case (plural ../entry_type.name)}}]); + + useEffect(() => { + fetch{{pascal_case (plural ../entry_type.name)}}(); + client?.on('signal', handleSignal); + }, [client, handleSignal]); + + if (loading) { + return ; + } + + return ( +
+ {error ? ( +
Error fetching the {{plural ../entry_type.name}}: {error.message}
+ ) : hashes.length > 0 ? ( +
+ {hashes.map((hash, i) => ( + <{{pascal_case ../entry_type.name}}Detail key={i} {{camel_case ../entry_type.name}}Hash={hash} on{{pascal_case ../entry_type.name}}Deleted={fetch{{pascal_case (plural ../entry_type.name)}}} /> + ))} +
+ ) : ( +
No {{plural ../entry_type.name}} found for this {{lower_case linked_from.name}}.
+ )} +
+ ); +}; + +interface {{pascal_case (plural ../entry_type.name)}}For{{pascal_case linked_from.name}}Props { + {{camel_case linked_from.singular_arg}}: Uint8Array +} + +export default {{pascal_case (plural ../entry_type.name)}}For{{pascal_case linked_from.name}}; diff --git "a/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if crud.update}}Edit{{pascal_case entry_type.name}}.tsx{{\302\241if}}.hbs" "b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if crud.update}}Edit{{pascal_case entry_type.name}}.tsx{{\302\241if}}.hbs" new file mode 100644 index 000000000..402526321 --- /dev/null +++ "b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if crud.update}}Edit{{pascal_case entry_type.name}}.tsx{{\302\241if}}.hbs" @@ -0,0 +1,138 @@ +import { Record, HolochainError } from '@holochain/client'; +import { FC, useState, useEffect, useContext, useCallback } from 'react'; + +import type { {{pascal_case entry_type.name}}{{#each entry_type.fields}}{{#if (eq field_type.type "Enum")}}, {{field_type.label}}{{/if}}{{/each}} } from './types'; +import { ClientContext } from '../../ClientContext'; +{{#uniq_lines}} + {{#each entry_type.fields}} + {{#if widget}} +{{> (concat field_type.type "/" widget "/edit/imports") }} + + {{/if}} + {{/each}} +{{/uniq_lines}} + +const Edit{{pascal_case entry_type.name}}: FC = ({ +{{#if link_from_original_to_each_update}} + original{{pascal_case entry_type.name}}Hash, +{{/if}} + currentRecord, + current{{pascal_case entry_type.name}}, + on{{pascal_case entry_type.name}}Updated, + on{{pascal_case entry_type.name}}UpdateError, + onEditCanceled, +}) => { + const {client} = useContext(ClientContext); +{{#each entry_type.fields}} + {{#if widget }} + {{#if (not (eq cardinality "vector" ) )}} + const [{{camel_case field_name}}, set{{pascal_case field_name}}] = useState<{{> (concat field_type.type "/type")}} | undefined>(current{{pascal_case ../entry_type.name}}?.{{snake_case field_name}}); + {{else}} + const [{{camel_case field_name}}, set{{pascal_case field_name}}] = useState<{{> (concat field_type.type "/type")}}[] | undefined>(current{{pascal_case ../entry_type.name}}?.{{snake_case field_name}}); + {{/if}} + {{/if}} +{{/each}} + const [is{{pascal_case entry_type.name}}Valid, setIs{{pascal_case entry_type.name}}Valid] = useState(false); + + const update{{pascal_case entry_type.name}} = useCallback(async () => { + const {{camel_case entry_type.name}}: Partial<{{pascal_case entry_type.name}}> = { +{{#each entry_type.fields}} + {{#if widget}} + {{#if (eq cardinality "single") }} + {{snake_case field_name}}: {{camel_case field_name}}, + {{else}} + {{snake_case field_name}}: {{camel_case field_name}}{{#if (eq cardinality "vector") }} as {{> (concat field_type.type "/type") }}[]{{/if}}, + {{/if}} + {{else}} + {{/if}} +{{/each}} +{{#each entry_type.fields}} + {{#if (not widget)}} + {{snake_case field_name}}: current{{pascal_case ../entry_type.name}}?.{{snake_case field_name}}, + {{/if}} +{{/each}} + }; + try { + const updateRecord = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{coordinator_zome_manifest.name}}', + fn_name: 'update_{{snake_case entry_type.name}}', + payload: { + {{#if link_from_original_to_each_update}} + original_{{snake_case entry_type.name}}_hash: original{{pascal_case entry_type.name}}Hash, + {{/if}} + previous_{{snake_case entry_type.name}}_hash: currentRecord?.signed_action.hashed.hash, + updated_{{snake_case entry_type.name}}: {{camel_case entry_type.name}} + } + }); + on{{pascal_case entry_type.name}}Updated(updateRecord.signed_action.hashed.hash); + } catch (e) { + on{{pascal_case entry_type.name}}UpdateError && on{{pascal_case entry_type.name}}UpdateError(e as HolochainError) + } + }, [ + client, + currentRecord, + on{{pascal_case entry_type.name}}Updated, + on{{pascal_case entry_type.name}}UpdateError, + {{#if link_from_original_to_each_update}} + original{{pascal_case entry_type.name}}Hash, + {{/if}} +{{#each entry_type.fields}} + {{#if (not widget)}} + current{{pascal_case ../entry_type.name}}?.{{snake_case field_name}}, + {{else}} + {{camel_case field_name}}, + {{/if}} +{{/each}} + ]); + + useEffect(() => { + if (!currentRecord) { + throw new Error(`The currentRecord prop is required`); + } +{{#if link_from_original_to_each_update}} + if (!original{{pascal_case entry_type.name}}Hash) { + throw new Error(`The original{{pascal_case entry_type.name}}Hash prop is required`); + } +{{/if}} + }, [currentRecord{{#if link_from_original_to_each_update}}, original{{pascal_case entry_type.name}}Hash{{/if}}]); + + useEffect(() => { + setIs{{pascal_case entry_type.name}}Valid(true{{#each entry_type.fields}}{{#if widget}}{{#if (eq cardinality "single")}} && {{> (concat field_type.type "/" widget "/is-valid") variable_to_validate=(camel_case field_name) }}{{/if}}{{#if (eq cardinality "vector")}} && {{camel_case field_name}}!.every(e => {{> (concat field_type.type "/" widget "/is-valid") variable_to_validate="e" }}){{/if}}{{/if}}{{/each}}); + }, [{{#each entry_type.fields}}{{#if widget}}{{camel_case field_name}}{{#unless @last}}, {{/unless}}{{/if}}{{/each}}]); + + return ( +
+{{#each entry_type.fields}} + {{#if widget}} +
+ {{#if (not (eq cardinality "vector") )}} + {{> (concat field_type.type "/" widget "/edit/render") label=(title_case field_name) variable_to_read=(camel_case field_name) variable_to_change=(camel_case field_name) required=(eq cardinality "single") }} + {{else}} + {{> Vec/edit/render field_name=field_name field_type=field_type widget=widget }} + {{/if}} +
+ + {{/if}} +{{/each}} +
+ + +
+
+ ); +}; + +interface Edit{{pascal_case entry_type.name}}Props { +{{#if link_from_original_to_each_update}} + original{{pascal_case entry_type.name}}Hash: Uint8Array, +{{/if}} + currentRecord: Record | undefined, + current{{pascal_case entry_type.name}}: {{pascal_case entry_type.name}} | undefined, + on{{pascal_case entry_type.name}}Updated: (hash?: Uint8Array) => void, + onEditCanceled: () => void, + on{{pascal_case entry_type.name}}UpdateError?: (error: HolochainError) => void, +} + +export default Edit{{pascal_case entry_type.name}}; diff --git a/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case entry_type.name}}Detail.tsx.hbs b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case entry_type.name}}Detail.tsx.hbs new file mode 100644 index 000000000..ec774b827 --- /dev/null +++ b/templates/react/entry-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{pascal_case entry_type.name}}Detail.tsx.hbs @@ -0,0 +1,140 @@ +import { Record, HolochainError } from '@holochain/client'; +import { decode } from '@msgpack/msgpack'; +import { FC, useState, useEffect, useContext, useCallback } from 'react'; + +{{#if crud.update}} +import Edit{{pascal_case entry_type.name}} from './Edit{{pascal_case entry_type.name}}'; +{{/if}} +{{#uniq_lines}} + {{#each entry_type.fields}} + {{#if widget}} +{{> (concat field_type.type "/" widget "/detail/imports") }} + {{/if}} + {{/each}} +{{/uniq_lines}} +import type { {{pascal_case entry_type.name}}{{#each entry_type.fields}}{{#if (eq field_type.type "Enum")}}, {{field_type.label}}{{/if}}{{/each}} } from './types'; +import { ClientContext } from '../../ClientContext'; + +const {{pascal_case entry_type.name}}Detail: FC<{{pascal_case entry_type.name}}DetailProps> = ({ {{camel_case entry_type.name}}Hash, on{{pascal_case entry_type.name}}Deleted }) => { + const {client} = useContext(ClientContext); + const [record, setRecord] = useState(undefined); + const [{{camel_case entry_type.name}}, set{{pascal_case entry_type.name}}] = useState<{{pascal_case entry_type.name}} | undefined>(undefined); +{{#if crud.update}} + const [editing, setEditing] = useState(false); +{{/if}} + const [loading, setLoading] = useState(false); + const [error, setError] = useState() + + const fetch{{pascal_case entry_type.name}} = useCallback(async () => { + setLoading(true); + setRecord(undefined); + try { + const result = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{coordinator_zome_manifest.name}}', + fn_name: '{{#if crud.update}}get_latest_{{snake_case entry_type.name}}{{else}}get_{{snake_case entry_type.name}}{{/if}}', + payload: {{camel_case entry_type.name}}Hash, + }); + setRecord(result); + setLoading(false); + } catch (e) { + setError(e as HolochainError) + } finally { + setLoading(false) + } + }, [client, {{camel_case entry_type.name}}Hash]); + +{{#if crud.delete}} + const delete{{pascal_case entry_type.name}} = async () => { + setLoading(true) + try { + await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{coordinator_zome_manifest.name}}', + fn_name: 'delete_{{snake_case entry_type.name}}', + payload: {{camel_case entry_type.name}}Hash, + }); + on{{pascal_case entry_type.name}}Deleted && on{{pascal_case entry_type.name}}Deleted({{camel_case entry_type.name}}Hash) + } catch (e) { + setError(e as HolochainError) + } finally { + setLoading(false) + } + }; +{{/if}} + + useEffect(() => { + if (!{{camel_case entry_type.name}}Hash) { + throw new Error(`The {{camel_case entry_type.name}}Hash prop is required for this component`); + } + fetch{{pascal_case entry_type.name}}(); + }, [fetch{{pascal_case entry_type.name}}, {{camel_case entry_type.name}}Hash]); + + useEffect(() => { + if (!record) return + set{{pascal_case entry_type.name}}(decode((record.entry as any).Present.entry) as {{pascal_case entry_type.name}}); + }, [record]); + + if (loading) { + return + } + + if (error) { + return
Error: {error.message}
+ } + + return ( +
+ { {{#if crud.update}}editing ? ( +
+ { + setEditing(false); + await fetch{{pascal_case entry_type.name}}(); + }} + onEditCanceled={() => setEditing(false)} + /> +
+ ) : {{/if}}record ? ( +
+{{#each entry_type.fields}} + {{#if widget}} + {{#if (not (eq cardinality "vector") )}} +
+ {{title_case field_name}}: + {{> (concat field_type.type "/" widget "/detail/render") variable_to_read=(concat (camel_case ../entry_type.name) "?." (snake_case field_name) ) }} +
+ {{else}} + {{> Vec/detail/render variable_to_read=(concat (camel_case ../entry_type.name) "?." (snake_case field_name) ) field_name=field_name field_type=field_type widget=widget }} + {{/if}} + {{/if}} +{{/each}} +
+{{#if crud.update}} + +{{/if}} +{{#if crud.delete}} + +{{/if}} +
+
+ ) : ( +
The requested {{camel_case entry_type.name}} was not found.
+ )} +
+ ); +}; + +interface {{pascal_case entry_type.name}}DetailProps { + {{camel_case entry_type.name}}Hash: Uint8Array + on{{pascal_case entry_type.name}}Deleted?: ({{camel_case entry_type.name}}Hash: Uint8Array) => void +} + +export default {{pascal_case entry_type.name}}Detail; diff --git a/templates/react/example.instructions.hbs b/templates/react/example.instructions.hbs new file mode 100644 index 000000000..533acd402 --- /dev/null +++ b/templates/react/example.instructions.hbs @@ -0,0 +1,6 @@ +Run the example app with: + + cd {{example}} + nix develop + npm install + npm start \ No newline at end of file diff --git a/templates/react/example/ui/src/App.tsx.hbs b/templates/react/example/ui/src/App.tsx.hbs new file mode 100644 index 000000000..9eb9ccc0f --- /dev/null +++ b/templates/react/example/ui/src/App.tsx.hbs @@ -0,0 +1,25 @@ +import { useContext } from 'react'; +import { ClientContext } from './contexts/ClientContext'; +import AllPosts from './forum/posts/AllPosts'; +import CreatePost from './forum/posts/CreatePost'; + +import './App.css'; + +const App = () => { + const { error, loading } = useContext(ClientContext); + if (loading) { + return ; + } + if (error) { + return
Error starting app: {error.message}
; + } + return ( +
+

Welcome to the Forum hApp

+ + +
+ ) +} + +export default App; diff --git a/templates/react/field-types/ActionHash/sample.hbs b/templates/react/field-types/ActionHash/sample.hbs new file mode 100644 index 000000000..62bb9db14 --- /dev/null +++ b/templates/react/field-types/ActionHash/sample.hbs @@ -0,0 +1 @@ +(await fakeActionHash()) diff --git a/templates/react/field-types/ActionHash/type.hbs b/templates/react/field-types/ActionHash/type.hbs new file mode 100644 index 000000000..a00d2d429 --- /dev/null +++ b/templates/react/field-types/ActionHash/type.hbs @@ -0,0 +1 @@ +ActionHash diff --git a/templates/react/field-types/AgentPubKey/sample.hbs b/templates/react/field-types/AgentPubKey/sample.hbs new file mode 100644 index 000000000..98a2ed787 --- /dev/null +++ b/templates/react/field-types/AgentPubKey/sample.hbs @@ -0,0 +1 @@ +(await fakeAgentPubKey()) diff --git a/templates/react/field-types/AgentPubKey/type.hbs b/templates/react/field-types/AgentPubKey/type.hbs new file mode 100644 index 000000000..f25075ccb --- /dev/null +++ b/templates/react/field-types/AgentPubKey/type.hbs @@ -0,0 +1 @@ +AgentPubKey diff --git a/templates/react/field-types/DnaHash/sample.hbs b/templates/react/field-types/DnaHash/sample.hbs new file mode 100644 index 000000000..9369d2e2b --- /dev/null +++ b/templates/react/field-types/DnaHash/sample.hbs @@ -0,0 +1 @@ +(await fakeDnaHash()) diff --git a/templates/react/field-types/DnaHash/type.hbs b/templates/react/field-types/DnaHash/type.hbs new file mode 100644 index 000000000..c0e9d7168 --- /dev/null +++ b/templates/react/field-types/DnaHash/type.hbs @@ -0,0 +1 @@ +DnaHash diff --git a/templates/react/field-types/EntryHash/sample.hbs b/templates/react/field-types/EntryHash/sample.hbs new file mode 100644 index 000000000..5f7895b86 --- /dev/null +++ b/templates/react/field-types/EntryHash/sample.hbs @@ -0,0 +1 @@ +(await fakeEntryHash()) diff --git a/templates/react/field-types/EntryHash/type.hbs b/templates/react/field-types/EntryHash/type.hbs new file mode 100644 index 000000000..b97d6b80e --- /dev/null +++ b/templates/react/field-types/EntryHash/type.hbs @@ -0,0 +1 @@ +EntryHash diff --git a/templates/react/field-types/Enum/Select/detail/render.hbs b/templates/react/field-types/Enum/Select/detail/render.hbs new file mode 100644 index 000000000..ed936522e --- /dev/null +++ b/templates/react/field-types/Enum/Select/detail/render.hbs @@ -0,0 +1 @@ +{ {{#each field_type.variants}}{{#unless @last}} {{../variable_to_read}}.type === '{{pascal_case this}}' ?{{/unless}} `{{title_case this}}`{{#unless @last}} :{{/unless}} {{/each}} } diff --git a/templates/react/field-types/Enum/Select/edit/render.hbs b/templates/react/field-types/Enum/Select/edit/render.hbs new file mode 100644 index 000000000..ca55a3618 --- /dev/null +++ b/templates/react/field-types/Enum/Select/edit/render.hbs @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/templates/react/field-types/Enum/Select/initial-value.hbs b/templates/react/field-types/Enum/Select/initial-value.hbs new file mode 100644 index 000000000..560b9ff7e --- /dev/null +++ b/templates/react/field-types/Enum/Select/initial-value.hbs @@ -0,0 +1 @@ +{ type: '{{lookup field_type.variants 0}}' } diff --git a/templates/react/field-types/Enum/Select/is-valid.hbs b/templates/react/field-types/Enum/Select/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/Enum/Select/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/Enum/default.hbs b/templates/react/field-types/Enum/default.hbs new file mode 100644 index 000000000..560b9ff7e --- /dev/null +++ b/templates/react/field-types/Enum/default.hbs @@ -0,0 +1 @@ +{ type: '{{lookup field_type.variants 0}}' } diff --git a/templates/react/field-types/Enum/sample.hbs b/templates/react/field-types/Enum/sample.hbs new file mode 100644 index 000000000..560b9ff7e --- /dev/null +++ b/templates/react/field-types/Enum/sample.hbs @@ -0,0 +1 @@ +{ type: '{{lookup field_type.variants 0}}' } diff --git a/templates/react/field-types/Enum/type.hbs b/templates/react/field-types/Enum/type.hbs new file mode 100644 index 000000000..e9b80fff9 --- /dev/null +++ b/templates/react/field-types/Enum/type.hbs @@ -0,0 +1 @@ +{{pascal_case field_type.label}} diff --git a/templates/react/field-types/String/TextArea/detail/render.hbs b/templates/react/field-types/String/TextArea/detail/render.hbs new file mode 100644 index 000000000..defd9c7a6 --- /dev/null +++ b/templates/react/field-types/String/TextArea/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} } diff --git a/templates/react/field-types/String/TextArea/edit/render.hbs b/templates/react/field-types/String/TextArea/edit/render.hbs new file mode 100644 index 000000000..e5f30dd5c --- /dev/null +++ b/templates/react/field-types/String/TextArea/edit/render.hbs @@ -0,0 +1,25 @@ + +{{#if (eq cardinality "vector")}} + + +{{else}} + + +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/String/TextArea/initial-value.hbs b/templates/react/field-types/String/TextArea/initial-value.hbs new file mode 100644 index 000000000..a614936fa --- /dev/null +++ b/templates/react/field-types/String/TextArea/initial-value.hbs @@ -0,0 +1 @@ +'' diff --git a/templates/react/field-types/String/TextArea/is-valid.hbs b/templates/react/field-types/String/TextArea/is-valid.hbs new file mode 100644 index 000000000..a9e6c9814 --- /dev/null +++ b/templates/react/field-types/String/TextArea/is-valid.hbs @@ -0,0 +1 @@ +{{variable_to_validate}} !== '' diff --git a/templates/react/field-types/String/TextField/detail/render.hbs b/templates/react/field-types/String/TextField/detail/render.hbs new file mode 100644 index 000000000..defd9c7a6 --- /dev/null +++ b/templates/react/field-types/String/TextField/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} } diff --git a/templates/react/field-types/String/TextField/edit/render.hbs b/templates/react/field-types/String/TextField/edit/render.hbs new file mode 100644 index 000000000..c03a456f5 --- /dev/null +++ b/templates/react/field-types/String/TextField/edit/render.hbs @@ -0,0 +1,22 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues?.map((v, idx) => { + if ({{key}} === idx) { + v = e.target.value; + } + return v; + })) + }} +/> +{{else}} + + set{{pascal_case variable_to_change}}(e.target.value)} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/String/TextField/initial-value.hbs b/templates/react/field-types/String/TextField/initial-value.hbs new file mode 100644 index 000000000..a614936fa --- /dev/null +++ b/templates/react/field-types/String/TextField/initial-value.hbs @@ -0,0 +1 @@ +'' diff --git a/templates/react/field-types/String/TextField/is-valid.hbs b/templates/react/field-types/String/TextField/is-valid.hbs new file mode 100644 index 000000000..a9e6c9814 --- /dev/null +++ b/templates/react/field-types/String/TextField/is-valid.hbs @@ -0,0 +1 @@ +{{variable_to_validate}} !== '' diff --git a/templates/react/field-types/String/default.hbs b/templates/react/field-types/String/default.hbs new file mode 100644 index 000000000..5de6da439 --- /dev/null +++ b/templates/react/field-types/String/default.hbs @@ -0,0 +1 @@ +{{#if (eq cardinality "vector")}}[]{{else}}""{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/String/sample.hbs b/templates/react/field-types/String/sample.hbs new file mode 100644 index 000000000..56b4c51a6 --- /dev/null +++ b/templates/react/field-types/String/sample.hbs @@ -0,0 +1 @@ +"Lorem ipsum dolor sit amet, consectetur adipiscing elit." diff --git a/templates/react/field-types/String/type.hbs b/templates/react/field-types/String/type.hbs new file mode 100644 index 000000000..ee8a39c38 --- /dev/null +++ b/templates/react/field-types/String/type.hbs @@ -0,0 +1 @@ +string diff --git a/templates/react/field-types/Timestamp/DateTimePicker/detail/render.hbs b/templates/react/field-types/Timestamp/DateTimePicker/detail/render.hbs new file mode 100644 index 000000000..b38a804eb --- /dev/null +++ b/templates/react/field-types/Timestamp/DateTimePicker/detail/render.hbs @@ -0,0 +1 @@ +{new Date({{variable_to_read}} as number / 1000).toLocaleString()} \ No newline at end of file diff --git a/templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs b/templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs new file mode 100644 index 000000000..5e73fb698 --- /dev/null +++ b/templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs @@ -0,0 +1,26 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues?.map((v, idx) => { + if ({{key}} === idx) { + v = Math.floor(new Date(e.target.value).getTime() * 1000); + } + return v; + })) + }} +{{#if required}} + required +{{/if}} +/> +{{else}} + + set{{pascal_case variable_to_change}}(Math.floor(new Date(e.target.value).getTime() / 1000))} {{#if required}}required{{/if}} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/Timestamp/DateTimePicker/initial-value.hbs b/templates/react/field-types/Timestamp/DateTimePicker/initial-value.hbs new file mode 100644 index 000000000..56bfef5d4 --- /dev/null +++ b/templates/react/field-types/Timestamp/DateTimePicker/initial-value.hbs @@ -0,0 +1 @@ +Date.now() diff --git a/templates/react/field-types/Timestamp/DateTimePicker/is-valid.hbs b/templates/react/field-types/Timestamp/DateTimePicker/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/Timestamp/DateTimePicker/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/Timestamp/default.hbs b/templates/react/field-types/Timestamp/default.hbs new file mode 100644 index 000000000..1b4b0c67a --- /dev/null +++ b/templates/react/field-types/Timestamp/default.hbs @@ -0,0 +1 @@ +Date.now() * 1000 \ No newline at end of file diff --git a/templates/react/field-types/Timestamp/sample.hbs b/templates/react/field-types/Timestamp/sample.hbs new file mode 100644 index 000000000..1b4b0c67a --- /dev/null +++ b/templates/react/field-types/Timestamp/sample.hbs @@ -0,0 +1 @@ +Date.now() * 1000 \ No newline at end of file diff --git a/templates/react/field-types/Timestamp/type.hbs b/templates/react/field-types/Timestamp/type.hbs new file mode 100644 index 000000000..b67e17aeb --- /dev/null +++ b/templates/react/field-types/Timestamp/type.hbs @@ -0,0 +1 @@ +number diff --git a/templates/react/field-types/Vec/default.hbs b/templates/react/field-types/Vec/default.hbs new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/templates/react/field-types/Vec/default.hbs @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/templates/react/field-types/Vec/detail/render.hbs b/templates/react/field-types/Vec/detail/render.hbs new file mode 100644 index 000000000..d219d1f2c --- /dev/null +++ b/templates/react/field-types/Vec/detail/render.hbs @@ -0,0 +1,6 @@ +
+

{{title_case field_name}}

+ { {{ variable_to_read }}?.map((el, i) => ( +

{{> (concat field_type.type "/" widget "/detail/render") variable_to_read="el"}}

+ ))} +
\ No newline at end of file diff --git a/templates/react/field-types/Vec/edit/render.hbs b/templates/react/field-types/Vec/edit/render.hbs new file mode 100644 index 000000000..49cb7ca3c --- /dev/null +++ b/templates/react/field-types/Vec/edit/render.hbs @@ -0,0 +1,12 @@ +

{{title_case field_name}}

+ +{ {{camel_case field_name}}?.map((value, i) => ( +
+ {{> (concat field_type.type "/" widget "/edit/render") label=(pascal_case field_name) variable_to_read="value" variable_to_change=(camel_case field_name) key="i" }} + +
+))} + + \ No newline at end of file diff --git a/templates/react/field-types/Vec/type.hbs b/templates/react/field-types/Vec/type.hbs new file mode 100644 index 000000000..1f1899cec --- /dev/null +++ b/templates/react/field-types/Vec/type.hbs @@ -0,0 +1 @@ +Array<{{field_type.type}}> diff --git a/templates/react/field-types/bool/Checkbox/detail/render.hbs b/templates/react/field-types/bool/Checkbox/detail/render.hbs new file mode 100644 index 000000000..f6e93cd1f --- /dev/null +++ b/templates/react/field-types/bool/Checkbox/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} ? 'Yes' : 'No' } diff --git a/templates/react/field-types/bool/Checkbox/edit/render.hbs b/templates/react/field-types/bool/Checkbox/edit/render.hbs new file mode 100644 index 000000000..974653713 --- /dev/null +++ b/templates/react/field-types/bool/Checkbox/edit/render.hbs @@ -0,0 +1,25 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues?.map((v, idx) => { + if ({{key}} === idx) { + v = e.target.checked; + } + return v; + })) + }} +/> +{{else}} + + set{{pascal_case variable_to_change}}(e.target.checked)} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/bool/Checkbox/initial-value.hbs b/templates/react/field-types/bool/Checkbox/initial-value.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/bool/Checkbox/initial-value.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/bool/Checkbox/is-valid.hbs b/templates/react/field-types/bool/Checkbox/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/bool/Checkbox/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/bool/default.hbs b/templates/react/field-types/bool/default.hbs new file mode 100644 index 000000000..51b676e7a --- /dev/null +++ b/templates/react/field-types/bool/default.hbs @@ -0,0 +1 @@ +{{#if (eq cardinality "vector")}}[]{{else}}false{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/bool/sample.hbs b/templates/react/field-types/bool/sample.hbs new file mode 100644 index 000000000..c508d5366 --- /dev/null +++ b/templates/react/field-types/bool/sample.hbs @@ -0,0 +1 @@ +false diff --git a/templates/react/field-types/bool/type.hbs b/templates/react/field-types/bool/type.hbs new file mode 100644 index 000000000..7b19ee8df --- /dev/null +++ b/templates/react/field-types/bool/type.hbs @@ -0,0 +1 @@ +boolean diff --git a/templates/react/field-types/f32/NumberInput/detail/render.hbs b/templates/react/field-types/f32/NumberInput/detail/render.hbs new file mode 100644 index 000000000..defd9c7a6 --- /dev/null +++ b/templates/react/field-types/f32/NumberInput/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} } diff --git a/templates/react/field-types/f32/NumberInput/edit/render.hbs b/templates/react/field-types/f32/NumberInput/edit/render.hbs new file mode 100644 index 000000000..e6bf524cb --- /dev/null +++ b/templates/react/field-types/f32/NumberInput/edit/render.hbs @@ -0,0 +1,22 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues?.map((v, idx) => { + if ({{key}} === idx) { + v = parseFloat(e.target.value); + } + return v; + })) + }} +/> +{{else}} + + set{{pascal_case variable_to_change}}(parseFloat(e.target.value))} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/f32/NumberInput/initial-value.hbs b/templates/react/field-types/f32/NumberInput/initial-value.hbs new file mode 100644 index 000000000..ba66466c2 --- /dev/null +++ b/templates/react/field-types/f32/NumberInput/initial-value.hbs @@ -0,0 +1 @@ +0.0 diff --git a/templates/react/field-types/f32/NumberInput/is-valid.hbs b/templates/react/field-types/f32/NumberInput/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/f32/NumberInput/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/f32/default.hbs b/templates/react/field-types/f32/default.hbs new file mode 100644 index 000000000..0039c80a1 --- /dev/null +++ b/templates/react/field-types/f32/default.hbs @@ -0,0 +1 @@ +{{#if (eq cardinality "vector")}}[]{{else}}0.0{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/f32/sample.hbs b/templates/react/field-types/f32/sample.hbs new file mode 100644 index 000000000..2eb3c4fe4 --- /dev/null +++ b/templates/react/field-types/f32/sample.hbs @@ -0,0 +1 @@ +0.5 diff --git a/templates/react/field-types/f32/type.hbs b/templates/react/field-types/f32/type.hbs new file mode 100644 index 000000000..b67e17aeb --- /dev/null +++ b/templates/react/field-types/f32/type.hbs @@ -0,0 +1 @@ +number diff --git a/templates/react/field-types/i32/NumberInput/detail/render.hbs b/templates/react/field-types/i32/NumberInput/detail/render.hbs new file mode 100644 index 000000000..defd9c7a6 --- /dev/null +++ b/templates/react/field-types/i32/NumberInput/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} } diff --git a/templates/react/field-types/i32/NumberInput/edit/render.hbs b/templates/react/field-types/i32/NumberInput/edit/render.hbs new file mode 100644 index 000000000..8a654ffd0 --- /dev/null +++ b/templates/react/field-types/i32/NumberInput/edit/render.hbs @@ -0,0 +1,22 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues?.map((v, idx) => { + if ({{key}} === idx) { + v = parseInt(e.target.value); + } + return v; + })) + }} +/> +{{else}} + + set{{pascal_case variable_to_change}}(parseInt(e.target.value))} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/i32/NumberInput/initial-value.hbs b/templates/react/field-types/i32/NumberInput/initial-value.hbs new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/templates/react/field-types/i32/NumberInput/initial-value.hbs @@ -0,0 +1 @@ +0 diff --git a/templates/react/field-types/i32/NumberInput/is-valid.hbs b/templates/react/field-types/i32/NumberInput/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/i32/NumberInput/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/i32/default.hbs b/templates/react/field-types/i32/default.hbs new file mode 100644 index 000000000..16b2eb52b --- /dev/null +++ b/templates/react/field-types/i32/default.hbs @@ -0,0 +1 @@ +{{#if (eq cardinality "vector")}}[]{{else}}0{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/i32/sample.hbs b/templates/react/field-types/i32/sample.hbs new file mode 100644 index 000000000..291670352 --- /dev/null +++ b/templates/react/field-types/i32/sample.hbs @@ -0,0 +1 @@ +-10 diff --git a/templates/react/field-types/i32/type.hbs b/templates/react/field-types/i32/type.hbs new file mode 100644 index 000000000..b67e17aeb --- /dev/null +++ b/templates/react/field-types/i32/type.hbs @@ -0,0 +1 @@ +number diff --git a/templates/react/field-types/u32/NumberInput/detail/render.hbs b/templates/react/field-types/u32/NumberInput/detail/render.hbs new file mode 100644 index 000000000..defd9c7a6 --- /dev/null +++ b/templates/react/field-types/u32/NumberInput/detail/render.hbs @@ -0,0 +1 @@ +{ {{variable_to_read}} } diff --git a/templates/react/field-types/u32/NumberInput/edit/render.hbs b/templates/react/field-types/u32/NumberInput/edit/render.hbs new file mode 100644 index 000000000..1a2bf4352 --- /dev/null +++ b/templates/react/field-types/u32/NumberInput/edit/render.hbs @@ -0,0 +1,23 @@ + +{{#if (eq cardinality "vector")}} + + { + set{{pascal_case variable_to_change}}(prevValues => prevValues.map((v, idx) => { + if ({{key}} === idx) { + v = parseInt(e.target.value); + } + return v; + })) + }} +/> +{{else}} + + set{{pascal_case variable_to_change}}(parseInt(e.target.value))} /> +{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/u32/NumberInput/initial-value.hbs b/templates/react/field-types/u32/NumberInput/initial-value.hbs new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/templates/react/field-types/u32/NumberInput/initial-value.hbs @@ -0,0 +1 @@ +0 diff --git a/templates/react/field-types/u32/NumberInput/is-valid.hbs b/templates/react/field-types/u32/NumberInput/is-valid.hbs new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/templates/react/field-types/u32/NumberInput/is-valid.hbs @@ -0,0 +1 @@ +true diff --git a/templates/react/field-types/u32/default.hbs b/templates/react/field-types/u32/default.hbs new file mode 100644 index 000000000..16b2eb52b --- /dev/null +++ b/templates/react/field-types/u32/default.hbs @@ -0,0 +1 @@ +{{#if (eq cardinality "vector")}}[]{{else}}0{{/if}} \ No newline at end of file diff --git a/templates/react/field-types/u32/sample.hbs b/templates/react/field-types/u32/sample.hbs new file mode 100644 index 000000000..f599e28b8 --- /dev/null +++ b/templates/react/field-types/u32/sample.hbs @@ -0,0 +1 @@ +10 diff --git a/templates/react/field-types/u32/type.hbs b/templates/react/field-types/u32/type.hbs new file mode 100644 index 000000000..b67e17aeb --- /dev/null +++ b/templates/react/field-types/u32/type.hbs @@ -0,0 +1 @@ +number diff --git "a/templates/react/link-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if to_referenceable}}{{kebab_case from_referenceable.name}}-to-{{kebab_case (plural to_referenceable.name)}}.test.ts{{\302\241if}}.hbs" "b/templates/react/link-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if to_referenceable}}{{kebab_case from_referenceable.name}}-to-{{kebab_case (plural to_referenceable.name)}}.test.ts{{\302\241if}}.hbs" new file mode 100644 index 000000000..4d8314202 --- /dev/null +++ "b/templates/react/link-type/tests/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if to_referenceable}}{{kebab_case from_referenceable.name}}-to-{{kebab_case (plural to_referenceable.name)}}.test.ts{{\302\241if}}.hbs" @@ -0,0 +1,158 @@ +import { assert, test } from "vitest"; + +import { runScenario, dhtSync, CallableCell } from '@holochain/tryorama'; +import { + NewEntryAction, + ActionHash, + Record, + Link, + CreateLink, + DeleteLink, + SignedActionHashed, + AppBundleSource, + fakeActionHash, + fakeAgentPubKey, + fakeEntryHash +} from '@holochain/client'; +import { decode } from '@msgpack/msgpack'; + +{{#if (ne from_referenceable.hash_type "AgentPubKey")}} +import { create{{pascal_case from_referenceable.name}} } from './common.js'; +{{/if}} +{{#if (ne to_referenceable.hash_type "AgentPubKey")}} +import { create{{pascal_case to_referenceable.name}} } from './common.js'; +{{/if}} + +test('link a {{pascal_case from_referenceable.name}} to a {{pascal_case to_referenceable.name}}', async () => { + await runScenario(async scenario => { + // Construct proper paths for your app. + // This assumes app bundle created by the `hc app pack` command. + const testAppPath = process.cwd() + '/../workdir/{{app_name}}.happ'; + + // Set up the app to be installed + const appSource = { appBundleSource: { path: testAppPath } }; + + // Add 2 players with the test app to the Scenario. The returned players + // can be destructured. + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + // Shortcut peer discovery through gossip and register all agents in every + // conductor of the scenario. + await scenario.shareAllAgents(); + +{{#if (eq from_referenceable.hash_type "AgentPubKey")}} + const baseAddress = alice.agentPubKey; +{{else}} + const baseRecord = await create{{pascal_case from_referenceable.name}}(alice.cells[0]); + {{#if (eq from_referenceable.hash_type "EntryHash")}} + const baseAddress = (baseRecord.signed_action.hashed.content as NewEntryAction).entry_hash; + {{else}} + const baseAddress = baseRecord.signed_action.hashed.hash; + {{/if}} +{{/if}} +{{#if (eq to_referenceable.hash_type "AgentPubKey")}} + const targetAddress = alice.agentPubKey; +{{else}} + const targetRecord = await create{{pascal_case to_referenceable.name}}(alice.cells[0]); + {{#if (eq to_referenceable.hash_type "EntryHash")}} + const targetAddress = (targetRecord.signed_action.hashed.content as NewEntryAction).entry_hash; + {{else}} + const targetAddress = targetRecord.signed_action.hashed.hash; + {{/if}} +{{/if}} + + // Bob gets the links, should be empty + let linksOutput: Link[] = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{plural (snake_case to_referenceable.name)}}_for_{{snake_case from_referenceable.name}}", + payload: baseAddress + }); + assert.equal(linksOutput.length, 0); + + // Alice creates a link from {{pascal_case from_referenceable.name}} to {{pascal_case to_referenceable.name}} + await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "add_{{snake_case to_referenceable.name}}_for_{{snake_case from_referenceable.name}}", + payload: { + base_{{snake_case from_referenceable.singular_arg}}: baseAddress, + target_{{snake_case to_referenceable.singular_arg}}: targetAddress + } + }); + + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the links again + linksOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{plural (snake_case to_referenceable.name)}}_for_{{snake_case from_referenceable.name}}", + payload: baseAddress + }); + assert.equal(linksOutput.length, 1); +{{#if (ne to_referenceable.hash_type "AgentPubKey")}} + assert.deepEqual(targetAddress, linksOutput[0].target); +{{/if}} + +{{#if bidirectional}} + + // Bob gets the links in the inverse direction + linksOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{plural (snake_case from_referenceable.name)}}_for_{{snake_case to_referenceable.name}}", + payload: targetAddress + }); + assert.equal(linksOutput.length, 1); + {{#if (ne from_referenceable.hash_type "AgentPubKey")}} + assert.deepEqual(baseAddress, linksOutput[0].target); + {{/if}} +{{/if}} + +{{#if delete}} + await alice.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "remove_{{snake_case to_referenceable.name}}_for_{{snake_case from_referenceable.name}}", + payload: { + base_{{snake_case from_referenceable.singular_arg}}: baseAddress, + target_{{snake_case to_referenceable.singular_arg}}: targetAddress + } + }); + + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + + // Bob gets the links again + linksOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{plural (snake_case to_referenceable.name)}}_for_{{snake_case from_referenceable.name}}", + payload: baseAddress + }); + assert.equal(linksOutput.length, 0); + + // Bob gets the deleted links + let deletedLinksOutput: Array<[SignedActionHashed, SignedActionHashed[]]> = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_deleted_{{plural (snake_case to_referenceable.name)}}_for_{{snake_case from_referenceable.name}}", + payload: baseAddress + }); + assert.equal(deletedLinksOutput.length, 1); + + {{#if bidirectional}} + // Bob gets the links in the inverse direction + linksOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_{{plural (snake_case from_referenceable.name)}}_for_{{snake_case to_referenceable.name}}", + payload: targetAddress + }); + assert.equal(linksOutput.length, 0); + + // Bob gets the deleted links in the inverse direction + deletedLinksOutput = await bob.cells[0].callZome({ + zome_name: "{{coordinator_zome_manifest.name}}", + fn_name: "get_deleted_{{plural (snake_case from_referenceable.name)}}_for_{{snake_case to_referenceable.name}}", + payload: targetAddress + }); + assert.equal(deletedLinksOutput.length, 1); + {{/if}} + +{{/if}} + }); +}); + diff --git "a/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and bidireccional (and to_referenceable (ne from_referenceable.hash_type 'AgentPubKey')))}}{{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}.tsx{{\302\241if}}.hbs" "b/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and bidireccional (and to_referenceable (ne from_referenceable.hash_type 'AgentPubKey')))}}{{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}.tsx{{\302\241if}}.hbs" new file mode 100644 index 000000000..baa19bf50 --- /dev/null +++ "b/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and bidireccional (and to_referenceable (ne from_referenceable.hash_type 'AgentPubKey')))}}{{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}.tsx{{\302\241if}}.hbs" @@ -0,0 +1,72 @@ +import { Link, AppSignal, HolochainError } from '@holochain/client'; +import { FC, useCallback, useState, useEffect, useContext } from 'react'; + +import {{pascal_case from_referenceable.name}}Detail from './{{pascal_case from_referenceable.name}}Detail'; +import type { {{pascal_case ../coordinator_zome_manifest.name}}Signal } from './types'; +import { ClientContext } from '../../ClientContext'; + +const {{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}: FC<{{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}Props> = ({ {{camel_case to_referenceable.singular_arg}} }) => { + const {client} = useContext(ClientContext); + const [hashes, setHashes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const fetch{{pascal_case (plural from_referenceable.name)}} = useCallback(async () => { + setLoading(true) + try { + const links: Link[] = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{snake_case coordinator_zome_manifest.name}}', + fn_name: 'get_{{snake_case (plural from_referenceable.name)}}_for_{{snake_case to_referenceable.name}}', + payload: {{camel_case to_referenceable.singular_arg}}, + }); + if (links?.length) { + setHashes(links.map((l) => l.target)); + } + } catch (e) { + setError(e as HolochainError); + } finally { + setLoading(false); + } + }, [client]); + + const handleSignal = useCallback(async (signal: AppSignal) => { + if (signal.zome_name !== '{{coordinator_zome_manifest.name}}') return; + const payload = signal.payload as {{pascal_case coordinator_zome_manifest.name}}Signal; + if (payload.type !== 'LinkCreated') return; + if (payload.link_type !== '{{pascal_case bidirectional}}') return; + await fetch{{pascal_case (plural from_referenceable.name)}}(); + }, [fetch{{pascal_case (plural from_referenceable.name)}}]); + + useEffect(() => { + fetch{{pascal_case (plural from_referenceable.name)}}(); + client?.on('signal', handleSignal); + }, [client, handleSignal]); + + if (loading) { + return ; + } + + return ( +
+ {error ? ( +
Error fetching {{lower_case (plural from_referenceable.name)}}: {error.data.data}.
+ ) : hashes.length > 0 ? ( +
+ {hashes.map((hash, i) => ( + <{{pascal_case from_referenceable.name}}Detail key={i} {{camel_case from_referenceable.name}}Hash={hash} /> + ))} +
+ ) : ( +
No {{lower_case (plural from_referenceable.name)}} found for this {{lower_case to_referenceable.name}}.
+ )} +
+ ); +}; + +interface {{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}Props { + {{camel_case to_referenceable.singular_arg}}: Uint8Array +} + +export default {{pascal_case (plural from_referenceable.name)}}For{{pascal_case to_referenceable.name}}; diff --git "a/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and to_referenceable (ne to_referenceable.hash_type 'AgentPubKey'))}}{{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}.tsx{{\302\241if}}.hbs" "b/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and to_referenceable (ne to_referenceable.hash_type 'AgentPubKey'))}}{{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}.tsx{{\302\241if}}.hbs" new file mode 100644 index 000000000..6c0579892 --- /dev/null +++ "b/templates/react/link-type/ui/src/{{dna_role_name}}/{{coordinator_zome_manifest.name}}/{{#if (and to_referenceable (ne to_referenceable.hash_type 'AgentPubKey'))}}{{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}.tsx{{\302\241if}}.hbs" @@ -0,0 +1,72 @@ +import { Link, AppSignal, HolochainError } from '@holochain/client'; +import { FC, useCallback, useState, useEffect, useContext } from 'react'; + +import {{pascal_case to_referenceable.name}}Detail from './{{pascal_case to_referenceable.name}}Detail'; +import type { {{pascal_case ../coordinator_zome_manifest.name}}Signal } from './types'; +import { ClientContext } from '../../ClientContext'; + +const {{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}: FC<{{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}Props > = ({ {{camel_case from_referenceable.singular_arg}} }) => { + const {client} = useContext(ClientContext); + const [hashes, setHashes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const fetch{{pascal_case (plural to_referenceable.name)}} = useCallback(async () => { + setLoading(true) + try { + const links: Link[] = await client?.callZome({ + cap_secret: null, + role_name: '{{dna_role_name}}', + zome_name: '{{coordinator_zome_manifest.name}}', + fn_name: 'get_{{snake_case (plural to_referenceable.name)}}_for_{{snake_case from_referenceable.name}}', + payload: {{camel_case from_referenceable.singular_arg}}, + }); + if (links?.length) { + setHashes(links.map((l) => l.target)); + } + } catch (e) { + setError(e as HolochainError); + } finally { + setLoading(false); + } + }, [client]); + + const handleSignal = useCallback(async (signal: AppSignal) => { + if (signal.zome_name !== '{{coordinator_zome_manifest.name}}') return; + const payload = signal.payload as {{pascal_case coordinator_zome_manifest.name}}Signal; + if (payload.type !== 'LinkCreated') return; + if (payload.link_type !== '{{pascal_case link_type_name}}') return; + await fetch{{pascal_case (plural to_referenceable.name)}}(); + }, [fetch{{pascal_case (plural to_referenceable.name)}}]); + + useEffect(() => { + fetch{{pascal_case (plural to_referenceable.name)}}(); + client?.on('signal', handleSignal); + }, [client]); + + if (loading) { + return ; + } + + return ( +
+ {error ? ( +
Error fetching {{lower_case (plural to_referenceable.name)}}: {error.message}
+ ) : hashes.length > 0 ? ( +
+ {hashes.map((hash, i) => ( + <{{pascal_case to_referenceable.name}}Detail key={i} {{camel_case to_referenceable.name}}Hash={hash} /> + ))} +
+ ) : ( +
No {{lower_case (plural to_referenceable.name)}} found for this {{lower_case from_referenceable.name}}.
+ )} +
+ ); +}; + +interface {{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}Props { + {{camel_case from_referenceable.singular_arg}}: Uint8Array +} + +export default {{pascal_case (plural to_referenceable.name)}}For{{pascal_case from_referenceable.name}}; diff --git a/templates/react/web-app/.github/workflows/test.yaml.hbs b/templates/react/web-app/.github/workflows/test.yaml.hbs new file mode 100644 index 000000000..3c256fa8f --- /dev/null +++ b/templates/react/web-app/.github/workflows/test.yaml.hbs @@ -0,0 +1,30 @@ +name: "test" +on: + # Trigger the workflow on push or pull request, + # but only for the main branch + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + testbuild: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install nix + uses: cachix/install-nix-action@v25 + with: + install_url: https://releases.nixos.org/nix/nix-2.20.0/install + extra_nix_config: | + experimental-features = flakes nix-command + + - uses: cachix/cachix-action@v14 + with: + name: holochain-ci + + - name: Install and test + run: | + nix develop --command bash -c "npm i && npm t" + diff --git a/templates/react/web-app/README.md.hbs b/templates/react/web-app/README.md.hbs new file mode 100644 index 000000000..5274b8049 --- /dev/null +++ b/templates/react/web-app/README.md.hbs @@ -0,0 +1,59 @@ +# {{title_case app_name}} + +## Environment Setup + +> PREREQUISITE: set up the [holochain development environment](https://developer.holochain.org/docs/install/). + +Enter the nix shell by running this in the root folder of the repository: + +```bash +nix develop +npm install +``` + +**Run all the other instructions in this README from inside this nix shell, otherwise they won't work**. + +## Running 2 agents + +```bash +npm start +``` + +This will create a network of 2 nodes connected to each other and their respective UIs. +It will also bring up the Holochain Playground for advanced introspection of the conductors. + +## Running the backend tests + +```bash +npm test +``` + +## Bootstrapping a network + +Create a custom network of nodes connected to each other and their respective UIs with: + +```bash +AGENTS=3 npm run network +``` + +Substitute the "3" for the number of nodes that you want to bootstrap in your network. +This will also bring up the Holochain Playground for advanced introspection of the conductors. + +## Packaging + +To package the web happ: +``` bash +npm run package +``` + +You'll have the `{{app_name}}.webhapp` in `workdir`. This is what you should distribute so that the Holochain Launcher can install it. +You will also have its subcomponent `{{app_name}}.happ` in the same folder`. + +## Documentation + +This repository is using these tools: +- [NPM Workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces/): npm v7's built-in monorepo capabilities. +- [hc](https://github.com/holochain/holochain/tree/develop/crates/hc): Holochain CLI to easily manage Holochain development instances. +- [@holochain/tryorama](https://www.npmjs.com/package/@holochain/tryorama): test framework. +- [@holochain/client](https://www.npmjs.com/package/@holochain/client): client library to connect to Holochain from the UI. +- [@holochain-playground/cli](https://www.npmjs.com/package/@holochain-playground/cli): introspection tooling to understand what's going on in the Holochain nodes. diff --git a/templates/react/web-app/package.json.hbs b/templates/react/web-app/package.json.hbs new file mode 100644 index 000000000..fdeb2f8f4 --- /dev/null +++ b/templates/react/web-app/package.json.hbs @@ -0,0 +1,38 @@ +{ + "name": "{{app_name}}-dev", + "private": true, + "workspaces": [ + "ui", + "tests" + ], + "scripts": { + "start": "AGENTS=${AGENTS:-2} BOOTSTRAP_PORT=$(port) SIGNAL_PORT=$(port) npm run network", + "network": "hc sandbox clean && npm run build:happ && UI_PORT=$(port) concurrently \"npm start -w ui\" \"npm run launch:happ\" \"holochain-playground\"", + "test": "npm run build:zomes && hc app pack workdir --recursive && npm t -w tests", + "launch:happ": "hc-spin -n $AGENTS --ui-port $UI_PORT workdir/{{app_name}}.happ", + "start:tauri": "AGENTS=${AGENTS:-2} BOOTSTRAP_PORT=$(port) SIGNAL_PORT=$(port) npm run network:tauri", + "network:tauri": "hc sandbox clean && npm run build:happ && UI_PORT=$(port) concurrently \"npm start -w ui\" \"npm run launch:tauri\" \"holochain-playground\"", + "launch:tauri": "concurrently \"hc run-local-services --bootstrap-port $BOOTSTRAP_PORT --signal-port $SIGNAL_PORT\" \"echo pass | RUST_LOG=warn hc launch --piped -n $AGENTS workdir/{{app_name}}.happ --ui-port $UI_PORT network --bootstrap http://127.0.0.1:\"$BOOTSTRAP_PORT\" webrtc ws://127.0.0.1:\"$SIGNAL_PORT\"\"", + {{#if holo_enabled}} + "start:holo": "AGENTS=${AGENTS:-2} npm run network:holo", + "network:holo": "npm run build:happ && UI_PORT=$(port) concurrently \"npm run launch:holo-dev-server\" \"holochain-playground ws://localhost:4444\" \"concurrently-repeat 'VITE_APP_CHAPERONE_URL=http://localhost:24274 VITE_APP_IS_HOLO=true npm start -w ui' $AGENTS\"", + "launch:holo-dev-server": "holo-dev-server workdir/{{app_name}}.happ", + {{/if}} + "package": "npm run build:happ && npm run package -w ui && hc web-app pack workdir --recursive", + "build:happ": "npm run build:zomes && hc app pack workdir --recursive", + "build:zomes": "RUSTFLAGS='' CARGO_TARGET_DIR=target cargo build --release --target wasm32-unknown-unknown" + }, + "devDependencies": { + "@holochain-playground/cli": "{{holochain_playground_cli_version}}", + "@holochain/hc-spin": "{{hc_spin_version}}", + "concurrently": "^6.2.1", + "rimraf": "^3.0.2", + {{#if holo_enabled}} + "concurrently-repeat": "^0.0.1", + {{/if}} + "new-port-cli": "^1.0.0" + }, + "engines": { + "npm": ">=7.0.0" + } +} diff --git a/templates/react/web-app/tests/package.json.hbs b/templates/react/web-app/tests/package.json.hbs new file mode 100644 index 000000000..8821314bc --- /dev/null +++ b/templates/react/web-app/tests/package.json.hbs @@ -0,0 +1,15 @@ +{ + "name": "tests", + "private": true, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@msgpack/msgpack": "^2.8.0", + "@holochain/client": "{{holochain_client_version}}", + "@holochain/tryorama": "{{tryorama_version}}", + "typescript": "^4.9.4", + "vitest": "^0.28.4" + }, + "type": "module" +} diff --git a/templates/react/web-app/tests/tsconfig.json.hbs b/templates/react/web-app/tests/tsconfig.json.hbs new file mode 100644 index 000000000..88643849d --- /dev/null +++ b/templates/react/web-app/tests/tsconfig.json.hbs @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + } +} diff --git a/templates/react/web-app/tests/vitest.config.ts.hbs b/templates/react/web-app/tests/vitest.config.ts.hbs new file mode 100644 index 000000000..7737dbd2a --- /dev/null +++ b/templates/react/web-app/tests/vitest.config.ts.hbs @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + threads: false, + testTimeout: 60*1000*3 // 3 mins + }, +}) + diff --git a/templates/react/web-app/ui/.gitignore.hbs b/templates/react/web-app/ui/.gitignore.hbs new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/templates/react/web-app/ui/.gitignore.hbs @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/templates/react/web-app/ui/index.html.hbs b/templates/react/web-app/ui/index.html.hbs new file mode 100644 index 000000000..221b84a4d --- /dev/null +++ b/templates/react/web-app/ui/index.html.hbs @@ -0,0 +1,12 @@ + + + + + + React hApp + + +
+ + + diff --git a/templates/react/web-app/ui/package.json.hbs b/templates/react/web-app/ui/package.json.hbs new file mode 100644 index 000000000..deb1bf0f3 --- /dev/null +++ b/templates/react/web-app/ui/package.json.hbs @@ -0,0 +1,31 @@ +{ + "name": "ui", + "version": "0.1.0", + "scripts": { + "start": "vite --clearScreen false --port $UI_PORT", + "build": "npm run check && vite build", +{{#if holo_enabled}} + "build:holo": "VITE_APP_IS_HOLO=true vite build", + "package:holo": "npm run build:holo && rimraf dist.zip && cd dist && bestzip ../dist.zip *", +{{/if}} + "package": "npm run build && rimraf dist.zip && cd dist && bestzip ../dist.zip *" + }, + "dependencies": { + "@holochain/client": "{{holochain_client_version}}", +{{#if holo_enabled}} + "@holo-host/web-sdk": "{{web_sdk_version}}", +{{/if}} + "@msgpack/msgpack": "^2.8.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "bestzip": "^2.2.1", + "typescript": "^5.2.2", + "vite": "^5.2.0" + }, + "type": "module" +} diff --git a/templates/react/web-app/ui/src/App.css.hbs b/templates/react/web-app/ui/src/App.css.hbs new file mode 100644 index 000000000..2775e3020 --- /dev/null +++ b/templates/react/web-app/ui/src/App.css.hbs @@ -0,0 +1,30 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 15em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; + width: auto; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +.logo.holochain:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/templates/react/web-app/ui/src/App.tsx.hbs b/templates/react/web-app/ui/src/App.tsx.hbs new file mode 100644 index 000000000..4530eda19 --- /dev/null +++ b/templates/react/web-app/ui/src/App.tsx.hbs @@ -0,0 +1,32 @@ +import { useContext } from 'react'; + +import './App.css' +import holochainLogo from './assets/holochainLogo.svg'; +import { ClientContext } from './ClientContext'; + +const App = () => { + const { error, loading } = useContext(ClientContext); + return ( + <> +
+ + holochain logo + +
+

Holochain React hApp

+
+
+ {loading ? 'connecting...' : (error ? error.message : 'Client is connected.')} +
+

+ Import scaffolded components into src/App.tsx to use your hApp +

+

+ Click on the Holochain logo to learn more +

+
+ + ) +} + +export default App diff --git a/templates/react/web-app/ui/src/ClientContext.tsx.hbs b/templates/react/web-app/ui/src/ClientContext.tsx.hbs new file mode 100644 index 000000000..ce7102278 --- /dev/null +++ b/templates/react/web-app/ui/src/ClientContext.tsx.hbs @@ -0,0 +1,98 @@ +import { AppWebsocket, HolochainError, type AppClient } from '@holochain/client'; +{{#if holo_enabled}} +import WebSdk from '@holo-host/web-sdk' +{{/if}} +import { createContext, FC, useEffect, useRef, useState } from 'react'; + +{{#if holo_enabled}} +const IS_HOLO = ['true', '1', 't'].includes(import.meta.env.VITE_APP_IS_HOLO?.toLowerCase()); +{{/if}} + +export const ClientContext = createContext({ + client: undefined, + error: undefined, + loading: false, +}); + +const ClientProvider: FC = ({ children }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const holochainClient = useRef(); +{{#if holo_enabled}} + const holoClient = useRef(); +{{/if}} + + const value = { + client: {{#if holo_enabled}}IS_HOLO ? holoClient.current as WebSdk : holochainClient.current as AppClient{{else}}holochainClient.current{{/if}}, + error, + loading + }; + +{{#if holo_enabled}} + const signOut = async () => { + await holoClient.current.signOut(); + await holoClient.current.signIn({ cancellable: false }); + } + + useEffect(() => { + const connectClient = async () => { + setLoading(true); + try { + if (IS_HOLO) { + holoClient.current = await WebSdk.connect({ + chaperoneUrl: import.meta.env.VITE_APP_CHAPERONE_URL, + authFormCustomization: { appName: '{{app_name}}' } + }); + (holoClient.current as WebSdk).signUp({ cancellable: false }); + } else { + holochainClient.current = await AppWebsocket.connect(); + } + } catch (error) { + setError(error as HolochainError) + console.error('Failed to establish websocket connection:', error); + } finally { + setLoading(false); + } + }; + connectClient(); + }, []); +{{else}} + useEffect(() => { + const connectClient = async () => { + setLoading(true); + try { + holochainClient.current = await AppWebsocket.connect(); + } catch (error) { + setError(error as HolochainError) + console.error('Failed to establish websocket connection:', error); + } finally { + setLoading(false); + } + }; + connectClient(); + }, []); +{{/if}} + + return ( + + {children} +{{#if holo_enabled}} + {IS_HOLO && holoClient.current && ( + + )} +{{/if}} + + ); +}; + +interface ClientContextValues { + client: AppClient{{#if holo_enabled}} | WebSdk{{/if}} | undefined, + error: HolochainError | undefined, + loading: boolean, +} + +interface ClientProviderProps { + children: React.ReactNode; +} + +export default ClientProvider; diff --git a/templates/react/web-app/ui/src/assets/holochainLogo.svg.hbs b/templates/react/web-app/ui/src/assets/holochainLogo.svg.hbs new file mode 100644 index 000000000..d5b87c953 --- /dev/null +++ b/templates/react/web-app/ui/src/assets/holochainLogo.svg.hbs @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/react/web-app/ui/src/index.css.hbs b/templates/react/web-app/ui/src/index.css.hbs new file mode 100644 index 000000000..6a01b0e5f --- /dev/null +++ b/templates/react/web-app/ui/src/index.css.hbs @@ -0,0 +1,259 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +label { + display: block; + margin-bottom: 0.5em; + font-weight: 500; +} + +input { + display: block; + width: 540px; + padding: 0.75em; + margin: 0.5em 0; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 1em; + color: inherit; + background-color: #2c2c2c; + transition: border-color 0.25s, box-shadow 0.25s; +} + +input[type="checkbox"] { + display: inline-block; + width: auto; + margin-bottom: 1em; + transform: scale(1.5); + margin-right: 0.5em; + vertical-align: middle; +} + +input:focus { + outline: none; + border-color: #646cff; + box-shadow: 0 0 0 4px rgba(100, 108, 255, 0.2); +} + +textarea { + display: block; + width: 540px; + height: 150px; + padding: 0.75em; + margin: 0.5em 0; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 1em; + color: inherit; + background-color: #2c2c2c; + transition: border-color 0.25s, box-shadow 0.25s; + resize: none; +} + +textarea:focus { + outline: none; + border-color: #646cff; + box-shadow: 0 0 0 4px rgba(100, 108, 255, 0.2); +} + +@media (prefers-color-scheme: light) { + input, + textarea { + background-color: #ffffff; + border-color: #ccc; + } + + input:focus, + textarea:focus { + border-color: #646cff; + box-shadow: 0 0 0 4px rgba(100, 108, 255, 0.2); + } +} + +select { + display: block; + width: 566px; + padding: 0.75em; + margin: 1em 0; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 1em; + color: inherit; + background-color: #2c2c2c; + transition: border-color 0.25s, box-shadow 0.25s; +} + +select:focus { + outline: none; + border-color: #646cff; + box-shadow: 0 0 0 4px rgba(100, 108, 255, 0.2); +} + +@media (prefers-color-scheme: light) { + select { + background-color: #ffffff; + border-color: #ccc; + } + + select:focus { + border-color: #646cff; + box-shadow: 0 0 0 4px rgba(100, 108, 255, 0.2); + } +} + +article { + font-size: 1.2em; + font-weight: bold; + color: rgba(255, 255, 255, 0.87); + text-align: center; + padding: 2rem; + background-color: #333333; + border: 1px solid #555555; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin: 1rem auto; + max-width: 500px; +} + +section { + padding: 2rem; + margin: 1rem 0; + background-color: #333333; + border: 1px solid #555555; + border-radius: 8px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + transition: transform 0.3s, box-shadow 0.3s; + max-width: 500px; +} + +section:hover { + transform: translateY(-5px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.4); +} + +section div { + margin-bottom: 1.5rem; + padding: 1rem; + background-color: #444444; + border: 1px solid #666666; + border-radius: 4px; +} + +section input { + width: 440px; +} + +section select { + width: 464px; +} + +section textarea { + width: 440px; +} + +section div:last-child { + margin-bottom: 0; +} + +section p, +section span { + margin: 0; + font-size: 1em; + line-height: 1.6; + color: rgba(255, 255, 255, 0.87); +} + +section div:has(button) { + display: flex; + justify-content: space-between; + align-items: center; +} + +progress { + width: 100%; + height: 1.5em; +} + +progress::-webkit-progress-bar { + background-color: #444444; + border-radius: 8px; +} + +progress::-webkit-progress-value { + background-color: #646cff; + border-radius: 8px; +} + +progress::-moz-progress-bar { + background-color: #646cff; + border-radius: 8px; +} diff --git a/templates/react/web-app/ui/src/main.tsx.hbs b/templates/react/web-app/ui/src/main.tsx.hbs new file mode 100644 index 000000000..8ba1786f5 --- /dev/null +++ b/templates/react/web-app/ui/src/main.tsx.hbs @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import './index.css'; +import App from './App.tsx'; +import ClientProvider from './ClientContext.tsx'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/templates/react/web-app/ui/src/vite-env.d.ts.hbs b/templates/react/web-app/ui/src/vite-env.d.ts.hbs new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/templates/react/web-app/ui/src/vite-env.d.ts.hbs @@ -0,0 +1 @@ +/// diff --git a/templates/react/web-app/ui/tsconfig.json.hbs b/templates/react/web-app/ui/tsconfig.json.hbs new file mode 100644 index 000000000..20cda9e75 --- /dev/null +++ b/templates/react/web-app/ui/tsconfig.json.hbs @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["node", "vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/templates/react/web-app/ui/tsconfig.node.json.hbs b/templates/react/web-app/ui/tsconfig.node.json.hbs new file mode 100644 index 000000000..97ede7ee6 --- /dev/null +++ b/templates/react/web-app/ui/tsconfig.node.json.hbs @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/templates/react/web-app/ui/vite.config.ts.hbs b/templates/react/web-app/ui/vite.config.ts.hbs new file mode 100644 index 000000000..5a33944a9 --- /dev/null +++ b/templates/react/web-app/ui/vite.config.ts.hbs @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +})