In this challenge we are going to create a joke app, where each user (agent) can create jokes and store them on the DHT.
A large portion of the code has been scaffolded using hc scaffold
and we have removed key sections to help you understand what the code generated by hc scaffold is actually doing.
Your mission is to update the code so an agent can create, retrieve, edit and delete jokes.
You will notice the error popup: Attempted to call a zome function that doesn't exist: Zome: jokes Fn create_joke
Tip!
If you see the error
sh: 1: hc: Permission denied
or sh: 1: hc: not found
in your terminal, it means you forgot to run nix develop
!
2. Open up the browser console, navigate to where the error occured. You will see our frontend is trying to make a call to a backend zome function. We need to implement this function on the holochain app to resolve the error.
Hint
Press F12
or right click > inspect element
to open up the dev tools. Select console
you should then see the error.
The error inside the console should point us to our CreateJoke.svelte file
3. Navigate to dnas/jokes/zomes/coordinator/jokes/src/joke.rs
and paste the following code at the top of the file, underneath the use
statements
#[hdk_extern]
pub fn create_joke(joke: Joke) -> ExternResult<Record> {
let joke_hash = create_entry(&EntryTypes::Joke(joke.clone()))?;
let record = get(joke_hash.clone(), GetOptions::default())?.ok_or(
wasm_error!(WasmErrorInner::Guest(String::from("Could not find the newly created Joke")))
)?;
Ok(record)
}
hdk::entry::create_entry hdk::entry::get
If you look back at the playground, this time you should see a new entry has been created in the DHT.
Hint
Press Ctrl + C
in your terminal to stop the holochain process, and npm start
to start it again
Have a look through CreateJoke.svelte
and see how the createJoke
function is implemented.
Given we can get the author of an entry through the create action, why might we want to store the creator field int he entry as well?
You will notice that the source chain of each cell is different. The cell of the Agent who created the joke, contains an entry and its corresponding create action, and the other cell will not have this.
To retrieve an agent's joke from the DHT, we are going to need the Action Hash of that joke.
Inside ui/src/App.svelte
, we are going to create a text field for where we can input an action hash.
let jokeHash = ''
let retrieveJokeHash = ''
$: jokeHash
2. Next we can create our text field element. Paste this code just below where the CreateJoke
component is implemented, inside the <main>
block
<h3 style="margin-bottom: 16px; margin-top: 32px;">Retrieve A Joke!</h3>
<mwc-textfield
type="text"
placeholder="Enter the action hash of a joke..."
value={jokeHash}
on:input={(e) => {
jokeHash = e.currentTarget.value
}}
required
style="margin-bottom: 16px;"
/>
3. Save App.svelte
, and head back into one of the agent windows. You should see the text field displayed
Notice how we didn't need to completely restart the app this time? You only need to restart your app when you change rust code, the front end will use hot reloading to stayu up to date.
4. We are going to need to add a button which triggers the retrieval of a joke from the DHT, and then displays it for the user. To do this, we will use the UI component JokeDetail
, as well as another piece of state to manage its visibility.
Place these lines of code inside the same script
tag of App.svelte
import JokeDetail from './jokes/jokes/JokeDetail.svelte'
<!-- svelte-ignore a11y-click-events-have-key-events -->
<mwc-button
raised
on:click={() => {
retrieveJokeHash = undefined //force reload of joke detail component
retrieveJokeHash = jokeHash
}}
>
Get Joke
</mwc-button>
{#if retrieveJokeHash}
<JokeDetail jokeHash={decodeHashFromBase64(retrieveJokeHash)} />
{/if}
5. Navigate back to dnas/jokes/zomes/coordinator/jokes/src/joke.rs
and paste the following code underneath our create_joke
function
#[hdk_extern]
pub fn get_joke_by_hash(original_joke_hash: ActionHash) -> ExternResult<Option<Record>> {
let Some(details) = get_details(original_joke_hash, GetOptions::default())? else {
return Ok(None);
};
match details {
Details::Record(details) => Ok(Some(details.record)),
_ => {
Err(wasm_error!(WasmErrorInner::Guest(String::from("Malformed get details response"))))
}
}
}
This zome function is called by the JokeDetail
component when it mounts. It takes in the action hash for the joke as an argument, and then returns the record corresponding to it.
Inside that same agents window, open up the console, copy the hash of the action just created, paste it into the other Agents Get Joke text field, and press the Get Joke button.
You should see your newely created joke render on the UI!
You may have noticed that when we retrieve a joke, our JokeDetail
component displays the joke text, as well as an option to edit and delete the joke.
You will notice nothing will happen. Once again, we will need to implement some code to get this working.
This EditJoke
component holds the code for the UI where we can edit jokes. It is already included inside the JokeDetail
component.
const joke: Joke = {
text: text!,
creator: currentJoke.creator,
}
try {
const updateRecord: Record = await client.callZome({
cap_secret: null,
role_name: 'jokes',
zome_name: 'jokes',
fn_name: 'update_joke',
payload: {
original_joke_hash: originalJokeHash,
previous_joke_hash: currentRecord.signed_action.hashed.hash,
updated_joke: joke,
},
})
console.log(
`NEW ACTION HASH: ${encodeHashToBase64(updateRecord.signed_action.hashed.hash)}`
)
dispatch('joke-updated', {
actionHash: updateRecord.signed_action.hashed.hash,
})
} catch (e) {
errorSnackbar.labelText = `Error updating the joke: ${e}`
errorSnackbar.show()
}
When the save
button is clicked in the UI this block of code will make a call to the backend Zome function update_joke
.
5. Save the file, navigate back to dnas/jokes/zomes/coordinator/jokes/src/joke.rs
and paste the following code underneath our get_joke_by_hash
function
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateJokeInput {
pub original_joke_hash: ActionHash,
pub previous_joke_hash: ActionHash,
pub updated_joke: Joke,
}
#[hdk_extern]
pub fn update_joke(input: UpdateJokeInput) -> ExternResult<Record> {
let updated_joke_hash = update_entry(input.previous_joke_hash.clone(), &input.updated_joke)?;
let record = get(updated_joke_hash.clone(), GetOptions::default())?.ok_or(
wasm_error!(WasmErrorInner::Guest(String::from("Could not find the newly updated Joke")))
)?;
Ok(record)
}
Notice how this block of code contains a struct as well as the Zome function. For this update function, we want to send multiple bits of data from the client, but Zome functions can only a take a single parameter. Using a struct type allows us to circumvent this.
8. Look at the source chain for the cell we just edited a joke for. You will see another action has been added.
It's important to understand how updates in Holochain work. When you commit an update action, it will not update the contents of the entry. Instead it will add new entry to to the DHT, and link it to the previous entry via the update action.
This applies to delete actions as well, and it means that any entries once added to the DHT will remain on it forever.
Try putting a console.log in the fetchJoke
function of EditDetail.svelte
to see what the record
looks like when you retrieve multiple action hashes.
What do you think will happen if you edit two separate entries to have the same content?
1. Navigate to the deleteJoke
function inside JokeDetail.svelte
, and write code to create a zome call to delete_joke
.
- The payload should be the
jokeHash
2. Save the file, navigate to dnas/jokes/zomes/coordinator/jokes/src/joke.rs
and write a zome function to delete a joke
Try figure it out yourself!
Hint!
#[hdk_extern]
pub fn delete_joke(original_joke_hash: ActionHash) -> ExternResult<ActionHash> {
delete_entry(original_joke_hash)
}
Just like with editing and creating a joke, deleting a joke should add another action the the Agents source chain, however this action won't be associated with an entry.
Also like update actions, delete actions don't achually remove the previous actions/entries of this piece of data from the DHT. They just change the entry_dht_status
to Dead. You can still access the initial data.
What does the delete_joke function return?
pub fn get_joke_by_hash(original_joke_hash: AnyDhtHash) -> ExternResult<Option<Details>> {
let Some(details) = get_details(original_joke_hash, GetOptions::default())? else {
return Ok(None);
};
match details {
Details::Record(details) => Ok(Some(Details::Record(details))),
Details::Entry(details) => Ok(Some(Details::Entry(details))),
_ => {
Err(wasm_error!(WasmErrorInner::Guest(String::from("Malformed get details response"))))
}
}
}
Have a look at the differences between the two versions of the function and make a note of anything you want to explore further.
Update the fetchJoke function in JokeDetails.svelte
async function fetchJoke() {
loading = true
try {
let details = await client.callZome({
cap_secret: null,
role_name: 'jokes',
zome_name: 'jokes',
fn_name: 'get_joke_by_hash',
payload: jokeHash,
})
if (details) {
if (details.type === 'Record') {
record = details.content.record
let entry_hash = record.signed_action.hashed.content.entry_hash
let entry_details = await client.callZome({
cap_secret: null,
role_name: 'jokes',
zome_name: 'jokes',
fn_name: 'get_joke_by_hash',
payload: entry_hash,
})
console.log('ACTION HASH:', encodeHashToBase64(jokeHash))
console.log('ENTRY HASH: ', encodeHashToBase64(entry_hash))
console.log('LIVENESS:', entry_details.content.entry_dht_status)
joke = decode((record.entry as any).Present.entry) as Joke
} else {
joke = undefined
console.log('entry found')
console.log(details)
}
}
} catch (e) {
error = e
}
loading = false
}
Try creating, updating and deleting various jokes and seeing how the liveness changes
Well done! You made it to the end.