Skip to content

Commit

Permalink
feat: Add react template (holochain#295)
Browse files Browse the repository at this point in the history
* 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 <amillionlobsters@gmail.com>

* Update templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs

Co-authored-by: Paul d'Aoust <amillionlobsters@gmail.com>

* Update templates/react/field-types/Timestamp/DateTimePicker/edit/render.hbs

Co-authored-by: Paul d'Aoust <amillionlobsters@gmail.com>

* set default and initial values for boolean inputs to false

* ensure previous zip file is deleted

* fix templates

---------

Co-authored-by: Paul d'Aoust <amillionlobsters@gmail.com>
  • Loading branch information
c12i and pdaoust committed Jun 19, 2024
1 parent 24429e3 commit 56ed50f
Show file tree
Hide file tree
Showing 104 changed files with 2,344 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 3 additions & 7 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

Expand Down Expand Up @@ -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()?)
}
Expand Down Expand Up @@ -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(())
Expand Down
16 changes: 15 additions & 1 deletion src/scaffold/web_app/uis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -20,6 +21,7 @@ pub enum UiFramework {
Lit,
Svelte,
Vue,
React,
Headless,
}

Expand All @@ -31,6 +33,7 @@ impl UiFramework {
UiFramework::Lit => "lit",
UiFramework::Svelte => "svelte",
UiFramework::Vue => "vue",
UiFramework::React => "react",
UiFramework::Headless => "headless",
};
name.to_string()
Expand All @@ -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)
Expand All @@ -52,6 +56,7 @@ impl UiFramework {
UiFramework::Lit,
UiFramework::Svelte,
UiFramework::Vue,
UiFramework::React,
UiFramework::Vanilla,
UiFramework::Headless,
];
Expand All @@ -64,7 +69,12 @@ impl UiFramework {
}

pub fn choose_non_vanilla() -> ScaffoldResult<UiFramework> {
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)
Expand Down Expand Up @@ -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);
}
Expand All @@ -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(),
};
Expand All @@ -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!(
Expand Down
4 changes: 4 additions & 0 deletions templates/react/collection.instructions.hbs
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -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}}
});
});
Original file line number Diff line number Diff line change
@@ -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<Uint8Array[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<HolochainError | undefined>();

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 <progress />;
}

return (
<div>
{error ? (
<span>Error fetching the {{lower_case (plural referenceable.name)}}: {error.message}</span>
) : hashes.length > 0 ? (
<div>
{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)}}} />
))}
</div>
) : (
<article>No {{lower_case (plural referenceable.name)}} found{{#if (eq collection_type.type "ByAuthor")}} for this author{{/if}}.</article>
)}
</div>
);
};

{{#if (eq collection_type.type "ByAuthor")}}
interface {{pascal_case collection_name}}Props {
author: AgentPubKey
}
{{/if}}

export default {{pascal_case collection_name}};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CallableCell } from '@holochain/tryorama';
import { NewEntryAction, ActionHash, Record, AppBundleSource, fakeActionHash, fakeAgentPubKey, fakeEntryHash, fakeDnaHash } from '@holochain/client';

Original file line number Diff line number Diff line change
@@ -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<Create>;
app_entry: EntryTypes;
} | {
type: 'EntryUpdated';
action: SignedActionHashed<Update>;
app_entry: EntryTypes;
original_app_entry: EntryTypes;
} | {
type: 'EntryDeleted';
action: SignedActionHashed<Delete>;
original_app_entry: EntryTypes;
} | {
type: 'LinkCreated';
action: SignedActionHashed<CreateLink>;
link_type: string;
} | {
type: 'LinkDeleted';
action: SignedActionHashed<DeleteLink>;
link_type: string;
};

export type EntryTypes = {};
Original file line number Diff line number Diff line change
@@ -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<Record> {
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),
});
}

Loading

0 comments on commit 56ed50f

Please sign in to comment.